From 171e719b3678fdd324d9e7971b14653a507ec5e1 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sun, 22 Mar 2026 14:04:33 -0700 Subject: [PATCH 1/2] feat(validator): add target-acquisition, flow, and feedback-recovery contract support --- .../dist/commands/compile.d.ts.map | 2 +- .../interfacectl-cli/dist/commands/compile.js | 168 +++- .../dist/commands/prepare-generation.d.ts.map | 2 +- .../dist/commands/prepare-generation.js | 40 + .../dist/commands/prepare-runtime.d.ts.map | 2 +- .../dist/commands/prepare-runtime.js | 39 + .../interfacectl-cli/src/commands/compile.ts | 235 +++++- .../src/commands/prepare-generation.ts | 49 ++ .../src/commands/prepare-runtime.ts | 42 + .../interfacectl-cli/test/compile.test.mjs | 117 ++- .../test/prepare-generation.test.mjs | 64 ++ .../test/prepare-runtime.test.mjs | 64 ++ .../interfacectl-validator/dist/index.d.ts | 2 +- .../dist/index.d.ts.map | 2 +- packages/interfacectl-validator/dist/index.js | 461 ++++++++++- .../schema/web.surface.contract.schema.json | 260 ++++++ .../interfacectl-validator/dist/types.d.ts | 106 ++- .../dist/types.d.ts.map | 2 +- packages/interfacectl-validator/src/index.ts | 656 ++++++++++++++- .../schema/web.surface.contract.schema.json | 260 ++++++ packages/interfacectl-validator/src/types.ts | 158 ++++ .../test/authoring-contract.test.mjs | 284 +++++++ .../test/evaluate-contract.test.mjs | 765 ++++++++++++++++++ 23 files changed, 3759 insertions(+), 21 deletions(-) diff --git a/packages/interfacectl-cli/dist/commands/compile.d.ts.map b/packages/interfacectl-cli/dist/commands/compile.d.ts.map index 3c3510c..976353e 100644 --- a/packages/interfacectl-cli/dist/commands/compile.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/compile.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["../../src/commands/compile.ts"],"names":[],"mappings":"AAqBA,MAAM,WAAW,qBAAqB;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AA64BD,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,qBAAqB,EAC9B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,CAkGjB"} \ No newline at end of file +{"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["../../src/commands/compile.ts"],"names":[],"mappings":"AA8BA,MAAM,WAAW,qBAAqB;IACpC,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAmmCD,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,qBAAqB,EAC9B,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,MAAM,CAAC,CAkGjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/compile.js b/packages/interfacectl-cli/dist/commands/compile.js index 2d0e10c..d9af5e5 100644 --- a/packages/interfacectl-cli/dist/commands/compile.js +++ b/packages/interfacectl-cli/dist/commands/compile.js @@ -5,6 +5,12 @@ import { validateContractStructure, getBundledContractSchema, } from "@surfaces/ import { normalizeContract } from "../utils/normalize.js"; const BUNDLE_VERSION = "2.0"; const SCHEMA_VERSION = "surfaces.web.contract@1"; +const DEFAULT_TARGET_ACQUISITION_MODALITY = "touch-mouse"; +const DEFAULT_MIN_HIT_AREA_PX = 44; +const DEFAULT_MIN_GAP_PX = 8; +const DEFAULT_MIN_EDGE_INSET_PX = 8; +const DEFAULT_DESTRUCTIVE_GAP_PX = 16; +const DEFAULT_FEEDBACK_REQUIRED_STATE_KINDS = ["loading", "empty", "error"]; function sortKeysRecursive(value) { if (value === null || value === undefined) { return value; @@ -53,6 +59,66 @@ function uniqueStrings(values) { } return result; } +function resolveTargetAcquisitionBudget(budget) { + return { + minHitAreaPx: budget?.minHitAreaPx ?? DEFAULT_MIN_HIT_AREA_PX, + minGapPx: budget?.minGapPx ?? DEFAULT_MIN_GAP_PX, + minEdgeInsetPx: budget?.minEdgeInsetPx ?? DEFAULT_MIN_EDGE_INSET_PX, + destructiveGapPx: budget?.destructiveGapPx ?? DEFAULT_DESTRUCTIVE_GAP_PX, + }; +} +function applyTargetAcquisitionBudget(base, budget) { + return { + ...base, + ...(budget?.minHitAreaPx !== undefined ? { minHitAreaPx: budget.minHitAreaPx } : {}), + ...(budget?.minGapPx !== undefined ? { minGapPx: budget.minGapPx } : {}), + ...(budget?.minEdgeInsetPx !== undefined ? { minEdgeInsetPx: budget.minEdgeInsetPx } : {}), + ...(budget?.destructiveGapPx !== undefined + ? { destructiveGapPx: budget.destructiveGapPx } + : {}), + }; +} +function resolveTargetAcquisitionPolicy(policy) { + if (!policy) + return null; + const resolvedBudget = applyTargetAcquisitionBudget(resolveTargetAcquisitionBudget(undefined), policy); + return { + policy: policy.policy, + modality: policy.modality ?? DEFAULT_TARGET_ACQUISITION_MODALITY, + ...resolvedBudget, + ...(policy.viewportOverrides?.length + ? { + viewportOverrides: policy.viewportOverrides.map((override) => ({ + viewport: override.viewport, + ...applyTargetAcquisitionBudget(resolvedBudget, override), + })), + } + : {}), + ...(policy.contextOverrides?.length + ? { + contextOverrides: policy.contextOverrides.map((override) => ({ + context: override.context, + ...applyTargetAcquisitionBudget(resolvedBudget, override), + })), + } + : {}), + }; +} +function resolveFeedbackRecoveryPolicy(policy, contexts = []) { + if (!policy) + return null; + return { + policy: policy.policy, + requiredStateKinds: [ + ...new Set([ + ...(policy.requiredStateKinds ?? DEFAULT_FEEDBACK_REQUIRED_STATE_KINDS), + ...((Array.isArray(contexts) ? contexts : []) + .map((context) => context?.kind) + .filter(Boolean)), + ]), + ], + }; +} function getPolicyRank(policy) { switch (policy) { case "strict": @@ -155,14 +221,16 @@ function buildPolicySeverities(contract, surface) { const structure = maxPolicy(surface.requiredSections.length > 0 ? "strict" : "off", surface.layout.landingPattern?.policy); const layout = maxPolicy(surface.layout.pageFrame?.enforcement, surface.layout.chromePolicy?.policy, surface.layout.landingPattern?.policy, surface.layout.landingPattern?.marketingLayoutPolicy); const visual = maxPolicy(contract.color.policy, surface.icons?.policy, contract.tokens?.typography?.policy, contract.tokens?.motion?.policy, surface.marketingTypographyPolicy); - const interaction = surface.flows?.policy ?? "off"; - const runtime = maxPolicy(surface.runtime?.policy, boundary, structure, layout, visual, interaction); + const feedbackRecoveryPolicy = surface.runtime?.feedbackRecovery?.policy ?? "off"; + const interaction = maxPolicy(surface.flows?.policy, feedbackRecoveryPolicy); + const targetAcquisitionPolicy = surface.layout.targetAcquisition?.policy ?? "off"; + const runtime = maxPolicy(surface.runtime?.policy, boundary, structure, layout, visual, interaction, targetAcquisitionPolicy, feedbackRecoveryPolicy); return { boundary, structure, layout, visual, - interaction, + interaction: maxPolicy(interaction, targetAcquisitionPolicy, feedbackRecoveryPolicy), runtime, }; } @@ -303,6 +371,9 @@ function buildConstraintsPayload(contract, surface) { ...(surface.layout.pageFrame ? { pageFrame: surface.layout.pageFrame } : {}), ...(surface.layout.chromePolicy ? { chromePolicy: surface.layout.chromePolicy } : {}), ...(surface.layout.landingPattern ? { landingPattern: surface.layout.landingPattern } : {}), + ...(resolveTargetAcquisitionPolicy(surface.layout.targetAcquisition) + ? { targetAcquisition: resolveTargetAcquisitionPolicy(surface.layout.targetAcquisition) } + : {}), ...(surface.viewports ? { viewports: surface.viewports } : {}), }, ...(surface.icons ? { icons: surface.icons } : {}), @@ -320,6 +391,10 @@ function buildGuidance(contract, surface, sections) { const prohibitedRoles = uniqueStrings([...shellOwns, ...mustNotEmit]); const hasLandingPattern = Boolean(surface.layout.landingPattern); const hasResponsiveRules = sections.some((section) => section.responsive?.rules?.length); + const hasTargetAcquisition = Boolean(surface.layout.targetAcquisition) && + surface.layout.targetAcquisition?.policy !== "off"; + const hasFeedbackRecovery = Boolean(surface.runtime?.feedbackRecovery) && + surface.runtime?.feedbackRecovery?.policy !== "off"; const hasVisualPolicy = contract.color.policy !== "off" || Boolean(surface.icons && surface.icons.policy !== "off") || Boolean(contract.tokens && Object.keys(contract.tokens).length > 0); @@ -354,6 +429,8 @@ function buildGuidance(contract, surface, sections) { hasLandingPattern ? "landing-pattern" : undefined, hasResponsiveRules ? "responsive" : undefined, "layout", + hasTargetAcquisition ? "target-acquisition" : undefined, + hasFeedbackRecovery ? "feedback-recovery" : undefined, hasVisualPolicy ? "visual" : undefined, surface.flows ? "flows" : undefined, ]), @@ -374,12 +451,15 @@ function buildGenerationPayload(contract, surface, sections) { const mustNotEmit = surface.mustNotEmit ?? []; const requiredSections = surface.requiredSections; const landingPattern = surface.layout.landingPattern; + const targetAcquisition = resolveTargetAcquisitionPolicy(surface.layout.targetAcquisition); + const feedbackRecovery = resolveFeedbackRecoveryPolicy(surface.runtime?.feedbackRecovery, surface.runtime?.contexts); const observationRefs = buildObservationRefs(contract); const governance = buildGovernancePayload(surface); const adaptation = { policy: surface.runtime?.policy ?? buildPolicySeverities(contract, surface).runtime, mutationEnvelope: buildMutationEnvelope(surface, sections), contextIds: (surface.runtime?.contexts ?? []).map((context) => context.id), + ...(feedbackRecovery ? { feedbackRecovery } : {}), }; return { identity: { @@ -413,6 +493,7 @@ function buildGenerationPayload(contract, surface, sections) { ...(surface.layout.pageFrame ? { pageFrame: surface.layout.pageFrame } : {}), ...(surface.layout.chromePolicy ? { chromePolicy: surface.layout.chromePolicy } : {}), ...(landingPattern ? { landingPattern } : {}), + ...(targetAcquisition ? { targetAcquisition } : {}), viewportIds: (surface.viewports ?? []).map((viewport) => viewport.id), }, visual: { @@ -471,6 +552,8 @@ function buildRepairMapPayload(contract, surface, sections) { const shellOwns = contract.shell?.owns ?? []; const mustNotEmit = surface.mustNotEmit ?? []; const prohibitedRoles = uniqueStrings([...shellOwns, ...mustNotEmit]); + const targetAcquisition = resolveTargetAcquisitionPolicy(surface.layout.targetAcquisition); + const feedbackRecovery = resolveFeedbackRecoveryPolicy(surface.runtime?.feedbackRecovery, surface.runtime?.contexts); if (prohibitedRoles.length > 0) { addRepair(repairs, "shell.primitive.disallowed", "high", "boundary", { type: "remove-prohibited-primitives", @@ -589,6 +672,10 @@ function buildRepairMapPayload(contract, surface, sections) { }); } if (surface.flows && surface.flows.policy !== "off") { + addRepair(repairs, "descriptor.flows.missing", "high", "interaction", { + type: "restore-flow-observability", + requirements: surface.flows.requirements, + }); addRepair(repairs, "flow.required.missing", "high", "interaction", { type: "restore-required-flows", requirements: surface.flows.requirements, @@ -601,6 +688,60 @@ function buildRepairMapPayload(contract, surface, sections) { type: "restore-required-transitions", requirements: surface.flows.requirements, }); + addRepair(repairs, "flow.unobservable", "medium", "interaction", { + type: "restore-flow-observability", + requirements: surface.flows.requirements, + }); + } + if (targetAcquisition && targetAcquisition.policy !== "off") { + addRepair(repairs, "target.hit-area-too-small", "medium", "interaction", { + type: "increase-hit-area", + policy: targetAcquisition.policy, + modality: targetAcquisition.modality, + minHitAreaPx: targetAcquisition.minHitAreaPx, + }); + addRepair(repairs, "target.gap-too-tight", "medium", "interaction", { + type: "increase-target-gap", + policy: targetAcquisition.policy, + modality: targetAcquisition.modality, + minGapPx: targetAcquisition.minGapPx, + }); + addRepair(repairs, "target.edge-inset-too-small", "medium", "interaction", { + type: "move-away-from-edge", + policy: targetAcquisition.policy, + modality: targetAcquisition.modality, + minEdgeInsetPx: targetAcquisition.minEdgeInsetPx, + }); + addRepair(repairs, "target.destructive-too-close", "high", "interaction", { + type: "separate-destructive-action", + policy: targetAcquisition.policy, + modality: targetAcquisition.modality, + destructiveGapPx: targetAcquisition.destructiveGapPx, + }); + } + if (feedbackRecovery && feedbackRecovery.policy !== "off") { + addRepair(repairs, "feedback.state-missing", "high", "runtime", { + type: "add-loading-state", + policy: feedbackRecovery.policy, + requiredStateKinds: feedbackRecovery.requiredStateKinds, + }); + addRepair(repairs, "feedback.state-missing", "high", "runtime", { + type: "add-empty-state", + policy: feedbackRecovery.policy, + requiredStateKinds: feedbackRecovery.requiredStateKinds, + }); + addRepair(repairs, "feedback.recovery-action-missing", "medium", "runtime", { + type: "add-error-retry", + policy: feedbackRecovery.policy, + }); + addRepair(repairs, "feedback.last-good-content-missing", "medium", "runtime", { + type: "preserve-last-good-content", + policy: feedbackRecovery.policy, + }); + addRepair(repairs, "feedback.pending-action-not-blocked", "medium", "runtime", { + type: "disable-pending-submit", + policy: feedbackRecovery.policy, + }); } return { provenance: makeBundleProvenance(contract, surface.id), @@ -610,6 +751,8 @@ function buildRepairMapPayload(contract, surface, sections) { function buildRuntimePayload(contract, surface, sections, components) { const policySeverities = buildPolicySeverities(contract, surface); const mutationEnvelope = buildMutationEnvelope(surface, sections); + const targetAcquisition = resolveTargetAcquisitionPolicy(surface.layout.targetAcquisition); + const feedbackRecovery = resolveFeedbackRecoveryPolicy(surface.runtime?.feedbackRecovery, surface.runtime?.contexts); return { provenance: makeBundleProvenance(contract, surface.id), identity: { @@ -623,6 +766,7 @@ function buildRuntimePayload(contract, surface, sections, components) { policySeverities, mutationEnvelope, contexts: surface.runtime?.contexts ?? [], + ...(feedbackRecovery ? { feedbackRecovery } : {}), boundary: { shellOwns: contract.shell?.owns ?? [], contentSlot: contract.shell?.contentSlot ?? null, @@ -633,6 +777,15 @@ function buildRuntimePayload(contract, surface, sections, components) { requiredSections: surface.requiredSections, allowedSections: sections.map((section) => section.id), allowedComponents: components.map((component) => component.id), + ...(surface.flows + ? { + flowSummary: { + policy: surface.flows.policy, + flowIds: surface.flows.requirements.map((flow) => flow.flowId), + requirementCount: surface.flows.requirements.length, + }, + } + : {}), }, layout: { maxContentWidth: surface.layout.maxContentWidth, @@ -649,7 +802,14 @@ function buildRuntimePayload(contract, surface, sections, components) { ...(contract.tokens ? { tokens: contract.tokens } : {}), ...(surface.icons ? { icons: surface.icons } : {}), }, - ...(surface.flows ? { interaction: { flows: surface.flows } } : {}), + ...((surface.flows || targetAcquisition) + ? { + interaction: { + ...(surface.flows ? { flows: surface.flows } : {}), + ...(targetAcquisition ? { targetAcquisition } : {}), + }, + } + : {}), }, refs: { contract: "../../contract/normalized.json", diff --git a/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map index 968c7e1..bac370c 100644 --- a/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/prepare-generation.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"prepare-generation.d.ts","sourceRoot":"","sources":["../../src/commands/prepare-generation.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,UAAU,EACf,KAAK,2BAA2B,EACjC,MAAM,sBAAsB,CAAC;AAG9B,MAAM,WAAW,+BAA+B;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,KAAK,cAAc,GAAG,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;AAEhD,UAAU,iBAAiB;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,cAAc,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AA2ID,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBApEnD,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;;;;;;;;;;;;EAmInE;AAED,wBAAgB,6BAA6B,CAC3C,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,GAAG,SAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBAxIU,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;;;;;;;;;;;;EA4InE;AAgBD,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,MAAM,CAAC,CA+BjB"} \ No newline at end of file +{"version":3,"file":"prepare-generation.d.ts","sourceRoot":"","sources":["../../src/commands/prepare-generation.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,UAAU,EACf,KAAK,2BAA2B,EACjC,MAAM,sBAAsB,CAAC;AAG9B,MAAM,WAAW,+BAA+B;IAC9C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,KAAK,cAAc,GAAG,MAAM,GAAG,QAAQ,GAAG,KAAK,CAAC;AAEhD,UAAU,iBAAiB;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,cAAc,CAAC;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AA4LD,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBAjHnD,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;;;;;;;;;;;;EAgLnE;AAED,wBAAgB,6BAA6B,CAC3C,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,GAAG,SAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBArLU,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;;;;;;;;;;;;EAyLnE;AAgBD,wBAAsB,2BAA2B,CAC/C,OAAO,EAAE,+BAA+B,GACvC,OAAO,CAAC,MAAM,CAAC,CA+BjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/prepare-generation.js b/packages/interfacectl-cli/dist/commands/prepare-generation.js index 739b6a3..ae3ecb4 100644 --- a/packages/interfacectl-cli/dist/commands/prepare-generation.js +++ b/packages/interfacectl-cli/dist/commands/prepare-generation.js @@ -54,6 +54,10 @@ function buildSummary(bundle) { const adaptation = asRecord(generation.adaptation); const mutationEnvelope = asRecord(adaptation.mutationEnvelope); const guidance = asRecord(generation.guidance); + const layout = asRecord(generation.layout); + const flowSummary = asRecord(structure.flowSummary); + const targetAcquisition = asRecord(layout.targetAcquisition); + const feedbackRecovery = asRecord(adaptation.feedbackRecovery); const repairs = getRepairSummary(bundle.surface.repairMap.value); const focusOrder = asStringArray(guidance.generationFocusOrder); const requiredSectionIds = asStringArray(structure.requiredSectionIds); @@ -92,6 +96,33 @@ function buildSummary(bundle) { detail: `Allowed mutation mode: ${mutationMode}.`, }); } + if (Object.keys(targetAcquisition).length > 0) { + checklist.push({ + id: "target-acquisition", + label: "Keep targets easy to acquire", + detail: `Use ${asString(targetAcquisition.modality) ?? "touch-mouse"} budgets: ` + + `${String(targetAcquisition.minHitAreaPx ?? 44)}px targets, ` + + `${String(targetAcquisition.minGapPx ?? 8)}px gaps, ` + + `${String(targetAcquisition.minEdgeInsetPx ?? 8)}px edge inset, ` + + `${String(targetAcquisition.destructiveGapPx ?? 16)}px destructive separation.`, + }); + } + if (Object.keys(feedbackRecovery).length > 0) { + checklist.push({ + id: "feedback-recovery", + label: "Cover async feedback and recovery states", + detail: `Support async states ${asStringArray(feedbackRecovery.requiredStateKinds).join(", ")} ` + + "with explicit loading, empty, and error recovery affordances.", + }); + } + if (Object.keys(flowSummary).length > 0) { + checklist.push({ + id: "flows", + label: "Preserve required task flows", + detail: `Support flow requirements for ${asStringArray(flowSummary.flowIds).join(", ")} ` + + "with the declared steps, transitions, and terminal behavior.", + }); + } if (repairs.length > 0) { checklist.push({ id: "repair-priorities", @@ -115,6 +146,15 @@ function buildSummary(bundle) { if (mutationMode) { textParts.push(`stay within ${mutationMode} mutation scope`); } + if (Object.keys(targetAcquisition).length > 0) { + textParts.push(`honor ${String(targetAcquisition.minHitAreaPx ?? 44)}px targets and ${String(targetAcquisition.minGapPx ?? 8)}px spacing`); + } + if (Object.keys(feedbackRecovery).length > 0) { + textParts.push(`cover async feedback states ${asStringArray(feedbackRecovery.requiredStateKinds).join(", ")}`); + } + if (Object.keys(flowSummary).length > 0) { + textParts.push(`preserve required flows ${asStringArray(flowSummary.flowIds).join(", ")}`); + } if (repairs.length > 0) { textParts.push(`prioritize repairs ${repairs.slice(0, 3).map((repair) => repair.code).join(", ")}`); } diff --git a/packages/interfacectl-cli/dist/commands/prepare-runtime.d.ts.map b/packages/interfacectl-cli/dist/commands/prepare-runtime.d.ts.map index f121f9b..1eceac5 100644 --- a/packages/interfacectl-cli/dist/commands/prepare-runtime.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/prepare-runtime.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"prepare-runtime.d.ts","sourceRoot":"","sources":["../../src/commands/prepare-runtime.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,UAAU,EACf,KAAK,2BAA2B,EACjC,MAAM,sBAAsB,CAAC;AAG9B,MAAM,WAAW,4BAA4B;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAsFD,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBAjDhD,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;EA4FnE;AAED,wBAAgB,0BAA0B,CACxC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,GAAG,SAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBAjGU,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;EAqGnE;AAgBD,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,4BAA4B,GACpC,OAAO,CAAC,MAAM,CAAC,CA+BjB"} \ No newline at end of file +{"version":3,"file":"prepare-runtime.d.ts","sourceRoot":"","sources":["../../src/commands/prepare-runtime.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,UAAU,EACf,KAAK,2BAA2B,EACjC,MAAM,sBAAsB,CAAC;AAG9B,MAAM,WAAW,4BAA4B;IAC3C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAgID,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBAvFhD,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;EAkInE;AAED,wBAAgB,0BAA0B,CACxC,UAAU,EAAE,MAAM,EAClB,SAAS,EAAE,MAAM,EACjB,GAAG,SAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gBAvIU,MAAM;mBAAS,MAAM;oBAAU,MAAM;;;;;;EA2InE;AAgBD,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,4BAA4B,GACpC,OAAO,CAAC,MAAM,CAAC,CA+BjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/prepare-runtime.js b/packages/interfacectl-cli/dist/commands/prepare-runtime.js index 746a345..274f9a9 100644 --- a/packages/interfacectl-cli/dist/commands/prepare-runtime.js +++ b/packages/interfacectl-cli/dist/commands/prepare-runtime.js @@ -22,9 +22,13 @@ function buildSummary(bundle) { const runtimeDoc = asRecord(bundle.surface.runtime.value); const runtime = asRecord(runtimeDoc.runtime); const structure = asRecord(runtime.structure); + const flowSummary = asRecord(structure.flowSummary); const mutationEnvelope = asRecord(runtime.mutationEnvelope); const policySeverities = asRecord(runtime.policySeverities); const contexts = Array.isArray(runtime.contexts) ? runtime.contexts : []; + const feedbackRecovery = asRecord(runtime.feedbackRecovery); + const interaction = asRecord(runtime.interaction); + const targetAcquisition = asRecord(interaction.targetAcquisition); const requiredSectionIds = asStringArray(structure.requiredSections); const mutationMode = asString(mutationEnvelope.mode) ?? "content-only"; const strictCategories = Object.entries(policySeverities) @@ -57,11 +61,46 @@ function buildSummary(bundle) { detail: `Context rules: ${contexts.map((context) => asString(asRecord(context).id) ?? "unknown").join(", ")}.`, }); } + if (Object.keys(targetAcquisition).length > 0) { + checklist.push({ + id: "target-acquisition", + label: "Keep controls easy to acquire", + detail: `Runtime should preserve ${String(targetAcquisition.minHitAreaPx ?? 44)}px targets, ` + + `${String(targetAcquisition.minGapPx ?? 8)}px gaps, ` + + `${String(targetAcquisition.minEdgeInsetPx ?? 8)}px edge inset, ` + + `${String(targetAcquisition.destructiveGapPx ?? 16)}px destructive separation.`, + }); + } + if (Object.keys(feedbackRecovery).length > 0) { + checklist.push({ + id: "feedback-recovery", + label: "Honor async feedback and recovery policy", + detail: `Runtime should observe async states ${asStringArray(feedbackRecovery.requiredStateKinds).join(", ")} ` + + "and preserve required recovery affordances.", + }); + } + if (Object.keys(flowSummary).length > 0) { + checklist.push({ + id: "flows", + label: "Keep required task flows intact", + detail: `Runtime should preserve flow requirements for ${asStringArray(flowSummary.flowIds).join(", ")} ` + + "including required steps, transitions, and terminal states.", + }); + } const textParts = [ requiredSectionIds.length > 0 ? `preserve required sections ${requiredSectionIds.join(", ")}` : undefined, `stay within ${mutationMode} mutation scope`, strictCategories.length > 0 ? `treat ${strictCategories.join(", ")} as strict runtime categories` : undefined, contexts.length > 0 ? `evaluate ${contexts.length} contextual runtime rules` : undefined, + Object.keys(targetAcquisition).length > 0 + ? `preserve ${String(targetAcquisition.minHitAreaPx ?? 44)}px targets and ${String(targetAcquisition.minGapPx ?? 8)}px gaps` + : undefined, + Object.keys(feedbackRecovery).length > 0 + ? `observe async feedback states ${asStringArray(feedbackRecovery.requiredStateKinds).join(", ")}` + : undefined, + Object.keys(flowSummary).length > 0 + ? `preserve required flows ${asStringArray(flowSummary.flowIds).join(", ")}` + : undefined, ].filter((value) => Boolean(value)); return { text: textParts.length > 0 diff --git a/packages/interfacectl-cli/src/commands/compile.ts b/packages/interfacectl-cli/src/commands/compile.ts index 3568eb8..dd74467 100644 --- a/packages/interfacectl-cli/src/commands/compile.ts +++ b/packages/interfacectl-cli/src/commands/compile.ts @@ -7,7 +7,10 @@ import type { ContractSection, ContractSlot, ContractSurface, + FeedbackRecoveryPolicy, InterfaceContract, + SurfaceRuntimeContextRule, + TargetAcquisitionPolicy, } from "@surfaces/interfacectl-validator"; import { validateContractStructure, @@ -18,6 +21,12 @@ import { normalizeContract } from "../utils/normalize.js"; const BUNDLE_VERSION = "2.0"; const SCHEMA_VERSION = "surfaces.web.contract@1"; +const DEFAULT_TARGET_ACQUISITION_MODALITY = "touch-mouse"; +const DEFAULT_MIN_HIT_AREA_PX = 44; +const DEFAULT_MIN_GAP_PX = 8; +const DEFAULT_MIN_EDGE_INSET_PX = 8; +const DEFAULT_DESTRUCTIVE_GAP_PX = 16; +const DEFAULT_FEEDBACK_REQUIRED_STATE_KINDS = ["loading", "empty", "error"]; export interface CompileCommandOptions { contractPath: string; @@ -136,6 +145,98 @@ function uniqueStrings(values: Array): string[] { return result; } +function resolveTargetAcquisitionBudget( + budget: { + minHitAreaPx?: number; + minGapPx?: number; + minEdgeInsetPx?: number; + destructiveGapPx?: number; + } | undefined, +) { + return { + minHitAreaPx: budget?.minHitAreaPx ?? DEFAULT_MIN_HIT_AREA_PX, + minGapPx: budget?.minGapPx ?? DEFAULT_MIN_GAP_PX, + minEdgeInsetPx: budget?.minEdgeInsetPx ?? DEFAULT_MIN_EDGE_INSET_PX, + destructiveGapPx: budget?.destructiveGapPx ?? DEFAULT_DESTRUCTIVE_GAP_PX, + }; +} + +function applyTargetAcquisitionBudget(base: T, budget: { + minHitAreaPx?: number; + minGapPx?: number; + minEdgeInsetPx?: number; + destructiveGapPx?: number; +} | undefined): T { + return { + ...base, + ...(budget?.minHitAreaPx !== undefined ? { minHitAreaPx: budget.minHitAreaPx } : {}), + ...(budget?.minGapPx !== undefined ? { minGapPx: budget.minGapPx } : {}), + ...(budget?.minEdgeInsetPx !== undefined ? { minEdgeInsetPx: budget.minEdgeInsetPx } : {}), + ...(budget?.destructiveGapPx !== undefined + ? { destructiveGapPx: budget.destructiveGapPx } + : {}), + }; +} + +function resolveTargetAcquisitionPolicy( + policy: TargetAcquisitionPolicy | undefined, +) { + if (!policy) return null; + + const resolvedBudget = applyTargetAcquisitionBudget( + resolveTargetAcquisitionBudget(undefined), + policy, + ); + + return { + policy: policy.policy, + modality: policy.modality ?? DEFAULT_TARGET_ACQUISITION_MODALITY, + ...resolvedBudget, + ...(policy.viewportOverrides?.length + ? { + viewportOverrides: policy.viewportOverrides.map((override) => ({ + viewport: override.viewport, + ...applyTargetAcquisitionBudget(resolvedBudget, override), + })), + } + : {}), + ...(policy.contextOverrides?.length + ? { + contextOverrides: policy.contextOverrides.map((override) => ({ + context: override.context, + ...applyTargetAcquisitionBudget(resolvedBudget, override), + })), + } + : {}), + }; +} + +function resolveFeedbackRecoveryPolicy( + policy: FeedbackRecoveryPolicy | undefined, + contexts: SurfaceRuntimeContextRule[] | undefined = [], +) { + if (!policy) return null; + + return { + policy: policy.policy, + requiredStateKinds: [ + ...new Set( + [ + ...(policy.requiredStateKinds ?? DEFAULT_FEEDBACK_REQUIRED_STATE_KINDS), + ...((Array.isArray(contexts) ? contexts : []) + .map((context) => context?.kind) + .filter(Boolean)), + ], + ), + ], + }; +} + type PolicyLevel = "off" | "warn" | "strict"; function getPolicyRank(policy: PolicyLevel | undefined): number { @@ -276,16 +377,27 @@ function buildPolicySeverities( contract.tokens?.motion?.policy, surface.marketingTypographyPolicy, ); - const interaction = surface.flows?.policy ?? "off"; - const runtime = maxPolicy(surface.runtime?.policy, boundary, structure, layout, visual, interaction); - - return { + const feedbackRecoveryPolicy = surface.runtime?.feedbackRecovery?.policy ?? "off"; + const interaction = maxPolicy(surface.flows?.policy, feedbackRecoveryPolicy); + const targetAcquisitionPolicy = surface.layout.targetAcquisition?.policy ?? "off"; + const runtime = maxPolicy( + surface.runtime?.policy, boundary, structure, layout, visual, interaction, - runtime, + targetAcquisitionPolicy, + feedbackRecoveryPolicy, + ); + + return { + boundary, + structure, + layout, + visual, + interaction: maxPolicy(interaction, targetAcquisitionPolicy, feedbackRecoveryPolicy), + runtime, }; } @@ -467,6 +579,9 @@ function buildConstraintsPayload( ...(surface.layout.pageFrame ? { pageFrame: surface.layout.pageFrame } : {}), ...(surface.layout.chromePolicy ? { chromePolicy: surface.layout.chromePolicy } : {}), ...(surface.layout.landingPattern ? { landingPattern: surface.layout.landingPattern } : {}), + ...(resolveTargetAcquisitionPolicy(surface.layout.targetAcquisition) + ? { targetAcquisition: resolveTargetAcquisitionPolicy(surface.layout.targetAcquisition) } + : {}), ...(surface.viewports ? { viewports: surface.viewports } : {}), }, ...(surface.icons ? { icons: surface.icons } : {}), @@ -489,6 +604,12 @@ function buildGuidance( const prohibitedRoles = uniqueStrings([...shellOwns, ...mustNotEmit]); const hasLandingPattern = Boolean(surface.layout.landingPattern); const hasResponsiveRules = sections.some((section) => section.responsive?.rules?.length); + const hasTargetAcquisition = + Boolean(surface.layout.targetAcquisition) && + surface.layout.targetAcquisition?.policy !== "off"; + const hasFeedbackRecovery = + Boolean(surface.runtime?.feedbackRecovery) && + surface.runtime?.feedbackRecovery?.policy !== "off"; const hasVisualPolicy = contract.color.policy !== "off" || Boolean(surface.icons && surface.icons.policy !== "off") || @@ -525,6 +646,8 @@ function buildGuidance( hasLandingPattern ? "landing-pattern" : undefined, hasResponsiveRules ? "responsive" : undefined, "layout", + hasTargetAcquisition ? "target-acquisition" : undefined, + hasFeedbackRecovery ? "feedback-recovery" : undefined, hasVisualPolicy ? "visual" : undefined, surface.flows ? "flows" : undefined, ]), @@ -551,12 +674,20 @@ function buildGenerationPayload( const mustNotEmit = surface.mustNotEmit ?? []; const requiredSections = surface.requiredSections; const landingPattern = surface.layout.landingPattern; + const targetAcquisition = resolveTargetAcquisitionPolicy( + surface.layout.targetAcquisition, + ); + const feedbackRecovery = resolveFeedbackRecoveryPolicy( + surface.runtime?.feedbackRecovery, + surface.runtime?.contexts, + ); const observationRefs = buildObservationRefs(contract); const governance = buildGovernancePayload(surface); const adaptation = { policy: surface.runtime?.policy ?? buildPolicySeverities(contract, surface).runtime, mutationEnvelope: buildMutationEnvelope(surface, sections), contextIds: (surface.runtime?.contexts ?? []).map((context) => context.id), + ...(feedbackRecovery ? { feedbackRecovery } : {}), }; return { @@ -591,6 +722,7 @@ function buildGenerationPayload( ...(surface.layout.pageFrame ? { pageFrame: surface.layout.pageFrame } : {}), ...(surface.layout.chromePolicy ? { chromePolicy: surface.layout.chromePolicy } : {}), ...(landingPattern ? { landingPattern } : {}), + ...(targetAcquisition ? { targetAcquisition } : {}), viewportIds: (surface.viewports ?? []).map((viewport) => viewport.id), }, visual: { @@ -667,6 +799,13 @@ function buildRepairMapPayload( const shellOwns = contract.shell?.owns ?? []; const mustNotEmit = surface.mustNotEmit ?? []; const prohibitedRoles = uniqueStrings([...shellOwns, ...mustNotEmit]); + const targetAcquisition = resolveTargetAcquisitionPolicy( + surface.layout.targetAcquisition, + ); + const feedbackRecovery = resolveFeedbackRecoveryPolicy( + surface.runtime?.feedbackRecovery, + surface.runtime?.contexts, + ); if (prohibitedRoles.length > 0) { addRepair(repairs, "shell.primitive.disallowed", "high", "boundary", { @@ -801,6 +940,10 @@ function buildRepairMapPayload( } if (surface.flows && surface.flows.policy !== "off") { + addRepair(repairs, "descriptor.flows.missing", "high", "interaction", { + type: "restore-flow-observability", + requirements: surface.flows.requirements, + }); addRepair(repairs, "flow.required.missing", "high", "interaction", { type: "restore-required-flows", requirements: surface.flows.requirements, @@ -813,6 +956,62 @@ function buildRepairMapPayload( type: "restore-required-transitions", requirements: surface.flows.requirements, }); + addRepair(repairs, "flow.unobservable", "medium", "interaction", { + type: "restore-flow-observability", + requirements: surface.flows.requirements, + }); + } + + if (targetAcquisition && targetAcquisition.policy !== "off") { + addRepair(repairs, "target.hit-area-too-small", "medium", "interaction", { + type: "increase-hit-area", + policy: targetAcquisition.policy, + modality: targetAcquisition.modality, + minHitAreaPx: targetAcquisition.minHitAreaPx, + }); + addRepair(repairs, "target.gap-too-tight", "medium", "interaction", { + type: "increase-target-gap", + policy: targetAcquisition.policy, + modality: targetAcquisition.modality, + minGapPx: targetAcquisition.minGapPx, + }); + addRepair(repairs, "target.edge-inset-too-small", "medium", "interaction", { + type: "move-away-from-edge", + policy: targetAcquisition.policy, + modality: targetAcquisition.modality, + minEdgeInsetPx: targetAcquisition.minEdgeInsetPx, + }); + addRepair(repairs, "target.destructive-too-close", "high", "interaction", { + type: "separate-destructive-action", + policy: targetAcquisition.policy, + modality: targetAcquisition.modality, + destructiveGapPx: targetAcquisition.destructiveGapPx, + }); + } + + if (feedbackRecovery && feedbackRecovery.policy !== "off") { + addRepair(repairs, "feedback.state-missing", "high", "runtime", { + type: "add-loading-state", + policy: feedbackRecovery.policy, + requiredStateKinds: feedbackRecovery.requiredStateKinds, + }); + addRepair(repairs, "feedback.state-missing", "high", "runtime", { + type: "add-empty-state", + policy: feedbackRecovery.policy, + requiredStateKinds: feedbackRecovery.requiredStateKinds, + }); + addRepair(repairs, "feedback.recovery-action-missing", "medium", "runtime", { + type: "add-error-retry", + policy: feedbackRecovery.policy, + }); + addRepair(repairs, "feedback.last-good-content-missing", "medium", "runtime", { + type: "preserve-last-good-content", + policy: feedbackRecovery.policy, + }); + addRepair(repairs, "feedback.pending-action-not-blocked", "medium", "runtime", { + type: "disable-pending-submit", + policy: feedbackRecovery.policy, + }); } return { @@ -829,6 +1028,13 @@ function buildRuntimePayload( ) { const policySeverities = buildPolicySeverities(contract, surface); const mutationEnvelope = buildMutationEnvelope(surface, sections); + const targetAcquisition = resolveTargetAcquisitionPolicy( + surface.layout.targetAcquisition, + ); + const feedbackRecovery = resolveFeedbackRecoveryPolicy( + surface.runtime?.feedbackRecovery, + surface.runtime?.contexts, + ); return { provenance: makeBundleProvenance(contract, surface.id), @@ -843,6 +1049,7 @@ function buildRuntimePayload( policySeverities, mutationEnvelope, contexts: surface.runtime?.contexts ?? [], + ...(feedbackRecovery ? { feedbackRecovery } : {}), boundary: { shellOwns: contract.shell?.owns ?? [], contentSlot: contract.shell?.contentSlot ?? null, @@ -853,6 +1060,15 @@ function buildRuntimePayload( requiredSections: surface.requiredSections, allowedSections: sections.map((section) => section.id), allowedComponents: components.map((component) => component.id), + ...(surface.flows + ? { + flowSummary: { + policy: surface.flows.policy, + flowIds: surface.flows.requirements.map((flow) => flow.flowId), + requirementCount: surface.flows.requirements.length, + }, + } + : {}), }, layout: { maxContentWidth: surface.layout.maxContentWidth, @@ -869,7 +1085,14 @@ function buildRuntimePayload( ...(contract.tokens ? { tokens: contract.tokens } : {}), ...(surface.icons ? { icons: surface.icons } : {}), }, - ...(surface.flows ? { interaction: { flows: surface.flows } } : {}), + ...((surface.flows || targetAcquisition) + ? { + interaction: { + ...(surface.flows ? { flows: surface.flows } : {}), + ...(targetAcquisition ? { targetAcquisition } : {}), + }, + } + : {}), }, refs: { contract: "../../contract/normalized.json", diff --git a/packages/interfacectl-cli/src/commands/prepare-generation.ts b/packages/interfacectl-cli/src/commands/prepare-generation.ts index 1be72b7..b9af696 100644 --- a/packages/interfacectl-cli/src/commands/prepare-generation.ts +++ b/packages/interfacectl-cli/src/commands/prepare-generation.ts @@ -83,6 +83,10 @@ function buildSummary(bundle: LoadedCompiledSurfaceBundle) { const adaptation = asRecord(generation.adaptation); const mutationEnvelope = asRecord(adaptation.mutationEnvelope); const guidance = asRecord(generation.guidance); + const layout = asRecord(generation.layout); + const flowSummary = asRecord(structure.flowSummary); + const targetAcquisition = asRecord(layout.targetAcquisition); + const feedbackRecovery = asRecord(adaptation.feedbackRecovery); const repairs = getRepairSummary(bundle.surface.repairMap.value); const focusOrder = asStringArray(guidance.generationFocusOrder); @@ -123,6 +127,36 @@ function buildSummary(bundle: LoadedCompiledSurfaceBundle) { detail: `Allowed mutation mode: ${mutationMode}.`, }); } + if (Object.keys(targetAcquisition).length > 0) { + checklist.push({ + id: "target-acquisition", + label: "Keep targets easy to acquire", + detail: + `Use ${asString(targetAcquisition.modality) ?? "touch-mouse"} budgets: ` + + `${String(targetAcquisition.minHitAreaPx ?? 44)}px targets, ` + + `${String(targetAcquisition.minGapPx ?? 8)}px gaps, ` + + `${String(targetAcquisition.minEdgeInsetPx ?? 8)}px edge inset, ` + + `${String(targetAcquisition.destructiveGapPx ?? 16)}px destructive separation.`, + }); + } + if (Object.keys(feedbackRecovery).length > 0) { + checklist.push({ + id: "feedback-recovery", + label: "Cover async feedback and recovery states", + detail: + `Support async states ${asStringArray(feedbackRecovery.requiredStateKinds).join(", ")} ` + + "with explicit loading, empty, and error recovery affordances.", + }); + } + if (Object.keys(flowSummary).length > 0) { + checklist.push({ + id: "flows", + label: "Preserve required task flows", + detail: + `Support flow requirements for ${asStringArray(flowSummary.flowIds).join(", ")} ` + + "with the declared steps, transitions, and terminal behavior.", + }); + } if (repairs.length > 0) { checklist.push({ id: "repair-priorities", @@ -147,6 +181,21 @@ function buildSummary(bundle: LoadedCompiledSurfaceBundle) { if (mutationMode) { textParts.push(`stay within ${mutationMode} mutation scope`); } + if (Object.keys(targetAcquisition).length > 0) { + textParts.push( + `honor ${String(targetAcquisition.minHitAreaPx ?? 44)}px targets and ${String(targetAcquisition.minGapPx ?? 8)}px spacing`, + ); + } + if (Object.keys(feedbackRecovery).length > 0) { + textParts.push( + `cover async feedback states ${asStringArray(feedbackRecovery.requiredStateKinds).join(", ")}`, + ); + } + if (Object.keys(flowSummary).length > 0) { + textParts.push( + `preserve required flows ${asStringArray(flowSummary.flowIds).join(", ")}`, + ); + } if (repairs.length > 0) { textParts.push(`prioritize repairs ${repairs.slice(0, 3).map((repair) => repair.code).join(", ")}`); } diff --git a/packages/interfacectl-cli/src/commands/prepare-runtime.ts b/packages/interfacectl-cli/src/commands/prepare-runtime.ts index d4a7a39..8b8f1bc 100644 --- a/packages/interfacectl-cli/src/commands/prepare-runtime.ts +++ b/packages/interfacectl-cli/src/commands/prepare-runtime.ts @@ -41,9 +41,13 @@ function buildSummary(bundle: LoadedCompiledSurfaceBundle) { const runtimeDoc = asRecord(bundle.surface.runtime.value); const runtime = asRecord(runtimeDoc.runtime); const structure = asRecord(runtime.structure); + const flowSummary = asRecord(structure.flowSummary); const mutationEnvelope = asRecord(runtime.mutationEnvelope); const policySeverities = asRecord(runtime.policySeverities); const contexts = Array.isArray(runtime.contexts) ? runtime.contexts : []; + const feedbackRecovery = asRecord(runtime.feedbackRecovery); + const interaction = asRecord(runtime.interaction); + const targetAcquisition = asRecord(interaction.targetAcquisition); const requiredSectionIds = asStringArray(structure.requiredSections); const mutationMode = asString(mutationEnvelope.mode) ?? "content-only"; const strictCategories = Object.entries(policySeverities) @@ -77,12 +81,50 @@ function buildSummary(bundle: LoadedCompiledSurfaceBundle) { detail: `Context rules: ${contexts.map((context) => asString(asRecord(context).id) ?? "unknown").join(", ")}.`, }); } + if (Object.keys(targetAcquisition).length > 0) { + checklist.push({ + id: "target-acquisition", + label: "Keep controls easy to acquire", + detail: + `Runtime should preserve ${String(targetAcquisition.minHitAreaPx ?? 44)}px targets, ` + + `${String(targetAcquisition.minGapPx ?? 8)}px gaps, ` + + `${String(targetAcquisition.minEdgeInsetPx ?? 8)}px edge inset, ` + + `${String(targetAcquisition.destructiveGapPx ?? 16)}px destructive separation.`, + }); + } + if (Object.keys(feedbackRecovery).length > 0) { + checklist.push({ + id: "feedback-recovery", + label: "Honor async feedback and recovery policy", + detail: + `Runtime should observe async states ${asStringArray(feedbackRecovery.requiredStateKinds).join(", ")} ` + + "and preserve required recovery affordances.", + }); + } + if (Object.keys(flowSummary).length > 0) { + checklist.push({ + id: "flows", + label: "Keep required task flows intact", + detail: + `Runtime should preserve flow requirements for ${asStringArray(flowSummary.flowIds).join(", ")} ` + + "including required steps, transitions, and terminal states.", + }); + } const textParts = [ requiredSectionIds.length > 0 ? `preserve required sections ${requiredSectionIds.join(", ")}` : undefined, `stay within ${mutationMode} mutation scope`, strictCategories.length > 0 ? `treat ${strictCategories.join(", ")} as strict runtime categories` : undefined, contexts.length > 0 ? `evaluate ${contexts.length} contextual runtime rules` : undefined, + Object.keys(targetAcquisition).length > 0 + ? `preserve ${String(targetAcquisition.minHitAreaPx ?? 44)}px targets and ${String(targetAcquisition.minGapPx ?? 8)}px gaps` + : undefined, + Object.keys(feedbackRecovery).length > 0 + ? `observe async feedback states ${asStringArray(feedbackRecovery.requiredStateKinds).join(", ")}` + : undefined, + Object.keys(flowSummary).length > 0 + ? `preserve required flows ${asStringArray(flowSummary.flowIds).join(", ")}` + : undefined, ].filter((value): value is string => Boolean(value)); return { diff --git a/packages/interfacectl-cli/test/compile.test.mjs b/packages/interfacectl-cli/test/compile.test.mjs index fff48d5..b394aa1 100644 --- a/packages/interfacectl-cli/test/compile.test.mjs +++ b/packages/interfacectl-cli/test/compile.test.mjs @@ -187,7 +187,33 @@ test("compile: includes component catalog refs, authoring hints, and observation marketingLayoutProfile: "marketing-landing", marketingLayoutPolicy: "warn", }, + targetAcquisition: { + policy: "warn", + modality: "touch-mouse", + minHitAreaPx: 44, + minGapPx: 8, + minEdgeInsetPx: 8, + destructiveGapPx: 16, + viewportOverrides: [ + { + viewport: "mobile", + minHitAreaPx: 48, + }, + ], + contextOverrides: [ + { + context: "pricing-campaign", + destructiveGapPx: 24, + }, + ], + }, }, + viewports: [ + { + id: "mobile", + maxWidthPx: 767, + }, + ], governance: { status: "review", roles: { @@ -206,6 +232,10 @@ test("compile: includes component catalog refs, authoring hints, and observation }, runtime: { policy: "strict", + feedbackRecovery: { + policy: "warn", + requiredStateKinds: ["loading", "empty", "error", "success"], + }, mutationEnvelope: { mode: "slot-bound", scopes: ["content", "components"], @@ -220,6 +250,30 @@ test("compile: includes component catalog refs, authoring hints, and observation requiredSections: ["main.cta"], allowedLayoutIntents: ["columns"], }, + { + id: "loading", + when: "request == pending", + kind: "loading", + blockedActionsWhilePending: ["compact-primary"], + }, + { + id: "empty", + when: "items.length == 0", + kind: "empty", + }, + { + id: "error", + when: "request == failed", + kind: "error", + requiredRecoveryActions: ["retry"], + preserveSections: ["main.hero"], + preserveLastGoodContent: true, + }, + { + id: "success", + when: "request == fulfilled", + kind: "success", + }, ], }, authoring: { @@ -279,6 +333,20 @@ test("compile: includes component catalog refs, authoring hints, and observation slots: [ { id: "primary", kind: "action", required: true }, ], + interactions: [ + { + id: "compact-primary", + trigger: "click compact primary", + effect: "navigate", + navigationTarget: "/pricing", + targetAcquisition: { + exceptionId: "cta-group.compact-primary", + rationale: "Toolbar-adjacent compact action.", + minHitAreaPx: 40, + classification: "primary", + }, + }, + ], references: [ { system: "code", kind: "component", ref: "app/components/cta-group.tsx" }, ], @@ -355,7 +423,21 @@ test("compile: includes component catalog refs, authoring hints, and observation assert.equal(generation.governance.owner, "designers@example.com"); assert.equal(generation.governance.status, "review"); assert.equal(generation.adaptation.mutationEnvelope.mode, "slot-bound"); - assert.deepEqual(generation.adaptation.contextIds, ["pricing-campaign"]); + assert.deepEqual(generation.adaptation.contextIds, [ + "pricing-campaign", + "loading", + "empty", + "error", + "success", + ]); + assert.deepEqual(generation.adaptation.feedbackRecovery.requiredStateKinds, [ + "loading", + "empty", + "error", + "success", + ]); + assert.equal(generation.layout.targetAcquisition.minHitAreaPx, 44); + assert.equal(generation.layout.targetAcquisition.viewportOverrides[0].minHitAreaPx, 48); const runtime = await readJson(path.join(outDir, "surfaces", "demo-surface", "runtime.json")); assert.equal(runtime.runtime.policy, "strict"); @@ -363,6 +445,32 @@ test("compile: includes component catalog refs, authoring hints, and observation assert.equal(runtime.runtime.policySeverities.runtime, "strict"); assert.deepEqual(runtime.runtime.mutationEnvelope.allowedSections, ["main.hero", "main.cta"]); assert.deepEqual(runtime.runtime.structure.allowedComponents, ["hero-banner", "cta-group"]); + assert.deepEqual(runtime.runtime.feedbackRecovery.requiredStateKinds, [ + "loading", + "empty", + "error", + "success", + ]); + assert.equal(runtime.runtime.interaction.targetAcquisition.minGapPx, 8); + assert.equal(runtime.runtime.interaction.targetAcquisition.contextOverrides[0].destructiveGapPx, 24); + + const repairMap = await readJson(path.join(outDir, "surfaces", "demo-surface", "repair-map.json")); + assert.ok( + repairMap.repairs.some((repair) => repair.action.type === "increase-hit-area"), + "repair map should include hit-area guidance", + ); + assert.ok( + repairMap.repairs.some((repair) => repair.action.type === "separate-destructive-action"), + "repair map should include destructive separation guidance", + ); + assert.ok( + repairMap.repairs.some((repair) => repair.action.type === "add-error-retry"), + "repair map should include async error recovery guidance", + ); + assert.ok( + repairMap.repairs.some((repair) => repair.action.type === "disable-pending-submit"), + "repair map should include pending-action guidance", + ); } finally { await rm(outDir, { recursive: true, force: true }); } @@ -490,6 +598,13 @@ test("compile: includes surface flow policy when present in contract", async () flowIds: ["checkout"], requirementCount: 1, }); + const repairMap = await readJson( + path.join(outDir, "surfaces", "demo-surface", "repair-map.json"), + ); + assert.ok( + repairMap.repairs.some((repair) => repair.code === "flow.unobservable"), + "repair map should include flow.unobservable guidance", + ); } finally { await rm(outDir, { recursive: true, force: true }); } diff --git a/packages/interfacectl-cli/test/prepare-generation.test.mjs b/packages/interfacectl-cli/test/prepare-generation.test.mjs index be0cad7..5645fdb 100644 --- a/packages/interfacectl-cli/test/prepare-generation.test.mjs +++ b/packages/interfacectl-cli/test/prepare-generation.test.mjs @@ -67,6 +67,26 @@ function buildContract(overrides = {}) { layout: { maxContentWidth: 960, requiredContainers: ["contract-container"], + targetAcquisition: { + policy: "warn", + modality: "touch-mouse", + minHitAreaPx: 44, + minGapPx: 8, + minEdgeInsetPx: 8, + destructiveGapPx: 16, + }, + }, + flows: { + policy: "warn", + requirements: [ + { + flowId: "checkout", + minSteps: 2, + requiredSteps: ["start", "review"], + requiredTransitions: [{ from: "start", to: "review" }], + terminalSteps: ["review"], + }, + ], }, governance: { status: "review", @@ -77,6 +97,10 @@ function buildContract(overrides = {}) { }, runtime: { policy: "strict", + feedbackRecovery: { + policy: "warn", + requiredStateKinds: ["loading", "empty", "error", "success"], + }, mutationEnvelope: { mode: "slot-bound", scopes: ["content", "components"], @@ -90,6 +114,27 @@ function buildContract(overrides = {}) { policy: "warn", requiredSections: ["main.hero"], }, + { + id: "loading", + when: "request == pending", + kind: "loading", + }, + { + id: "empty", + when: "items.length == 0", + kind: "empty", + }, + { + id: "error", + when: "request == failed", + kind: "error", + requiredRecoveryActions: ["retry"], + }, + { + id: "success", + when: "request == fulfilled", + kind: "success", + }, ], }, authoring: { @@ -200,6 +245,25 @@ test("prepare-generation: emits resolved payload with summary, provenance, autho assert.deepEqual(payload.generation.boundary.shellOwns, ["header", "footer"]); assert.equal(payload.generation.governance.owner, "designers@example.com"); assert.equal(payload.generation.adaptation.mutationEnvelope.mode, "slot-bound"); + assert.deepEqual(payload.generation.adaptation.feedbackRecovery.requiredStateKinds, [ + "loading", + "empty", + "error", + "success", + ]); + assert.equal(payload.generation.layout.targetAcquisition.minHitAreaPx, 44); + assert.ok( + payload.summary.checklist.some((item) => item.id === "target-acquisition"), + "summary should include target acquisition checklist item", + ); + assert.ok( + payload.summary.checklist.some((item) => item.id === "feedback-recovery"), + "summary should include feedback recovery checklist item", + ); + assert.ok( + payload.summary.checklist.some((item) => item.id === "flows"), + "summary should include flow checklist item", + ); assert.equal(payload.sections.length, 1); assert.equal(payload.components.length, 1); assert.equal(payload.constraints.color.policy, "warn"); diff --git a/packages/interfacectl-cli/test/prepare-runtime.test.mjs b/packages/interfacectl-cli/test/prepare-runtime.test.mjs index 71d9242..7f64261 100644 --- a/packages/interfacectl-cli/test/prepare-runtime.test.mjs +++ b/packages/interfacectl-cli/test/prepare-runtime.test.mjs @@ -97,6 +97,26 @@ function buildContract() { layout: { maxContentWidth: 960, requiredContainers: ["contract-container"], + targetAcquisition: { + policy: "warn", + modality: "touch-mouse", + minHitAreaPx: 44, + minGapPx: 8, + minEdgeInsetPx: 8, + destructiveGapPx: 16, + }, + }, + flows: { + policy: "warn", + requirements: [ + { + flowId: "checkout", + minSteps: 2, + requiredSteps: ["start", "review"], + requiredTransitions: [{ from: "start", to: "review" }], + terminalSteps: ["review"], + }, + ], }, governance: { status: "published", @@ -115,6 +135,10 @@ function buildContract() { }, runtime: { policy: "strict", + feedbackRecovery: { + policy: "warn", + requiredStateKinds: ["loading", "empty", "error", "success"], + }, mutationEnvelope: { mode: "slot-bound", scopes: ["content", "components"], @@ -128,6 +152,27 @@ function buildContract() { policy: "warn", requiredSections: ["main.hero"], }, + { + id: "loading", + when: "request == pending", + kind: "loading", + }, + { + id: "empty", + when: "items.length == 0", + kind: "empty", + }, + { + id: "error", + when: "request == failed", + kind: "error", + requiredRecoveryActions: ["retry"], + }, + { + id: "success", + when: "request == fulfilled", + kind: "success", + }, ], }, }, @@ -197,7 +242,26 @@ test("prepare-runtime: emits resolved runtime payload with governance and enforc assert.equal(payload.governance.owner, "designers@example.com"); assert.equal(payload.governance.status, "published"); assert.equal(payload.runtime.policy, "strict"); + assert.deepEqual(payload.runtime.feedbackRecovery.requiredStateKinds, [ + "loading", + "empty", + "error", + "success", + ]); assert.deepEqual(payload.runtime.structure.requiredSections, ["main.hero"]); + assert.equal(payload.runtime.interaction.targetAcquisition.minHitAreaPx, 44); + assert.ok( + payload.summary.checklist.some((item) => item.id === "target-acquisition"), + "runtime summary should include target acquisition checklist item", + ); + assert.ok( + payload.summary.checklist.some((item) => item.id === "feedback-recovery"), + "runtime summary should include feedback recovery checklist item", + ); + assert.ok( + payload.summary.checklist.some((item) => item.id === "flows"), + "runtime summary should include flow checklist item", + ); assert.deepEqual(payload.evidenceRefs, [{ kind: "contract-field", path: "/x_extracted" }]); const schemaValidation = validateDiffOutput(payload, prepareRuntimeOutputSchema); diff --git a/packages/interfacectl-validator/dist/index.d.ts b/packages/interfacectl-validator/dist/index.d.ts index 900ea08..4837f95 100644 --- a/packages/interfacectl-validator/dist/index.d.ts +++ b/packages/interfacectl-validator/dist/index.d.ts @@ -8,7 +8,7 @@ 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, ContractComponent, ContractSlot, ContractSlotKind, ContractSlotContentRules, ContractComponentVariant, ContractState, ContractInteraction, ContractInteractionEffect, ContractComponentImplementation, ExternalReference, ExternalReferenceSystem, AuthoringSource, SectionAnatomy, SectionEditPolicy, SectionEditMode, SectionAllowedOperation, SectionResponsive, SectionResponsiveRule, ResponsiveLayoutIntent, ResponsiveSlotBehavior, ResponsiveSlotBehaviorKind, ViewportProfile, SurfaceAuthoring, SurfaceAuthoringStyling, SurfaceAuthoringLibraries, SurfacePhase0, SurfaceGovernanceRoles, SurfaceApprovalRole, SurfaceApprovalStatus, SurfaceApprovalRecord, SurfaceGovernance, SurfaceMutationMode, SurfaceMutationScope, SurfaceMutationAction, SurfaceMutationEnvelope, SurfaceRuntimeContextRule, SurfaceRuntimePolicy, 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 type { InterfaceContract, ContractComponent, ContractSlot, ContractSlotKind, ContractSlotContentRules, ContractComponentVariant, ContractState, ContractInteraction, ContractInteractionEffect, ContractComponentImplementation, ExternalReference, ExternalReferenceSystem, AuthoringSource, SectionAnatomy, SectionEditPolicy, SectionEditMode, SectionAllowedOperation, SectionResponsive, SectionResponsiveRule, ResponsiveLayoutIntent, ResponsiveSlotBehavior, ResponsiveSlotBehaviorKind, ViewportProfile, SurfaceAuthoring, SurfaceAuthoringStyling, SurfaceAuthoringLibraries, SurfacePhase0, SurfaceGovernanceRoles, SurfaceApprovalRole, SurfaceApprovalStatus, SurfaceApprovalRecord, SurfaceGovernance, SurfaceMutationMode, SurfaceMutationScope, SurfaceMutationAction, SurfaceMutationEnvelope, SurfaceRuntimeContextRule, SurfaceRuntimePolicy, FeedbackRecoveryPolicy, FeedbackRecoveryPolicyLevel, ContractSurface, ContractSection, ContractConstraints, ContractTokenPolicies, SurfaceDescriptor, SurfaceSectionDescriptor, SurfaceFontDescriptor, SurfaceColorDescriptor, SurfaceIconDescriptor, SurfaceAsyncStateDescriptor, AsyncStateObservationMetadata, InteractiveTargetDescriptor, InteractiveTargetBoundingBox, InteractiveTargetClassification, 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, AsyncStateKind, RecoveryActionKind, TargetAcquisitionBudget, TargetAcquisitionPolicy, TargetAcquisitionPolicyLevel, TargetAcquisitionModality, TargetAcquisitionViewportOverride, TargetAcquisitionContextOverride, ContractInteractionTargetAcquisitionOverride, SurfaceMarketingTypographyDescriptor, SurfaceMarketingTypographyRoleDescriptor, SurfaceFlowDescriptor, SurfaceFlowStepDescriptor, SurfaceFlowTransitionDescriptor, FlowObservationMetadata, FlowObservationSource, 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"; diff --git a/packages/interfacectl-validator/dist/index.d.ts.map b/packages/interfacectl-validator/dist/index.d.ts.map index 15739f3..b390a34 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,EAGL,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,CA8C7B;AAuiCD,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,iBAAiB,EACjB,YAAY,EACZ,gBAAgB,EAChB,wBAAwB,EACxB,wBAAwB,EACxB,aAAa,EACb,mBAAmB,EACnB,yBAAyB,EACzB,+BAA+B,EAC/B,iBAAiB,EACjB,uBAAuB,EACvB,eAAe,EACf,cAAc,EACd,iBAAiB,EACjB,eAAe,EACf,uBAAuB,EACvB,iBAAiB,EACjB,qBAAqB,EACrB,sBAAsB,EACtB,sBAAsB,EACtB,0BAA0B,EAC1B,eAAe,EACf,gBAAgB,EAChB,uBAAuB,EACvB,yBAAyB,EACzB,aAAa,EACb,sBAAsB,EACtB,mBAAmB,EACnB,qBAAqB,EACrB,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACnB,oBAAoB,EACpB,qBAAqB,EACrB,uBAAuB,EACvB,yBAAyB,EACzB,oBAAoB,EACpB,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 +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/interfacectl-validator/dist/index.js b/packages/interfacectl-validator/dist/index.js index f7407de..adf4d55 100644 --- a/packages/interfacectl-validator/dist/index.js +++ b/packages/interfacectl-validator/dist/index.js @@ -6,6 +6,16 @@ import bundledSchema from "./schema/web.surface.contract.schema.json" with { import { normalizeColorValue } from "./color-policy.js"; import { matchTokenPolicy } from "./token-policy.js"; const frozenBundledSchema = Object.freeze(bundledSchema); +const DEFAULT_TARGET_ACQUISITION_MODALITY = "touch-mouse"; +const DEFAULT_MIN_HIT_AREA_PX = 44; +const DEFAULT_MIN_GAP_PX = 8; +const DEFAULT_MIN_EDGE_INSET_PX = 8; +const DEFAULT_DESTRUCTIVE_GAP_PX = 16; +const DEFAULT_FEEDBACK_REQUIRED_STATE_KINDS = [ + "loading", + "empty", + "error", +]; export function getBundledContractSchema() { return frozenBundledSchema; } @@ -112,8 +122,11 @@ function validateAuthoringMetadata(contract) { function validateGovernanceMetadata(contract) { const errors = []; const sectionIds = new Set(contract.sections.map((section) => section.id)); + const interactionIds = new Set((contract.components ?? []).flatMap((component) => (component.interactions ?? []).map((interaction) => interaction.id))); for (const surface of contract.surfaces) { + const targetAcquisition = surface.layout.targetAcquisition; const mutationEnvelope = surface.runtime?.mutationEnvelope; + const feedbackRecovery = surface.runtime?.feedbackRecovery; validateSurfaceSectionReferences(mutationEnvelope?.allowedSections ?? [], sectionIds, `/surfaces/${surface.id}/runtime/mutationEnvelope/allowedSections`, errors); validateSurfaceSectionReferences(mutationEnvelope?.prohibitedSections ?? [], sectionIds, `/surfaces/${surface.id}/runtime/mutationEnvelope/prohibitedSections`, errors); const allowedSections = new Set(mutationEnvelope?.allowedSections ?? []); @@ -130,6 +143,51 @@ function validateGovernanceMetadata(contract) { contextIds.add(context.id); validateSurfaceSectionReferences(context.requiredSections ?? [], sectionIds, `/surfaces/${surface.id}/runtime/contexts/${context.id}/requiredSections`, errors); validateSurfaceSectionReferences(context.prohibitedSections ?? [], sectionIds, `/surfaces/${surface.id}/runtime/contexts/${context.id}/prohibitedSections`, errors); + validateSurfaceSectionReferences(context.preserveSections ?? [], sectionIds, `/surfaces/${surface.id}/runtime/contexts/${context.id}/preserveSections`, errors); + const hasFeedbackMetadata = Boolean(context.kind) || + Boolean(context.requiredRecoveryActions?.length) || + Boolean(context.preserveSections?.length) || + context.preserveLastGoodContent === true || + Boolean(context.blockedActionsWhilePending?.length); + if (hasFeedbackMetadata && !context.kind) { + errors.push(`/surfaces/${surface.id}/runtime/contexts/${context.id} must declare kind when feedback recovery metadata is present`); + } + for (const blockedActionId of context.blockedActionsWhilePending ?? []) { + if (!interactionIds.has(blockedActionId)) { + errors.push(`/surfaces/${surface.id}/runtime/contexts/${context.id}/blockedActionsWhilePending/${blockedActionId} must reference a declared component interaction id`); + } + } + } + if (targetAcquisition) { + const viewportIds = new Set((surface.viewports ?? []).map((viewport) => viewport.id)); + for (const override of targetAcquisition.viewportOverrides ?? []) { + if (viewportIds.size === 0) { + errors.push(`/surfaces/${surface.id}/layout/targetAcquisition/viewportOverrides/${override.viewport} must reference a declared surfaces[*].viewports id; none were declared`); + } + else if (!viewportIds.has(override.viewport)) { + errors.push(`/surfaces/${surface.id}/layout/targetAcquisition/viewportOverrides/${override.viewport} must reference a declared surfaces[*].viewports id`); + } + } + for (const override of targetAcquisition.contextOverrides ?? []) { + if (contextIds.size === 0) { + errors.push(`/surfaces/${surface.id}/layout/targetAcquisition/contextOverrides/${override.context} must reference a declared runtime context id; none were declared`); + } + else if (!contextIds.has(override.context)) { + errors.push(`/surfaces/${surface.id}/layout/targetAcquisition/contextOverrides/${override.context} must reference a declared runtime context id`); + } + } + } + if (feedbackRecovery && feedbackRecovery.policy !== "off") { + const requiredStateKinds = new Set(feedbackRecovery.requiredStateKinds ?? + DEFAULT_FEEDBACK_REQUIRED_STATE_KINDS); + const declaredContextKinds = new Set((surface.runtime?.contexts ?? []) + .map((context) => context.kind) + .filter((kind) => Boolean(kind))); + for (const kind of requiredStateKinds) { + if (!declaredContextKinds.has(kind)) { + errors.push(`/surfaces/${surface.id}/runtime/feedbackRecovery/requiredStateKinds/${kind} must reference a declared runtime context kind`); + } + } } } return errors; @@ -168,6 +226,7 @@ function validateComponentAuthoring(component, componentIds, errors) { } } const interactionIds = new Set(); + const targetAcquisitionExceptionIds = new Set(); for (const interaction of component.interactions ?? []) { if (interactionIds.has(interaction.id)) { errors.push(`/components/${component.id}/interactions/${interaction.id} must use a unique interaction id within the component`); @@ -177,6 +236,13 @@ function validateComponentAuthoring(component, componentIds, errors) { !stateIds.has(interaction.resultingState)) { errors.push(`/components/${component.id}/interactions/${interaction.id}/resultingState must reference a declared state id`); } + const targetAcquisition = interaction.targetAcquisition; + if (targetAcquisition) { + if (targetAcquisitionExceptionIds.has(targetAcquisition.exceptionId)) { + errors.push(`/components/${component.id}/interactions/${interaction.id}/targetAcquisition/exceptionId must be unique within the component`); + } + targetAcquisitionExceptionIds.add(targetAcquisition.exceptionId); + } } const implementation = component.implementation; if (implementation?.preferredSource && @@ -350,7 +416,25 @@ function validateFlowPolicy(surface, descriptor, violations) { const policy = flowPolicy.policy; const requirements = flowPolicy.requirements ?? []; const descriptorFlows = descriptor.flows; - const defaultSource = descriptor.flowDescriptorPath; + const flowObservation = descriptor.flowObservation; + const defaultSource = descriptor.flowDescriptorPath ?? flowObservation?.location; + const runtimeObservation = flowObservation?.source === "contract-scoped" || + flowObservation?.source === "none-observed"; + if (runtimeObservation && flowObservation?.source === "none-observed") { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "flow-unobservable", + message: `Flow policy is "${policy}" for surface "${descriptor.surfaceId}", ` + + "but runtime validation could not observe any contract-scoped flow markers.", + details: { + policy, + source: flowObservation.location, + requiredMetrics: ["contractScopedFlows"], + missingMetrics: ["contractScopedFlows"], + }, + }); + return; + } if (!Array.isArray(descriptorFlows)) { violations.push({ surfaceId: descriptor.surfaceId, @@ -430,7 +514,9 @@ function validateFlowPolicy(surface, descriptor, violations) { .filter((transition) => Boolean(transition.from && transition.to)); const transitionKeys = new Set(transitionList.map((transition) => `${transition.from}->${transition.to}`)); const requiredTransitions = requirement.requiredTransitions ?? []; - const missingRequiredTransitions = requiredTransitions.filter((transition) => !transitionKeys.has(`${transition.from}->${transition.to}`)); + const missingRequiredTransitions = requiredTransitions.filter((transition) => stepIds.has(transition.from) && + stepIds.has(transition.to) && + !transitionKeys.has(`${transition.from}->${transition.to}`)); if (missingRequiredTransitions.length > 0) { violations.push({ surfaceId: descriptor.surfaceId, @@ -463,6 +549,375 @@ function validateFlowPolicy(surface, descriptor, violations) { } } } +function resolveTargetAcquisitionBudget(budget) { + return { + minHitAreaPx: budget?.minHitAreaPx ?? DEFAULT_MIN_HIT_AREA_PX, + minGapPx: budget?.minGapPx ?? DEFAULT_MIN_GAP_PX, + minEdgeInsetPx: budget?.minEdgeInsetPx ?? DEFAULT_MIN_EDGE_INSET_PX, + destructiveGapPx: budget?.destructiveGapPx ?? DEFAULT_DESTRUCTIVE_GAP_PX, + }; +} +function applyTargetAcquisitionBudget(base, budget) { + return { + ...base, + ...(budget?.minHitAreaPx !== undefined ? { minHitAreaPx: budget.minHitAreaPx } : {}), + ...(budget?.minGapPx !== undefined ? { minGapPx: budget.minGapPx } : {}), + ...(budget?.minEdgeInsetPx !== undefined ? { minEdgeInsetPx: budget.minEdgeInsetPx } : {}), + ...(budget?.destructiveGapPx !== undefined + ? { destructiveGapPx: budget.destructiveGapPx } + : {}), + }; +} +function resolveTargetAcquisitionPolicy(policy, target) { + if (!policy || policy.policy === "off") { + return null; + } + const resolvedBudget = applyTargetAcquisitionBudget(resolveTargetAcquisitionBudget(undefined), policy); + const viewportOverride = target.viewportId + ? policy.viewportOverrides?.find((override) => override.viewport === target.viewportId) + : undefined; + const contextOverride = target.contextId + ? policy.contextOverrides?.find((override) => override.context === target.contextId) + : undefined; + const viewportBudget = applyTargetAcquisitionBudget(resolvedBudget, viewportOverride); + const contextBudget = applyTargetAcquisitionBudget(viewportBudget, contextOverride); + return { + policy: policy.policy, + modality: policy.modality ?? DEFAULT_TARGET_ACQUISITION_MODALITY, + ...contextBudget, + }; +} +function resolveTargetAcquisitionOverride(contract, target) { + if (!target.interactionId) { + return undefined; + } + if (target.componentId) { + return contract.components + ?.find((component) => component.id === target.componentId) + ?.interactions?.find((interaction) => interaction.id === target.interactionId) + ?.targetAcquisition; + } + const matches = (contract.components ?? []) + .flatMap((component) => (component.interactions ?? []) + .filter((interaction) => interaction.id === target.interactionId) + .map((interaction) => interaction.targetAcquisition) + .filter(Boolean)); + return matches.length === 1 ? matches[0] : undefined; +} +function resolveTargetClassification(target, override) { + return override?.classification ?? target.classification ?? "default"; +} +function validateTargetAcquisition(contract, surface, descriptor, violations) { + const surfacePolicy = surface.layout.targetAcquisition; + if (!surfacePolicy || surfacePolicy.policy === "off") { + return; + } + const interactiveTargets = descriptor.interactiveTargets ?? []; + if (interactiveTargets.length === 0) { + const observationSource = descriptor.interactiveTargetObservation?.source ?? "none-observed"; + const usedFallbackObservation = observationSource === "all-visible-fallback"; + violations.push({ + surfaceId: descriptor.surfaceId, + type: "target-unobservable", + message: usedFallbackObservation + ? `Target acquisition policy is "${surfacePolicy.policy}" for surface "${descriptor.surfaceId}", ` + + "but no contract-scoped interactive targets were observed during remote validation." + : `Target acquisition policy is "${surfacePolicy.policy}" for surface "${descriptor.surfaceId}", ` + + "but no interactive targets were observed.", + details: { + policy: surfacePolicy.policy, + modality: surfacePolicy.modality ?? DEFAULT_TARGET_ACQUISITION_MODALITY, + source: descriptor.interactiveTargetObservation?.location ?? + descriptor.layout.source, + requiredMetrics: [ + usedFallbackObservation + ? "contractScopedInteractiveTargets" + : "interactiveTargets", + ], + missingMetrics: [ + usedFallbackObservation + ? "contractScopedInteractiveTargets" + : "interactiveTargets", + ], + observationSource, + observedInteractiveTargetCount: descriptor.interactiveTargetObservation?.allVisibleCount ?? 0, + contractScopedObservedTargetCount: descriptor.interactiveTargetObservation?.contractScopedCount ?? 0, + }, + }); + return; + } + for (const target of interactiveTargets) { + const override = resolveTargetAcquisitionOverride(contract, target); + const resolvedPolicy = resolveTargetAcquisitionPolicy(surfacePolicy, target); + if (!resolvedPolicy) { + continue; + } + const effectiveBudget = applyTargetAcquisitionBudget(resolvedPolicy, override); + const classification = resolveTargetClassification(target, override); + const width = target.boundingBox?.width; + const height = target.boundingBox?.height; + const missingMetrics = []; + const requiredMetrics = ["boundingBox", "edgeInsetPx"]; + if (!Number.isFinite(width) || !Number.isFinite(height)) { + missingMetrics.push("boundingBox"); + } + if (target.edgeInsetPx === null || target.edgeInsetPx === undefined) { + missingMetrics.push("edgeInsetPx"); + } + if (missingMetrics.length > 0) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "target-unobservable", + message: `Interactive target "${target.id}" could not be fully observed for surface "${descriptor.surfaceId}".`, + details: { + policy: resolvedPolicy.policy, + modality: resolvedPolicy.modality, + targetId: target.id, + role: target.role, + source: target.source, + requiredMetrics, + missingMetrics, + interactionId: target.interactionId, + componentId: target.componentId, + exceptionId: override?.exceptionId ?? target.exceptionId, + observationSource: descriptor.interactiveTargetObservation?.source, + }, + }); + } + if (Number.isFinite(width) && + Number.isFinite(height) && + (Number(width) < effectiveBudget.minHitAreaPx || + Number(height) < effectiveBudget.minHitAreaPx)) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "target-hit-area-too-small", + message: `Interactive target "${target.id}" is smaller than the ${effectiveBudget.minHitAreaPx}px floor ` + + `for surface "${descriptor.surfaceId}".`, + details: { + policy: resolvedPolicy.policy, + modality: resolvedPolicy.modality, + targetId: target.id, + role: target.role, + source: target.source, + width, + height, + minHitAreaPx: effectiveBudget.minHitAreaPx, + exceptionId: override?.exceptionId ?? target.exceptionId, + }, + }); + } + if (target.nearestNeighborGapPx !== null && + target.nearestNeighborGapPx !== undefined && + target.nearestNeighborGapPx < effectiveBudget.minGapPx) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "target-gap-too-tight", + message: `Interactive target "${target.id}" is closer than ${effectiveBudget.minGapPx}px ` + + `to its nearest neighbor for surface "${descriptor.surfaceId}".`, + details: { + policy: resolvedPolicy.policy, + modality: resolvedPolicy.modality, + targetId: target.id, + role: target.role, + source: target.source, + nearestNeighborGapPx: target.nearestNeighborGapPx, + minGapPx: effectiveBudget.minGapPx, + exceptionId: override?.exceptionId ?? target.exceptionId, + }, + }); + } + if (target.edgeInsetPx !== null && + target.edgeInsetPx !== undefined && + target.edgeInsetPx < effectiveBudget.minEdgeInsetPx) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "target-edge-inset-too-small", + message: `Interactive target "${target.id}" is inset less than ${effectiveBudget.minEdgeInsetPx}px ` + + `from the viewport edge for surface "${descriptor.surfaceId}".`, + details: { + policy: resolvedPolicy.policy, + modality: resolvedPolicy.modality, + targetId: target.id, + role: target.role, + source: target.source, + edgeInsetPx: target.edgeInsetPx, + minEdgeInsetPx: effectiveBudget.minEdgeInsetPx, + exceptionId: override?.exceptionId ?? target.exceptionId, + }, + }); + } + if (classification === "destructive" && + target.nearestNeighborGapPx !== null && + target.nearestNeighborGapPx !== undefined && + target.nearestNeighborClassification !== "destructive" && + target.nearestNeighborGapPx < effectiveBudget.destructiveGapPx) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "destructive-target-too-close", + message: `Destructive target "${target.id}" must be separated by at least ${effectiveBudget.destructiveGapPx}px ` + + `from adjacent non-destructive actions for surface "${descriptor.surfaceId}".`, + details: { + policy: resolvedPolicy.policy, + modality: resolvedPolicy.modality, + targetId: target.id, + role: target.role, + source: target.source, + classification, + nearestNeighborGapPx: target.nearestNeighborGapPx, + destructiveGapPx: effectiveBudget.destructiveGapPx, + nearestNeighborClassification: target.nearestNeighborClassification ?? "default", + exceptionId: override?.exceptionId ?? target.exceptionId, + }, + }); + } + } +} +function resolveFeedbackRequiredStateKinds(surface) { + const feedbackRecovery = surface.runtime?.feedbackRecovery; + if (!feedbackRecovery || feedbackRecovery.policy === "off") { + return []; + } + return [ + ...new Set([ + ...(feedbackRecovery.requiredStateKinds ?? + DEFAULT_FEEDBACK_REQUIRED_STATE_KINDS), + ...(surface.runtime?.contexts ?? []) + .map((context) => context.kind) + .filter((kind) => Boolean(kind)), + ]), + ]; +} +function findMatchingFeedbackContexts(surface, state) { + return (surface.runtime?.contexts ?? []).filter((context) => { + if (!context.kind) { + return false; + } + if (state.contextId && state.contextId === context.id) { + return true; + } + if (state.id === context.id) { + return true; + } + return state.kind === context.kind; + }); +} +function validateFeedbackContextState(surface, context, state, policy, violations) { + const recoveryActions = new Set(state.recoveryActions ?? []); + const missingRecoveryActions = (context.requiredRecoveryActions ?? []).filter((action) => !recoveryActions.has(action)); + if (missingRecoveryActions.length > 0) { + violations.push({ + surfaceId: surface.id, + type: "feedback-recovery-action-missing", + message: `Async state "${state.id}" is missing required recovery actions for ` + + `context "${context.id}" on surface "${surface.id}".`, + details: { + policy: policy.policy, + stateId: state.id, + kind: state.kind, + contextId: context.id, + expectedRecoveryActions: context.requiredRecoveryActions, + missingRecoveryActions, + source: state.source, + }, + }); + } + const observedBlockedActions = new Map((state.blockedActions ?? []).map((action) => [ + action.interactionId, + action.disabled, + ])); + const missingBlockedActions = (context.blockedActionsWhilePending ?? []).filter((interactionId) => observedBlockedActions.get(interactionId) !== true); + if (missingBlockedActions.length > 0) { + violations.push({ + surfaceId: surface.id, + type: "feedback-pending-action-not-blocked", + message: `Async state "${state.id}" leaves required pending actions enabled for ` + + `context "${context.id}" on surface "${surface.id}".`, + details: { + policy: policy.policy, + stateId: state.id, + kind: state.kind, + contextId: context.id, + expectedBlockedActions: context.blockedActionsWhilePending, + missingBlockedActions, + source: state.source, + }, + }); + } + const observedSections = new Set(state.sectionIds ?? []); + const missingPreserveSections = (context.preserveSections ?? []).filter((sectionId) => !observedSections.has(sectionId)); + const preserveLastGoodRequired = context.preserveLastGoodContent === true; + const preserveLastGoodObserved = state.preserveLastGoodContent === true; + if (missingPreserveSections.length > 0 || + (preserveLastGoodRequired && !preserveLastGoodObserved)) { + violations.push({ + surfaceId: surface.id, + type: "feedback-last-good-content-missing", + message: `Async state "${state.id}" does not preserve the required last-good content ` + + `for context "${context.id}" on surface "${surface.id}".`, + details: { + policy: policy.policy, + stateId: state.id, + kind: state.kind, + contextId: context.id, + expectedPreserveSections: context.preserveSections ?? [], + missingPreserveSections, + preserveLastGoodContentRequired: preserveLastGoodRequired, + preserveLastGoodContentObserved: preserveLastGoodObserved, + source: state.source, + }, + }); + } +} +function validateFeedbackRecovery(surface, descriptor, violations) { + const feedbackRecovery = surface.runtime?.feedbackRecovery; + if (!feedbackRecovery || feedbackRecovery.policy === "off") { + return; + } + const asyncStates = descriptor.asyncStates ?? []; + const observationSource = descriptor.asyncStateObservation?.source; + const runtimeObservation = observationSource === "contract-scoped" || observationSource === "none-observed"; + if (runtimeObservation && asyncStates.length === 0) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "feedback-unobservable", + message: `Feedback and recovery policy is "${feedbackRecovery.policy}" for surface "${descriptor.surfaceId}", ` + + "but no contract-scoped async states were observed during remote validation.", + details: { + policy: feedbackRecovery.policy, + source: descriptor.asyncStateObservation?.location ?? + descriptor.layout.source, + requiredMetrics: ["contractScopedAsyncStates"], + missingMetrics: ["contractScopedAsyncStates"], + observationSource, + observedStateCount: descriptor.asyncStateObservation?.observedStateCount ?? 0, + }, + }); + return; + } + if (!runtimeObservation) { + for (const kind of resolveFeedbackRequiredStateKinds(surface)) { + if (!asyncStates.some((state) => state.kind === kind)) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "feedback-state-missing", + message: `Required async state "${kind}" is missing for surface "${descriptor.surfaceId}".`, + details: { + policy: feedbackRecovery.policy, + kind, + source: descriptor.asyncStateObservation?.location ?? + descriptor.layout.source, + }, + }); + } + } + } + for (const state of asyncStates) { + const matchingContexts = findMatchingFeedbackContexts(surface, state); + for (const context of matchingContexts) { + validateFeedbackContextState(surface, context, state, feedbackRecovery, violations); + } + } +} function validateLandingPattern(surface, contract, descriptor, violations) { const landingPattern = surface.layout.landingPattern; if (!landingPattern || landingPattern.policy === "off") { @@ -884,6 +1339,8 @@ export function evaluateSurfaceCompliance(contract, descriptor) { validateTokenPolicies(contract, descriptor, violations); validateIconPolicy(surface, descriptor, violations); validateFlowPolicy(surface, descriptor, violations); + validateTargetAcquisition(contract, surface, descriptor, violations); + validateFeedbackRecovery(surface, descriptor, violations); validateLandingPattern(surface, contract, descriptor, violations); validateMarketingTypography(surface, contract, descriptor, violations); const reportedWidth = descriptor.layout.maxContentWidth; 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 b5d5a93..1dd888b 100644 --- a/packages/interfacectl-validator/dist/schema/web.surface.contract.schema.json +++ b/packages/interfacectl-validator/dist/schema/web.surface.contract.schema.json @@ -185,6 +185,9 @@ }, "landingPattern": { "$ref": "#/$defs/landingPattern" + }, + "targetAcquisition": { + "$ref": "#/$defs/targetAcquisitionPolicy" } } }, @@ -561,6 +564,9 @@ "type": "string", "minLength": 1 }, + "targetAcquisition": { + "$ref": "#/$defs/interactionTargetAcquisitionOverride" + }, "notes": { "type": "string" } @@ -986,6 +992,9 @@ "mutationEnvelope": { "$ref": "#/$defs/surfaceMutationEnvelope" }, + "feedbackRecovery": { + "$ref": "#/$defs/feedbackRecoveryPolicy" + }, "contexts": { "type": "array", "items": { @@ -994,6 +1003,228 @@ } } }, + "asyncStateKind": { + "type": "string", + "enum": [ + "loading", + "empty", + "partial", + "error", + "success" + ] + }, + "recoveryActionKind": { + "type": "string", + "enum": [ + "retry", + "refresh", + "dismiss", + "contact-support", + "navigate-home", + "go-back" + ] + }, + "feedbackRecoveryPolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "policy" + ], + "properties": { + "policy": { + "type": "string", + "enum": [ + "off", + "warn", + "strict" + ] + }, + "requiredStateKinds": { + "type": "array", + "items": { + "$ref": "#/$defs/asyncStateKind" + }, + "uniqueItems": true + } + } + }, + "targetAcquisitionClassification": { + "type": "string", + "enum": [ + "default", + "primary", + "destructive" + ] + }, + "targetAcquisitionBudget": { + "type": "object", + "properties": { + "minHitAreaPx": { + "type": "number", + "minimum": 1 + }, + "minGapPx": { + "type": "number", + "minimum": 0 + }, + "minEdgeInsetPx": { + "type": "number", + "minimum": 0 + }, + "destructiveGapPx": { + "type": "number", + "minimum": 0 + } + } + }, + "targetAcquisitionViewportOverride": { + "type": "object", + "additionalProperties": false, + "required": [ + "viewport" + ], + "properties": { + "viewport": { + "type": "string", + "pattern": "^[a-z0-9]+([.-][a-z0-9]+)*$" + }, + "minHitAreaPx": { + "type": "number", + "minimum": 1 + }, + "minGapPx": { + "type": "number", + "minimum": 0 + }, + "minEdgeInsetPx": { + "type": "number", + "minimum": 0 + }, + "destructiveGapPx": { + "type": "number", + "minimum": 0 + } + } + }, + "targetAcquisitionContextOverride": { + "type": "object", + "additionalProperties": false, + "required": [ + "context" + ], + "properties": { + "context": { + "type": "string", + "pattern": "^[a-z0-9]+([.-][a-z0-9]+)*$" + }, + "minHitAreaPx": { + "type": "number", + "minimum": 1 + }, + "minGapPx": { + "type": "number", + "minimum": 0 + }, + "minEdgeInsetPx": { + "type": "number", + "minimum": 0 + }, + "destructiveGapPx": { + "type": "number", + "minimum": 0 + } + } + }, + "targetAcquisitionPolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "policy" + ], + "properties": { + "policy": { + "type": "string", + "enum": [ + "off", + "warn", + "strict" + ] + }, + "modality": { + "type": "string", + "enum": [ + "touch-mouse", + "touch", + "mouse" + ] + }, + "minHitAreaPx": { + "type": "number", + "minimum": 1 + }, + "minGapPx": { + "type": "number", + "minimum": 0 + }, + "minEdgeInsetPx": { + "type": "number", + "minimum": 0 + }, + "destructiveGapPx": { + "type": "number", + "minimum": 0 + }, + "viewportOverrides": { + "type": "array", + "items": { + "$ref": "#/$defs/targetAcquisitionViewportOverride" + } + }, + "contextOverrides": { + "type": "array", + "items": { + "$ref": "#/$defs/targetAcquisitionContextOverride" + } + } + } + }, + "interactionTargetAcquisitionOverride": { + "type": "object", + "additionalProperties": false, + "required": [ + "exceptionId", + "rationale" + ], + "properties": { + "exceptionId": { + "type": "string", + "pattern": "^[a-z0-9]+([.-][a-z0-9]+)*$" + }, + "rationale": { + "type": "string", + "minLength": 1 + }, + "classification": { + "$ref": "#/$defs/targetAcquisitionClassification" + }, + "minHitAreaPx": { + "type": "number", + "minimum": 1 + }, + "minGapPx": { + "type": "number", + "minimum": 0 + }, + "minEdgeInsetPx": { + "type": "number", + "minimum": 0 + }, + "destructiveGapPx": { + "type": "number", + "minimum": 0 + } + } + }, "surfaceMutationEnvelope": { "type": "object", "additionalProperties": false, @@ -1094,6 +1325,9 @@ "strict" ] }, + "kind": { + "$ref": "#/$defs/asyncStateKind" + }, "requiredSections": { "type": "array", "items": { @@ -1110,6 +1344,32 @@ }, "uniqueItems": true }, + "requiredRecoveryActions": { + "type": "array", + "items": { + "$ref": "#/$defs/recoveryActionKind" + }, + "uniqueItems": true + }, + "preserveSections": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9]+(\\.[a-z0-9]+)*$" + }, + "uniqueItems": true + }, + "preserveLastGoodContent": { + "type": "boolean" + }, + "blockedActionsWhilePending": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9]+([.-][a-z0-9]+)*$" + }, + "uniqueItems": true + }, "allowedLayoutIntents": { "type": "array", "items": { diff --git a/packages/interfacectl-validator/dist/types.d.ts b/packages/interfacectl-validator/dist/types.d.ts index 5b0e9ca..aa390aa 100644 --- a/packages/interfacectl-validator/dist/types.d.ts +++ b/packages/interfacectl-validator/dist/types.d.ts @@ -88,6 +88,7 @@ export interface ContractInteraction { resultingState?: string; navigationTarget?: string; notes?: string; + targetAcquisition?: ContractInteractionTargetAcquisitionOverride; } export interface ContractComponentImplementation { preferredSource?: AuthoringSource; @@ -158,6 +159,39 @@ export interface SurfaceAuthoring { preferredLibraries?: SurfaceAuthoringLibraries; sourcePriority: AuthoringSource[]; } +export type TargetAcquisitionPolicyLevel = "off" | "warn" | "strict"; +export type TargetAcquisitionModality = "touch-mouse" | "touch" | "mouse"; +export type InteractiveTargetClassification = "default" | "primary" | "destructive"; +export interface TargetAcquisitionBudget { + minHitAreaPx?: number; + minGapPx?: number; + minEdgeInsetPx?: number; + destructiveGapPx?: number; +} +export interface TargetAcquisitionViewportOverride extends TargetAcquisitionBudget { + viewport: string; +} +export interface TargetAcquisitionContextOverride extends TargetAcquisitionBudget { + context: string; +} +export interface TargetAcquisitionPolicy extends TargetAcquisitionBudget { + policy: TargetAcquisitionPolicyLevel; + modality?: TargetAcquisitionModality; + viewportOverrides?: TargetAcquisitionViewportOverride[]; + contextOverrides?: TargetAcquisitionContextOverride[]; +} +export interface ContractInteractionTargetAcquisitionOverride extends TargetAcquisitionBudget { + exceptionId: string; + rationale: string; + classification?: InteractiveTargetClassification; +} +export type FeedbackRecoveryPolicyLevel = "off" | "warn" | "strict"; +export type AsyncStateKind = "loading" | "empty" | "partial" | "error" | "success"; +export type RecoveryActionKind = "retry" | "refresh" | "dismiss" | "contact-support" | "navigate-home" | "go-back"; +export interface FeedbackRecoveryPolicy { + policy: FeedbackRecoveryPolicyLevel; + requiredStateKinds?: AsyncStateKind[]; +} export interface SurfacePhase0 { authPosture?: "public" | "auth-aware" | "auth-first"; requiresShell?: boolean; @@ -197,8 +231,13 @@ export interface SurfaceRuntimeContextRule { id: string; when: string; policy?: "off" | "warn" | "strict"; + kind?: AsyncStateKind; requiredSections?: string[]; prohibitedSections?: string[]; + requiredRecoveryActions?: RecoveryActionKind[]; + preserveSections?: string[]; + preserveLastGoodContent?: boolean; + blockedActionsWhilePending?: string[]; allowedLayoutIntents?: ResponsiveLayoutIntent[]; notes?: string; } @@ -206,6 +245,7 @@ export interface SurfaceRuntimePolicy { policy?: "off" | "warn" | "strict"; mutationEnvelope?: SurfaceMutationEnvelope; contexts?: SurfaceRuntimeContextRule[]; + feedbackRecovery?: FeedbackRecoveryPolicy; } export interface ContractSurface { id: string; @@ -221,6 +261,7 @@ export interface ContractSurface { pageFrame?: PageFrameLayout; chromePolicy?: ChromePolicy; landingPattern?: LandingPatternPolicy; + targetAcquisition?: TargetAcquisitionPolicy; }; icons?: IconPolicy; flows?: FlowPolicy; @@ -364,6 +405,7 @@ export interface SurfacePrimitiveDescriptor { } export interface SurfaceFlowStepDescriptor { id: string; + terminal?: boolean; } export interface SurfaceFlowTransitionDescriptor { from: string; @@ -424,6 +466,63 @@ export interface SurfaceLayoutDescriptor { chrome?: ChromeLayoutDescriptor; landingPattern?: LandingPatternDescriptor; } +export interface InteractiveTargetBoundingBox { + x: number; + y: number; + width: number; + height: number; +} +export interface InteractiveTargetDescriptor { + id: string; + role: string; + source?: string; + selector?: string; + componentId?: string; + interactionId?: string; + viewportId?: string; + contextId?: string; + boundingBox?: InteractiveTargetBoundingBox; + hitAreaPx?: number | null; + nearestNeighborGapPx?: number | null; + nearestNeighborClassification?: InteractiveTargetClassification; + edgeInsetPx?: number | null; + classification?: InteractiveTargetClassification; + exceptionId?: string; + notes?: string; +} +export type InteractiveTargetObservationSource = "contract-scoped" | "all-visible-fallback" | "none-observed"; +export interface InteractiveTargetObservationMetadata { + source: InteractiveTargetObservationSource; + allVisibleCount: number; + contractScopedCount: number; + location?: string; +} +export interface AsyncStateBlockedActionDescriptor { + interactionId: string; + disabled: boolean; +} +export interface SurfaceAsyncStateDescriptor { + id: string; + kind: AsyncStateKind; + source?: string; + contextId?: string; + sectionIds?: string[]; + recoveryActions?: RecoveryActionKind[]; + preserveLastGoodContent?: boolean; + blockedActions?: AsyncStateBlockedActionDescriptor[]; +} +export type AsyncStateObservationSource = "static-markers" | "contract-scoped" | "none-observed"; +export interface AsyncStateObservationMetadata { + source: AsyncStateObservationSource; + observedStateCount: number; + location?: string; +} +export type FlowObservationSource = "static-markers" | "flow-descriptor-artifact" | "contract-scoped" | "none-observed"; +export interface FlowObservationMetadata { + source: FlowObservationSource; + observedFlowCount: number; + location?: string; +} export interface SurfaceDescriptor { surfaceId: string; sections: SurfaceSectionDescriptor[]; @@ -434,11 +533,16 @@ export interface SurfaceDescriptor { marketingTypography?: SurfaceMarketingTypographyDescriptor; flows?: SurfaceFlowDescriptor[]; flowDescriptorPath?: string; + flowObservation?: FlowObservationMetadata; layout: SurfaceLayoutDescriptor; motion: SurfaceMotionDescriptor[]; primitives?: SurfacePrimitiveDescriptor[]; + interactiveTargets?: InteractiveTargetDescriptor[]; + interactiveTargetObservation?: InteractiveTargetObservationMetadata; + asyncStates?: SurfaceAsyncStateDescriptor[]; + asyncStateObservation?: AsyncStateObservationMetadata; } -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 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" | "target-hit-area-too-small" | "target-gap-too-tight" | "target-edge-inset-too-small" | "destructive-target-too-close" | "target-unobservable" | "feedback-state-missing" | "feedback-recovery-action-missing" | "feedback-pending-action-not-blocked" | "feedback-last-good-content-missing" | "feedback-unobservable" | "descriptor-flows-missing" | "flow-required-missing" | "flow-steps-min" | "flow-steps-required" | "flow-transition-required" | "flow-terminal-invalid" | "flow-unobservable" | "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 555c537..317ccf0 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 a6a1c0d..87702b2 100644 --- a/packages/interfacectl-validator/src/index.ts +++ b/packages/interfacectl-validator/src/index.ts @@ -1,8 +1,12 @@ import AjvModule, { type ErrorObject } from "ajv/dist/2020.js"; import addFormats from "ajv-formats"; import { + AsyncStateKind, ContractComponent, + ContractInteractionTargetAcquisitionOverride, ContractSlot, + InteractiveTargetClassification, + InteractiveTargetDescriptor, InterfaceContract, SurfaceDescriptor, SurfaceReport, @@ -11,6 +15,7 @@ import { ContractSection, ContractSurface, PageFrameLayoutDescriptor, + TargetAcquisitionPolicy, TokenCategory, TokenMetadata, TokenPolicy, @@ -22,6 +27,16 @@ import { normalizeColorValue } from "./color-policy.js"; import { matchTokenPolicy, normalizeTokenLiteralValue } from "./token-policy.js"; const frozenBundledSchema = Object.freeze(bundledSchema) as object; +const DEFAULT_TARGET_ACQUISITION_MODALITY = "touch-mouse"; +const DEFAULT_MIN_HIT_AREA_PX = 44; +const DEFAULT_MIN_GAP_PX = 8; +const DEFAULT_MIN_EDGE_INSET_PX = 8; +const DEFAULT_DESTRUCTIVE_GAP_PX = 16; +const DEFAULT_FEEDBACK_REQUIRED_STATE_KINDS: AsyncStateKind[] = [ + "loading", + "empty", + "error", +]; export function getBundledContractSchema(): object { return frozenBundledSchema; @@ -200,9 +215,16 @@ function validateAuthoringMetadata(contract: InterfaceContract): string[] { function validateGovernanceMetadata(contract: InterfaceContract): string[] { const errors: string[] = []; const sectionIds = new Set(contract.sections.map((section) => section.id)); + const interactionIds = new Set( + (contract.components ?? []).flatMap((component) => + (component.interactions ?? []).map((interaction) => interaction.id), + ), + ); for (const surface of contract.surfaces) { + const targetAcquisition = surface.layout.targetAcquisition; const mutationEnvelope = surface.runtime?.mutationEnvelope; + const feedbackRecovery = surface.runtime?.feedbackRecovery; validateSurfaceSectionReferences( mutationEnvelope?.allowedSections ?? [], sectionIds, @@ -245,6 +267,79 @@ function validateGovernanceMetadata(contract: InterfaceContract): string[] { `/surfaces/${surface.id}/runtime/contexts/${context.id}/prohibitedSections`, errors, ); + validateSurfaceSectionReferences( + context.preserveSections ?? [], + sectionIds, + `/surfaces/${surface.id}/runtime/contexts/${context.id}/preserveSections`, + errors, + ); + + const hasFeedbackMetadata = + Boolean(context.kind) || + Boolean(context.requiredRecoveryActions?.length) || + Boolean(context.preserveSections?.length) || + context.preserveLastGoodContent === true || + Boolean(context.blockedActionsWhilePending?.length); + if (hasFeedbackMetadata && !context.kind) { + errors.push( + `/surfaces/${surface.id}/runtime/contexts/${context.id} must declare kind when feedback recovery metadata is present`, + ); + } + + for (const blockedActionId of context.blockedActionsWhilePending ?? []) { + if (!interactionIds.has(blockedActionId)) { + errors.push( + `/surfaces/${surface.id}/runtime/contexts/${context.id}/blockedActionsWhilePending/${blockedActionId} must reference a declared component interaction id`, + ); + } + } + } + + if (targetAcquisition) { + const viewportIds = new Set((surface.viewports ?? []).map((viewport) => viewport.id)); + for (const override of targetAcquisition.viewportOverrides ?? []) { + if (viewportIds.size === 0) { + errors.push( + `/surfaces/${surface.id}/layout/targetAcquisition/viewportOverrides/${override.viewport} must reference a declared surfaces[*].viewports id; none were declared`, + ); + } else if (!viewportIds.has(override.viewport)) { + errors.push( + `/surfaces/${surface.id}/layout/targetAcquisition/viewportOverrides/${override.viewport} must reference a declared surfaces[*].viewports id`, + ); + } + } + + for (const override of targetAcquisition.contextOverrides ?? []) { + if (contextIds.size === 0) { + errors.push( + `/surfaces/${surface.id}/layout/targetAcquisition/contextOverrides/${override.context} must reference a declared runtime context id; none were declared`, + ); + } else if (!contextIds.has(override.context)) { + errors.push( + `/surfaces/${surface.id}/layout/targetAcquisition/contextOverrides/${override.context} must reference a declared runtime context id`, + ); + } + } + } + + if (feedbackRecovery && feedbackRecovery.policy !== "off") { + const requiredStateKinds = new Set( + feedbackRecovery.requiredStateKinds ?? + DEFAULT_FEEDBACK_REQUIRED_STATE_KINDS, + ); + const declaredContextKinds = new Set( + (surface.runtime?.contexts ?? []) + .map((context) => context.kind) + .filter((kind): kind is AsyncStateKind => Boolean(kind)), + ); + + for (const kind of requiredStateKinds) { + if (!declaredContextKinds.has(kind)) { + errors.push( + `/surfaces/${surface.id}/runtime/feedbackRecovery/requiredStateKinds/${kind} must reference a declared runtime context kind`, + ); + } + } } } @@ -312,6 +407,7 @@ function validateComponentAuthoring( } const interactionIds = new Set(); + const targetAcquisitionExceptionIds = new Set(); for (const interaction of component.interactions ?? []) { if (interactionIds.has(interaction.id)) { errors.push( @@ -328,6 +424,16 @@ function validateComponentAuthoring( `/components/${component.id}/interactions/${interaction.id}/resultingState must reference a declared state id`, ); } + + const targetAcquisition = interaction.targetAcquisition; + if (targetAcquisition) { + if (targetAcquisitionExceptionIds.has(targetAcquisition.exceptionId)) { + errors.push( + `/components/${component.id}/interactions/${interaction.id}/targetAcquisition/exceptionId must be unique within the component`, + ); + } + targetAcquisitionExceptionIds.add(targetAcquisition.exceptionId); + } } const implementation = component.implementation; @@ -620,7 +726,28 @@ function validateFlowPolicy( const policy = flowPolicy.policy; const requirements = flowPolicy.requirements ?? []; const descriptorFlows = descriptor.flows; - const defaultSource = descriptor.flowDescriptorPath; + const flowObservation = descriptor.flowObservation; + const defaultSource = descriptor.flowDescriptorPath ?? flowObservation?.location; + const runtimeObservation = + flowObservation?.source === "contract-scoped" || + flowObservation?.source === "none-observed"; + + if (runtimeObservation && flowObservation?.source === "none-observed") { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "flow-unobservable", + message: + `Flow policy is "${policy}" for surface "${descriptor.surfaceId}", ` + + "but runtime validation could not observe any contract-scoped flow markers.", + details: { + policy, + source: flowObservation.location, + requiredMetrics: ["contractScopedFlows"], + missingMetrics: ["contractScopedFlows"], + }, + }); + return; + } if (!Array.isArray(descriptorFlows)) { violations.push({ @@ -724,6 +851,8 @@ function validateFlowPolicy( const requiredTransitions = requirement.requiredTransitions ?? []; const missingRequiredTransitions = requiredTransitions.filter( (transition) => + stepIds.has(transition.from) && + stepIds.has(transition.to) && !transitionKeys.has(`${transition.from}->${transition.to}`), ); if (missingRequiredTransitions.length > 0) { @@ -762,6 +891,511 @@ function validateFlowPolicy( } } +function resolveTargetAcquisitionBudget( + budget: { + minHitAreaPx?: number; + minGapPx?: number; + minEdgeInsetPx?: number; + destructiveGapPx?: number; + } | undefined, +) { + return { + minHitAreaPx: budget?.minHitAreaPx ?? DEFAULT_MIN_HIT_AREA_PX, + minGapPx: budget?.minGapPx ?? DEFAULT_MIN_GAP_PX, + minEdgeInsetPx: budget?.minEdgeInsetPx ?? DEFAULT_MIN_EDGE_INSET_PX, + destructiveGapPx: budget?.destructiveGapPx ?? DEFAULT_DESTRUCTIVE_GAP_PX, + }; +} + +function applyTargetAcquisitionBudget(base: T, budget: { + minHitAreaPx?: number; + minGapPx?: number; + minEdgeInsetPx?: number; + destructiveGapPx?: number; +} | undefined): T { + return { + ...base, + ...(budget?.minHitAreaPx !== undefined ? { minHitAreaPx: budget.minHitAreaPx } : {}), + ...(budget?.minGapPx !== undefined ? { minGapPx: budget.minGapPx } : {}), + ...(budget?.minEdgeInsetPx !== undefined ? { minEdgeInsetPx: budget.minEdgeInsetPx } : {}), + ...(budget?.destructiveGapPx !== undefined + ? { destructiveGapPx: budget.destructiveGapPx } + : {}), + }; +} + +function resolveTargetAcquisitionPolicy( + policy: TargetAcquisitionPolicy | undefined, + target: InteractiveTargetDescriptor, +) { + if (!policy || policy.policy === "off") { + return null; + } + + const resolvedBudget = applyTargetAcquisitionBudget( + resolveTargetAcquisitionBudget(undefined), + policy, + ); + + const viewportOverride = target.viewportId + ? policy.viewportOverrides?.find((override) => override.viewport === target.viewportId) + : undefined; + const contextOverride = target.contextId + ? policy.contextOverrides?.find((override) => override.context === target.contextId) + : undefined; + const viewportBudget = applyTargetAcquisitionBudget( + resolvedBudget, + viewportOverride, + ); + const contextBudget = applyTargetAcquisitionBudget( + viewportBudget, + contextOverride, + ); + + return { + policy: policy.policy, + modality: policy.modality ?? DEFAULT_TARGET_ACQUISITION_MODALITY, + ...contextBudget, + }; +} + +function resolveTargetAcquisitionOverride( + contract: InterfaceContract, + target: InteractiveTargetDescriptor, +): ContractInteractionTargetAcquisitionOverride | undefined { + if (!target.interactionId) { + return undefined; + } + + if (target.componentId) { + return contract.components + ?.find((component) => component.id === target.componentId) + ?.interactions?.find((interaction) => interaction.id === target.interactionId) + ?.targetAcquisition; + } + + const matches = (contract.components ?? []) + .flatMap((component) => + (component.interactions ?? []) + .filter((interaction) => interaction.id === target.interactionId) + .map((interaction) => interaction.targetAcquisition) + .filter(Boolean), + ); + + return matches.length === 1 ? matches[0] : undefined; +} + +function resolveTargetClassification( + target: InteractiveTargetDescriptor, + override: ContractInteractionTargetAcquisitionOverride | undefined, +): InteractiveTargetClassification { + return override?.classification ?? target.classification ?? "default"; +} + +function validateTargetAcquisition( + contract: InterfaceContract, + surface: ContractSurface, + descriptor: SurfaceDescriptor, + violations: DriftViolation[], +): void { + const surfacePolicy = surface.layout.targetAcquisition; + if (!surfacePolicy || surfacePolicy.policy === "off") { + return; + } + + const interactiveTargets = descriptor.interactiveTargets ?? []; + if (interactiveTargets.length === 0) { + const observationSource = + descriptor.interactiveTargetObservation?.source ?? "none-observed"; + const usedFallbackObservation = observationSource === "all-visible-fallback"; + violations.push({ + surfaceId: descriptor.surfaceId, + type: "target-unobservable", + message: + usedFallbackObservation + ? `Target acquisition policy is "${surfacePolicy.policy}" for surface "${descriptor.surfaceId}", ` + + "but no contract-scoped interactive targets were observed during remote validation." + : `Target acquisition policy is "${surfacePolicy.policy}" for surface "${descriptor.surfaceId}", ` + + "but no interactive targets were observed.", + details: { + policy: surfacePolicy.policy, + modality: surfacePolicy.modality ?? DEFAULT_TARGET_ACQUISITION_MODALITY, + source: + descriptor.interactiveTargetObservation?.location ?? + descriptor.layout.source, + requiredMetrics: [ + usedFallbackObservation + ? "contractScopedInteractiveTargets" + : "interactiveTargets", + ], + missingMetrics: [ + usedFallbackObservation + ? "contractScopedInteractiveTargets" + : "interactiveTargets", + ], + observationSource, + observedInteractiveTargetCount: + descriptor.interactiveTargetObservation?.allVisibleCount ?? 0, + contractScopedObservedTargetCount: + descriptor.interactiveTargetObservation?.contractScopedCount ?? 0, + }, + }); + return; + } + + for (const target of interactiveTargets) { + const override = resolveTargetAcquisitionOverride(contract, target); + const resolvedPolicy = resolveTargetAcquisitionPolicy(surfacePolicy, target); + if (!resolvedPolicy) { + continue; + } + + const effectiveBudget = applyTargetAcquisitionBudget( + resolvedPolicy, + override, + ); + const classification = resolveTargetClassification(target, override); + const width = target.boundingBox?.width; + const height = target.boundingBox?.height; + const missingMetrics: string[] = []; + const requiredMetrics = ["boundingBox", "edgeInsetPx"]; + + if (!Number.isFinite(width) || !Number.isFinite(height)) { + missingMetrics.push("boundingBox"); + } + if (target.edgeInsetPx === null || target.edgeInsetPx === undefined) { + missingMetrics.push("edgeInsetPx"); + } + + if (missingMetrics.length > 0) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "target-unobservable", + message: + `Interactive target "${target.id}" could not be fully observed for surface "${descriptor.surfaceId}".`, + details: { + policy: resolvedPolicy.policy, + modality: resolvedPolicy.modality, + targetId: target.id, + role: target.role, + source: target.source, + requiredMetrics, + missingMetrics, + interactionId: target.interactionId, + componentId: target.componentId, + exceptionId: override?.exceptionId ?? target.exceptionId, + observationSource: + descriptor.interactiveTargetObservation?.source, + }, + }); + } + + if ( + Number.isFinite(width) && + Number.isFinite(height) && + (Number(width) < effectiveBudget.minHitAreaPx || + Number(height) < effectiveBudget.minHitAreaPx) + ) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "target-hit-area-too-small", + message: + `Interactive target "${target.id}" is smaller than the ${effectiveBudget.minHitAreaPx}px floor ` + + `for surface "${descriptor.surfaceId}".`, + details: { + policy: resolvedPolicy.policy, + modality: resolvedPolicy.modality, + targetId: target.id, + role: target.role, + source: target.source, + width, + height, + minHitAreaPx: effectiveBudget.minHitAreaPx, + exceptionId: override?.exceptionId ?? target.exceptionId, + }, + }); + } + + if ( + target.nearestNeighborGapPx !== null && + target.nearestNeighborGapPx !== undefined && + target.nearestNeighborGapPx < effectiveBudget.minGapPx + ) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "target-gap-too-tight", + message: + `Interactive target "${target.id}" is closer than ${effectiveBudget.minGapPx}px ` + + `to its nearest neighbor for surface "${descriptor.surfaceId}".`, + details: { + policy: resolvedPolicy.policy, + modality: resolvedPolicy.modality, + targetId: target.id, + role: target.role, + source: target.source, + nearestNeighborGapPx: target.nearestNeighborGapPx, + minGapPx: effectiveBudget.minGapPx, + exceptionId: override?.exceptionId ?? target.exceptionId, + }, + }); + } + + if ( + target.edgeInsetPx !== null && + target.edgeInsetPx !== undefined && + target.edgeInsetPx < effectiveBudget.minEdgeInsetPx + ) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "target-edge-inset-too-small", + message: + `Interactive target "${target.id}" is inset less than ${effectiveBudget.minEdgeInsetPx}px ` + + `from the viewport edge for surface "${descriptor.surfaceId}".`, + details: { + policy: resolvedPolicy.policy, + modality: resolvedPolicy.modality, + targetId: target.id, + role: target.role, + source: target.source, + edgeInsetPx: target.edgeInsetPx, + minEdgeInsetPx: effectiveBudget.minEdgeInsetPx, + exceptionId: override?.exceptionId ?? target.exceptionId, + }, + }); + } + + if ( + classification === "destructive" && + target.nearestNeighborGapPx !== null && + target.nearestNeighborGapPx !== undefined && + target.nearestNeighborClassification !== "destructive" && + target.nearestNeighborGapPx < effectiveBudget.destructiveGapPx + ) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "destructive-target-too-close", + message: + `Destructive target "${target.id}" must be separated by at least ${effectiveBudget.destructiveGapPx}px ` + + `from adjacent non-destructive actions for surface "${descriptor.surfaceId}".`, + details: { + policy: resolvedPolicy.policy, + modality: resolvedPolicy.modality, + targetId: target.id, + role: target.role, + source: target.source, + classification, + nearestNeighborGapPx: target.nearestNeighborGapPx, + destructiveGapPx: effectiveBudget.destructiveGapPx, + nearestNeighborClassification: target.nearestNeighborClassification ?? "default", + exceptionId: override?.exceptionId ?? target.exceptionId, + }, + }); + } + } +} + +function resolveFeedbackRequiredStateKinds( + surface: ContractSurface, +): AsyncStateKind[] { + const feedbackRecovery = surface.runtime?.feedbackRecovery; + if (!feedbackRecovery || feedbackRecovery.policy === "off") { + return []; + } + + return [ + ...new Set([ + ...(feedbackRecovery.requiredStateKinds ?? + DEFAULT_FEEDBACK_REQUIRED_STATE_KINDS), + ...(surface.runtime?.contexts ?? []) + .map((context) => context.kind) + .filter((kind): kind is AsyncStateKind => Boolean(kind)), + ]), + ]; +} + +function findMatchingFeedbackContexts( + surface: ContractSurface, + state: NonNullable[number], +) { + return (surface.runtime?.contexts ?? []).filter((context) => { + if (!context.kind) { + return false; + } + if (state.contextId && state.contextId === context.id) { + return true; + } + if (state.id === context.id) { + return true; + } + return state.kind === context.kind; + }); +} + +function validateFeedbackContextState( + surface: ContractSurface, + context: NonNullable["contexts"]>[number], + state: NonNullable[number], + policy: NonNullable["feedbackRecovery"]>, + violations: DriftViolation[], +): void { + const recoveryActions = new Set(state.recoveryActions ?? []); + const missingRecoveryActions = (context.requiredRecoveryActions ?? []).filter( + (action) => !recoveryActions.has(action), + ); + if (missingRecoveryActions.length > 0) { + violations.push({ + surfaceId: surface.id, + type: "feedback-recovery-action-missing", + message: + `Async state "${state.id}" is missing required recovery actions for ` + + `context "${context.id}" on surface "${surface.id}".`, + details: { + policy: policy.policy, + stateId: state.id, + kind: state.kind, + contextId: context.id, + expectedRecoveryActions: context.requiredRecoveryActions, + missingRecoveryActions, + source: state.source, + }, + }); + } + + const observedBlockedActions = new Map( + (state.blockedActions ?? []).map((action) => [ + action.interactionId, + action.disabled, + ]), + ); + const missingBlockedActions = (context.blockedActionsWhilePending ?? []).filter( + (interactionId) => observedBlockedActions.get(interactionId) !== true, + ); + if (missingBlockedActions.length > 0) { + violations.push({ + surfaceId: surface.id, + type: "feedback-pending-action-not-blocked", + message: + `Async state "${state.id}" leaves required pending actions enabled for ` + + `context "${context.id}" on surface "${surface.id}".`, + details: { + policy: policy.policy, + stateId: state.id, + kind: state.kind, + contextId: context.id, + expectedBlockedActions: context.blockedActionsWhilePending, + missingBlockedActions, + source: state.source, + }, + }); + } + + const observedSections = new Set(state.sectionIds ?? []); + const missingPreserveSections = (context.preserveSections ?? []).filter( + (sectionId) => !observedSections.has(sectionId), + ); + const preserveLastGoodRequired = context.preserveLastGoodContent === true; + const preserveLastGoodObserved = state.preserveLastGoodContent === true; + if ( + missingPreserveSections.length > 0 || + (preserveLastGoodRequired && !preserveLastGoodObserved) + ) { + violations.push({ + surfaceId: surface.id, + type: "feedback-last-good-content-missing", + message: + `Async state "${state.id}" does not preserve the required last-good content ` + + `for context "${context.id}" on surface "${surface.id}".`, + details: { + policy: policy.policy, + stateId: state.id, + kind: state.kind, + contextId: context.id, + expectedPreserveSections: context.preserveSections ?? [], + missingPreserveSections, + preserveLastGoodContentRequired: preserveLastGoodRequired, + preserveLastGoodContentObserved: preserveLastGoodObserved, + source: state.source, + }, + }); + } +} + +function validateFeedbackRecovery( + surface: ContractSurface, + descriptor: SurfaceDescriptor, + violations: DriftViolation[], +): void { + const feedbackRecovery = surface.runtime?.feedbackRecovery; + if (!feedbackRecovery || feedbackRecovery.policy === "off") { + return; + } + + const asyncStates = descriptor.asyncStates ?? []; + const observationSource = descriptor.asyncStateObservation?.source; + const runtimeObservation = + observationSource === "contract-scoped" || observationSource === "none-observed"; + + if (runtimeObservation && asyncStates.length === 0) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "feedback-unobservable", + message: + `Feedback and recovery policy is "${feedbackRecovery.policy}" for surface "${descriptor.surfaceId}", ` + + "but no contract-scoped async states were observed during remote validation.", + details: { + policy: feedbackRecovery.policy, + source: + descriptor.asyncStateObservation?.location ?? + descriptor.layout.source, + requiredMetrics: ["contractScopedAsyncStates"], + missingMetrics: ["contractScopedAsyncStates"], + observationSource, + observedStateCount: + descriptor.asyncStateObservation?.observedStateCount ?? 0, + }, + }); + return; + } + + if (!runtimeObservation) { + for (const kind of resolveFeedbackRequiredStateKinds(surface)) { + if (!asyncStates.some((state) => state.kind === kind)) { + violations.push({ + surfaceId: descriptor.surfaceId, + type: "feedback-state-missing", + message: + `Required async state "${kind}" is missing for surface "${descriptor.surfaceId}".`, + details: { + policy: feedbackRecovery.policy, + kind, + source: + descriptor.asyncStateObservation?.location ?? + descriptor.layout.source, + }, + }); + } + } + } + + for (const state of asyncStates) { + const matchingContexts = findMatchingFeedbackContexts(surface, state); + for (const context of matchingContexts) { + validateFeedbackContextState( + surface, + context, + state, + feedbackRecovery, + violations, + ); + } + } +} + function validateLandingPattern( surface: ContractSurface, contract: InterfaceContract, @@ -1283,6 +1917,8 @@ export function evaluateSurfaceCompliance( validateTokenPolicies(contract, descriptor, violations); validateIconPolicy(surface, descriptor, violations); validateFlowPolicy(surface, descriptor, violations); + validateTargetAcquisition(contract, surface, descriptor, violations); + validateFeedbackRecovery(surface, descriptor, violations); validateLandingPattern(surface, contract, descriptor, violations); validateMarketingTypography(surface, contract, descriptor, violations); @@ -1710,6 +2346,8 @@ export type { SurfaceMutationEnvelope, SurfaceRuntimeContextRule, SurfaceRuntimePolicy, + FeedbackRecoveryPolicy, + FeedbackRecoveryPolicyLevel, ContractSurface, ContractSection, ContractConstraints, @@ -1719,6 +2357,11 @@ export type { SurfaceFontDescriptor, SurfaceColorDescriptor, SurfaceIconDescriptor, + SurfaceAsyncStateDescriptor, + AsyncStateObservationMetadata, + InteractiveTargetDescriptor, + InteractiveTargetBoundingBox, + InteractiveTargetClassification, SurfaceTokenDescriptor, SurfaceTokenUsage, SurfaceMotionDescriptor, @@ -1760,11 +2403,22 @@ export type { FlowRequirement, FlowTransitionRequirement, LandingPatternPolicy, + AsyncStateKind, + RecoveryActionKind, + TargetAcquisitionBudget, + TargetAcquisitionPolicy, + TargetAcquisitionPolicyLevel, + TargetAcquisitionModality, + TargetAcquisitionViewportOverride, + TargetAcquisitionContextOverride, + ContractInteractionTargetAcquisitionOverride, SurfaceMarketingTypographyDescriptor, SurfaceMarketingTypographyRoleDescriptor, SurfaceFlowDescriptor, SurfaceFlowStepDescriptor, SurfaceFlowTransitionDescriptor, + FlowObservationMetadata, + FlowObservationSource, AutofixRule, FixSummary, FixEntry, 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 b5d5a93..1dd888b 100644 --- a/packages/interfacectl-validator/src/schema/web.surface.contract.schema.json +++ b/packages/interfacectl-validator/src/schema/web.surface.contract.schema.json @@ -185,6 +185,9 @@ }, "landingPattern": { "$ref": "#/$defs/landingPattern" + }, + "targetAcquisition": { + "$ref": "#/$defs/targetAcquisitionPolicy" } } }, @@ -561,6 +564,9 @@ "type": "string", "minLength": 1 }, + "targetAcquisition": { + "$ref": "#/$defs/interactionTargetAcquisitionOverride" + }, "notes": { "type": "string" } @@ -986,6 +992,9 @@ "mutationEnvelope": { "$ref": "#/$defs/surfaceMutationEnvelope" }, + "feedbackRecovery": { + "$ref": "#/$defs/feedbackRecoveryPolicy" + }, "contexts": { "type": "array", "items": { @@ -994,6 +1003,228 @@ } } }, + "asyncStateKind": { + "type": "string", + "enum": [ + "loading", + "empty", + "partial", + "error", + "success" + ] + }, + "recoveryActionKind": { + "type": "string", + "enum": [ + "retry", + "refresh", + "dismiss", + "contact-support", + "navigate-home", + "go-back" + ] + }, + "feedbackRecoveryPolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "policy" + ], + "properties": { + "policy": { + "type": "string", + "enum": [ + "off", + "warn", + "strict" + ] + }, + "requiredStateKinds": { + "type": "array", + "items": { + "$ref": "#/$defs/asyncStateKind" + }, + "uniqueItems": true + } + } + }, + "targetAcquisitionClassification": { + "type": "string", + "enum": [ + "default", + "primary", + "destructive" + ] + }, + "targetAcquisitionBudget": { + "type": "object", + "properties": { + "minHitAreaPx": { + "type": "number", + "minimum": 1 + }, + "minGapPx": { + "type": "number", + "minimum": 0 + }, + "minEdgeInsetPx": { + "type": "number", + "minimum": 0 + }, + "destructiveGapPx": { + "type": "number", + "minimum": 0 + } + } + }, + "targetAcquisitionViewportOverride": { + "type": "object", + "additionalProperties": false, + "required": [ + "viewport" + ], + "properties": { + "viewport": { + "type": "string", + "pattern": "^[a-z0-9]+([.-][a-z0-9]+)*$" + }, + "minHitAreaPx": { + "type": "number", + "minimum": 1 + }, + "minGapPx": { + "type": "number", + "minimum": 0 + }, + "minEdgeInsetPx": { + "type": "number", + "minimum": 0 + }, + "destructiveGapPx": { + "type": "number", + "minimum": 0 + } + } + }, + "targetAcquisitionContextOverride": { + "type": "object", + "additionalProperties": false, + "required": [ + "context" + ], + "properties": { + "context": { + "type": "string", + "pattern": "^[a-z0-9]+([.-][a-z0-9]+)*$" + }, + "minHitAreaPx": { + "type": "number", + "minimum": 1 + }, + "minGapPx": { + "type": "number", + "minimum": 0 + }, + "minEdgeInsetPx": { + "type": "number", + "minimum": 0 + }, + "destructiveGapPx": { + "type": "number", + "minimum": 0 + } + } + }, + "targetAcquisitionPolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "policy" + ], + "properties": { + "policy": { + "type": "string", + "enum": [ + "off", + "warn", + "strict" + ] + }, + "modality": { + "type": "string", + "enum": [ + "touch-mouse", + "touch", + "mouse" + ] + }, + "minHitAreaPx": { + "type": "number", + "minimum": 1 + }, + "minGapPx": { + "type": "number", + "minimum": 0 + }, + "minEdgeInsetPx": { + "type": "number", + "minimum": 0 + }, + "destructiveGapPx": { + "type": "number", + "minimum": 0 + }, + "viewportOverrides": { + "type": "array", + "items": { + "$ref": "#/$defs/targetAcquisitionViewportOverride" + } + }, + "contextOverrides": { + "type": "array", + "items": { + "$ref": "#/$defs/targetAcquisitionContextOverride" + } + } + } + }, + "interactionTargetAcquisitionOverride": { + "type": "object", + "additionalProperties": false, + "required": [ + "exceptionId", + "rationale" + ], + "properties": { + "exceptionId": { + "type": "string", + "pattern": "^[a-z0-9]+([.-][a-z0-9]+)*$" + }, + "rationale": { + "type": "string", + "minLength": 1 + }, + "classification": { + "$ref": "#/$defs/targetAcquisitionClassification" + }, + "minHitAreaPx": { + "type": "number", + "minimum": 1 + }, + "minGapPx": { + "type": "number", + "minimum": 0 + }, + "minEdgeInsetPx": { + "type": "number", + "minimum": 0 + }, + "destructiveGapPx": { + "type": "number", + "minimum": 0 + } + } + }, "surfaceMutationEnvelope": { "type": "object", "additionalProperties": false, @@ -1094,6 +1325,9 @@ "strict" ] }, + "kind": { + "$ref": "#/$defs/asyncStateKind" + }, "requiredSections": { "type": "array", "items": { @@ -1110,6 +1344,32 @@ }, "uniqueItems": true }, + "requiredRecoveryActions": { + "type": "array", + "items": { + "$ref": "#/$defs/recoveryActionKind" + }, + "uniqueItems": true + }, + "preserveSections": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9]+(\\.[a-z0-9]+)*$" + }, + "uniqueItems": true + }, + "preserveLastGoodContent": { + "type": "boolean" + }, + "blockedActionsWhilePending": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9]+([.-][a-z0-9]+)*$" + }, + "uniqueItems": true + }, "allowedLayoutIntents": { "type": "array", "items": { diff --git a/packages/interfacectl-validator/src/types.ts b/packages/interfacectl-validator/src/types.ts index d5cf160..56193bd 100644 --- a/packages/interfacectl-validator/src/types.ts +++ b/packages/interfacectl-validator/src/types.ts @@ -131,6 +131,7 @@ export interface ContractInteraction { resultingState?: string; navigationTarget?: string; notes?: string; + targetAcquisition?: ContractInteractionTargetAcquisitionOverride; } export interface ContractComponentImplementation { @@ -235,6 +236,62 @@ export interface SurfaceAuthoring { sourcePriority: AuthoringSource[]; } +export type TargetAcquisitionPolicyLevel = "off" | "warn" | "strict"; +export type TargetAcquisitionModality = "touch-mouse" | "touch" | "mouse"; +export type InteractiveTargetClassification = + | "default" + | "primary" + | "destructive"; + +export interface TargetAcquisitionBudget { + minHitAreaPx?: number; + minGapPx?: number; + minEdgeInsetPx?: number; + destructiveGapPx?: number; +} + +export interface TargetAcquisitionViewportOverride extends TargetAcquisitionBudget { + viewport: string; +} + +export interface TargetAcquisitionContextOverride extends TargetAcquisitionBudget { + context: string; +} + +export interface TargetAcquisitionPolicy extends TargetAcquisitionBudget { + policy: TargetAcquisitionPolicyLevel; + modality?: TargetAcquisitionModality; + viewportOverrides?: TargetAcquisitionViewportOverride[]; + contextOverrides?: TargetAcquisitionContextOverride[]; +} + +export interface ContractInteractionTargetAcquisitionOverride + extends TargetAcquisitionBudget { + exceptionId: string; + rationale: string; + classification?: InteractiveTargetClassification; +} + +export type FeedbackRecoveryPolicyLevel = "off" | "warn" | "strict"; +export type AsyncStateKind = + | "loading" + | "empty" + | "partial" + | "error" + | "success"; +export type RecoveryActionKind = + | "retry" + | "refresh" + | "dismiss" + | "contact-support" + | "navigate-home" + | "go-back"; + +export interface FeedbackRecoveryPolicy { + policy: FeedbackRecoveryPolicyLevel; + requiredStateKinds?: AsyncStateKind[]; +} + export interface SurfacePhase0 { authPosture?: "public" | "auth-aware" | "auth-first"; requiresShell?: boolean; @@ -306,8 +363,13 @@ export interface SurfaceRuntimeContextRule { id: string; when: string; policy?: "off" | "warn" | "strict"; + kind?: AsyncStateKind; requiredSections?: string[]; prohibitedSections?: string[]; + requiredRecoveryActions?: RecoveryActionKind[]; + preserveSections?: string[]; + preserveLastGoodContent?: boolean; + blockedActionsWhilePending?: string[]; allowedLayoutIntents?: ResponsiveLayoutIntent[]; notes?: string; } @@ -316,6 +378,7 @@ export interface SurfaceRuntimePolicy { policy?: "off" | "warn" | "strict"; mutationEnvelope?: SurfaceMutationEnvelope; contexts?: SurfaceRuntimeContextRule[]; + feedbackRecovery?: FeedbackRecoveryPolicy; } export interface ContractSurface { @@ -332,6 +395,7 @@ export interface ContractSurface { pageFrame?: PageFrameLayout; chromePolicy?: ChromePolicy; landingPattern?: LandingPatternPolicy; + targetAcquisition?: TargetAcquisitionPolicy; }; icons?: IconPolicy; flows?: FlowPolicy; @@ -508,6 +572,7 @@ export interface SurfacePrimitiveDescriptor { export interface SurfaceFlowStepDescriptor { id: string; + terminal?: boolean; } export interface SurfaceFlowTransitionDescriptor { @@ -577,6 +642,83 @@ export interface SurfaceLayoutDescriptor { landingPattern?: LandingPatternDescriptor; } +export interface InteractiveTargetBoundingBox { + x: number; + y: number; + width: number; + height: number; +} + +export interface InteractiveTargetDescriptor { + id: string; + role: string; + source?: string; + selector?: string; + componentId?: string; + interactionId?: string; + viewportId?: string; + contextId?: string; + boundingBox?: InteractiveTargetBoundingBox; + hitAreaPx?: number | null; + nearestNeighborGapPx?: number | null; + nearestNeighborClassification?: InteractiveTargetClassification; + edgeInsetPx?: number | null; + classification?: InteractiveTargetClassification; + exceptionId?: string; + notes?: string; +} + +export type InteractiveTargetObservationSource = + | "contract-scoped" + | "all-visible-fallback" + | "none-observed"; + +export interface InteractiveTargetObservationMetadata { + source: InteractiveTargetObservationSource; + allVisibleCount: number; + contractScopedCount: number; + location?: string; +} + +export interface AsyncStateBlockedActionDescriptor { + interactionId: string; + disabled: boolean; +} + +export interface SurfaceAsyncStateDescriptor { + id: string; + kind: AsyncStateKind; + source?: string; + contextId?: string; + sectionIds?: string[]; + recoveryActions?: RecoveryActionKind[]; + preserveLastGoodContent?: boolean; + blockedActions?: AsyncStateBlockedActionDescriptor[]; +} + +export type AsyncStateObservationSource = + | "static-markers" + | "contract-scoped" + | "none-observed"; + +export interface AsyncStateObservationMetadata { + source: AsyncStateObservationSource; + observedStateCount: number; + location?: string; +} + +export type FlowObservationSource = + | "static-markers" + | "flow-descriptor-artifact" + | "contract-scoped" + | "none-observed"; + +export interface FlowObservationMetadata { + source: FlowObservationSource; + observedFlowCount: number; + location?: string; +} + export interface SurfaceDescriptor { surfaceId: string; sections: SurfaceSectionDescriptor[]; @@ -587,9 +729,14 @@ export interface SurfaceDescriptor { marketingTypography?: SurfaceMarketingTypographyDescriptor; flows?: SurfaceFlowDescriptor[]; flowDescriptorPath?: string; + flowObservation?: FlowObservationMetadata; layout: SurfaceLayoutDescriptor; motion: SurfaceMotionDescriptor[]; primitives?: SurfacePrimitiveDescriptor[]; + interactiveTargets?: InteractiveTargetDescriptor[]; + interactiveTargetObservation?: InteractiveTargetObservationMetadata; + asyncStates?: SurfaceAsyncStateDescriptor[]; + asyncStateObservation?: AsyncStateObservationMetadata; } export type DriftViolationType = @@ -625,12 +772,23 @@ export type DriftViolationType = | "marketing-typography-role-token" | "motion-duration-not-allowed" | "motion-timing-not-allowed" + | "target-hit-area-too-small" + | "target-gap-too-tight" + | "target-edge-inset-too-small" + | "destructive-target-too-close" + | "target-unobservable" + | "feedback-state-missing" + | "feedback-recovery-action-missing" + | "feedback-pending-action-not-blocked" + | "feedback-last-good-content-missing" + | "feedback-unobservable" | "descriptor-flows-missing" | "flow-required-missing" | "flow-steps-min" | "flow-steps-required" | "flow-transition-required" | "flow-terminal-invalid" + | "flow-unobservable" | "descriptor-missing" | "descriptor-unused" | "shell-owned-primitive-emitted"; diff --git a/packages/interfacectl-validator/test/authoring-contract.test.mjs b/packages/interfacectl-validator/test/authoring-contract.test.mjs index 2055bf7..15364e3 100644 --- a/packages/interfacectl-validator/test/authoring-contract.test.mjs +++ b/packages/interfacectl-validator/test/authoring-contract.test.mjs @@ -132,6 +132,290 @@ test("validateContractStructure rejects runtime metadata that references unknown ); }); +test("validateContractStructure accepts target acquisition metadata with viewport, context, and interaction overrides", async () => { + const schema = getBundledContractSchema(); + const contract = await loadFixture(); + contract.surfaces[0].runtime = { + policy: "warn", + contexts: [ + { + id: "checkout", + when: "route == '/checkout'", + }, + ], + }; + contract.surfaces[0].layout.targetAcquisition = { + policy: "warn", + modality: "touch-mouse", + minHitAreaPx: 44, + viewportOverrides: [ + { + viewport: "mobile", + minHitAreaPx: 48, + }, + ], + contextOverrides: [ + { + context: "checkout", + destructiveGapPx: 24, + }, + ], + }; + contract.components[1].interactions[0].targetAcquisition = { + exceptionId: "feature-card.compact-nav", + rationale: "Marketing rail keeps secondary actions compact.", + minHitAreaPx: 40, + }; + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, true, JSON.stringify(result.errors)); +}); + +test("validateContractStructure rejects target acquisition overrides that reference unknown viewport or context ids", async () => { + const schema = getBundledContractSchema(); + const contract = await loadFixture(); + contract.surfaces[0].layout.targetAcquisition = { + policy: "warn", + viewportOverrides: [{ viewport: "tablet" }], + contextOverrides: [{ context: "checkout" }], + }; + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, false); + assert.ok( + result.errors.some((error) => error.includes("/surfaces/reference-target-web/layout/targetAcquisition/viewportOverrides/tablet")), + `expected viewport override validation error, got ${JSON.stringify(result.errors)}`, + ); + assert.ok( + result.errors.some((error) => error.includes("/surfaces/reference-target-web/layout/targetAcquisition/contextOverrides/checkout")), + `expected context override validation error, got ${JSON.stringify(result.errors)}`, + ); +}); + +test("validateContractStructure rejects duplicate target acquisition exception ids within a component", async () => { + const schema = getBundledContractSchema(); + const contract = await loadFixture(); + contract.components[1].interactions[0].targetAcquisition = { + exceptionId: "feature-card.compact-nav", + rationale: "First exception", + }; + contract.components[1].interactions.push({ + id: "secondary-cta", + trigger: "click secondary", + effect: "navigate", + navigationTarget: "#secondary", + targetAcquisition: { + exceptionId: "feature-card.compact-nav", + rationale: "Duplicate exception id", + }, + }); + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, false); + assert.ok( + result.errors.some((error) => error.includes("/components/feature-card/interactions/secondary-cta/targetAcquisition/exceptionId")), + `expected duplicate exception validation error, got ${JSON.stringify(result.errors)}`, + ); +}); + +test("validateContractStructure accepts feedback recovery metadata with required async contexts", async () => { + const schema = getBundledContractSchema(); + const contract = await loadFixture(); + contract.surfaces[0].runtime = { + policy: "warn", + feedbackRecovery: { + policy: "warn", + requiredStateKinds: ["loading", "empty", "error", "success"], + }, + contexts: [ + { + id: "loading", + when: "request == pending", + kind: "loading", + blockedActionsWhilePending: ["primary-cta"], + }, + { + id: "empty", + when: "items.length == 0", + kind: "empty", + }, + { + id: "error", + when: "request == failed", + kind: "error", + requiredRecoveryActions: ["retry"], + preserveSections: ["page.intro"], + preserveLastGoodContent: true, + }, + { + id: "success", + when: "request == fulfilled", + kind: "success", + }, + ], + }; + contract.components[0].interactions = [ + { + id: "primary-cta", + trigger: "click primary cta", + effect: "navigate", + navigationTarget: "#next", + }, + ]; + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, true, JSON.stringify(result.errors)); +}); + +test("validateContractStructure rejects feedback recovery contexts without kind", async () => { + const schema = getBundledContractSchema(); + const contract = await loadFixture(); + contract.surfaces[0].runtime = { + feedbackRecovery: { + policy: "warn", + }, + contexts: [ + { + id: "loading", + when: "request == pending", + blockedActionsWhilePending: ["primary-cta"], + }, + ], + }; + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, false); + assert.ok( + result.errors.some((error) => error.includes("/surfaces/reference-target-web/runtime/contexts/loading must declare kind")), + `expected feedback context kind validation error, got ${JSON.stringify(result.errors)}`, + ); +}); + +test("validateContractStructure rejects feedback recovery requiredStateKinds without matching runtime contexts", async () => { + const schema = getBundledContractSchema(); + const contract = await loadFixture(); + contract.surfaces[0].runtime = { + feedbackRecovery: { + policy: "warn", + requiredStateKinds: ["loading", "error"], + }, + contexts: [ + { + id: "loading", + when: "request == pending", + kind: "loading", + }, + ], + }; + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, false); + assert.ok( + result.errors.some((error) => error.includes("/surfaces/reference-target-web/runtime/feedbackRecovery/requiredStateKinds/error")), + `expected feedback requiredStateKinds validation error, got ${JSON.stringify(result.errors)}`, + ); +}); + +test("validateContractStructure rejects feedback recovery runtime contexts that reference unknown interaction ids", async () => { + const schema = getBundledContractSchema(); + const contract = await loadFixture(); + contract.surfaces[0].runtime = { + feedbackRecovery: { + policy: "warn", + }, + contexts: [ + { + id: "loading", + when: "request == pending", + kind: "loading", + blockedActionsWhilePending: ["missing-action"], + }, + { + id: "empty", + when: "items.length == 0", + kind: "empty", + }, + { + id: "error", + when: "request == failed", + kind: "error", + }, + ], + }; + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, false); + assert.ok( + result.errors.some((error) => error.includes("/surfaces/reference-target-web/runtime/contexts/loading/blockedActionsWhilePending/missing-action")), + `expected blockedActionsWhilePending validation error, got ${JSON.stringify(result.errors)}`, + ); +}); + +test("validateContractStructure rejects invalid feedback recovery action values", async () => { + const schema = getBundledContractSchema(); + const contract = await loadFixture(); + contract.surfaces[0].runtime = { + feedbackRecovery: { + policy: "warn", + }, + contexts: [ + { + id: "loading", + when: "request == pending", + kind: "loading", + }, + { + id: "empty", + when: "items.length == 0", + kind: "empty", + }, + { + id: "error", + when: "request == failed", + kind: "error", + requiredRecoveryActions: ["retry", "reboot"], + }, + ], + }; + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, false); + assert.ok( + result.errors.some((error) => error.includes("/surfaces/0/runtime/contexts/2/requiredRecoveryActions/1")), + `expected recovery action schema validation error, got ${JSON.stringify(result.errors)}`, + ); +}); + +test("validateContractStructure rejects duplicate runtime context ids", async () => { + const schema = getBundledContractSchema(); + const contract = await loadFixture(); + contract.surfaces[0].runtime = { + feedbackRecovery: { + policy: "warn", + }, + contexts: [ + { + id: "loading", + when: "request == pending", + kind: "loading", + }, + { + id: "loading", + when: "request == pending again", + kind: "loading", + }, + { + id: "empty", + when: "items.length == 0", + kind: "empty", + }, + { + id: "error", + when: "request == failed", + kind: "error", + }, + ], + }; + const result = validateContractStructure(contract, schema); + assert.equal(result.ok, false); + assert.ok( + result.errors.some((error) => error.includes("/surfaces/reference-target-web/runtime/contexts/loading must use a unique context id")), + `expected duplicate context id validation error, got ${JSON.stringify(result.errors)}`, + ); +}); + test("validateContractStructure rejects authoring metadata on non-web surfaces", async () => { const schema = getBundledContractSchema(); const contract = await loadFixture(); diff --git a/packages/interfacectl-validator/test/evaluate-contract.test.mjs b/packages/interfacectl-validator/test/evaluate-contract.test.mjs index b22aeb0..2e3d7e5 100644 --- a/packages/interfacectl-validator/test/evaluate-contract.test.mjs +++ b/packages/interfacectl-validator/test/evaluate-contract.test.mjs @@ -1004,6 +1004,34 @@ test("flow policy emits flow-transition-required when required transition is mis ]); }); +test("flow policy does not cascade missing transition findings when a required step is absent", () => { + const contract = makeFlowContract("warn", { + minSteps: 2, + requiredSteps: ["start", "review", "confirm"], + requiredTransitions: [ + { from: "start", to: "review" }, + { from: "review", to: "confirm" }, + ], + terminalSteps: ["confirm"], + }); + const descriptor = makeFlowDescriptor({ + flows: [ + { + flowId: "checkout", + steps: [{ id: "start" }, { id: "confirm", terminal: true }], + transitions: [{ from: "start", to: "review" }], + }, + ], + }); + + const report = evaluateSurfaceCompliance(contract, descriptor); + assert.ok(report.violations.some((item) => item.type === "flow-steps-required")); + assert.equal( + report.violations.some((item) => item.type === "flow-transition-required"), + false, + ); +}); + test("flow policy emits flow-terminal-invalid when terminal step has outgoing transition", () => { const contract = makeFlowContract("strict", { minSteps: 1, @@ -1031,6 +1059,27 @@ test("flow policy emits flow-terminal-invalid when terminal step has outgoing tr ]); }); +test("flow policy emits flow-unobservable when runtime validation cannot observe contract-scoped flow markers", () => { + const contract = makeFlowContract("warn"); + const descriptor = makeFlowDescriptor({ + flows: [], + flowObservation: { + source: "none-observed", + observedFlowCount: 0, + location: "http://127.0.0.1:3000/", + }, + }); + + const report = evaluateSurfaceCompliance(contract, descriptor); + const violation = report.violations.find( + (item) => item.type === "flow-unobservable", + ); + assert.ok(violation); + assert.equal(violation.details?.policy, "warn"); + assert.deepEqual(violation.details?.requiredMetrics, ["contractScopedFlows"]); + assert.deepEqual(violation.details?.missingMetrics, ["contractScopedFlows"]); +}); + test("reports landing pattern violations for nested sections and custom background", () => { const contract = { ...baseContract, @@ -1146,3 +1195,719 @@ test("passes landing pattern validation when the shared structure is preserved", ); assert.equal(landingViolations.length, 0); }); + +test("reports target acquisition violations for undersized, crowded, and edge-pinned controls", () => { + const contract = { + ...baseContract, + surfaces: [ + { + id: "surface-target-acquisition", + displayName: "Surface Target Acquisition", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-targets)"], + layout: { + maxContentWidth: 1120, + targetAcquisition: { + policy: "strict", + modality: "touch-mouse", + minHitAreaPx: 44, + minGapPx: 8, + minEdgeInsetPx: 8, + destructiveGapPx: 16, + }, + }, + }, + ], + components: [ + { + id: "toolbar", + intent: "toolbar", + slots: [{ id: "actions", kind: "action", required: true }], + interactions: [ + { + id: "compact-open", + trigger: "click compact open", + effect: "open", + targetAcquisition: { + exceptionId: "toolbar.compact-open", + rationale: "Toolbar icon is intentionally compact.", + minHitAreaPx: 32, + }, + }, + ], + }, + ], + }; + + const descriptor = { + surfaceId: "surface-target-acquisition", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-targets)" }], + colors: [{ value: "var(--color-primary)" }], + layout: { + maxContentWidth: 1000, + containers: ["contract-container"], + }, + motion: [], + interactiveTargets: [ + { + id: "compact-open", + role: "button", + componentId: "toolbar", + interactionId: "compact-open", + source: "apps/surface/app/page.tsx", + boundingBox: { x: 0, y: 0, width: 32, height: 32 }, + hitAreaPx: 1024, + nearestNeighborGapPx: 6, + edgeInsetPx: 4, + classification: "primary", + }, + { + id: "delete-workspace", + role: "button", + source: "apps/surface/app/page.tsx", + boundingBox: { x: 0, y: 0, width: 44, height: 44 }, + hitAreaPx: 1936, + nearestNeighborGapPx: 10, + nearestNeighborClassification: "primary", + edgeInsetPx: 12, + classification: "destructive", + }, + ], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const violationTypes = report.violations.map((violation) => violation.type); + + assert.ok(!violationTypes.includes("target-hit-area-too-small"), "compact override should suppress hit-area failure"); + assert.ok(violationTypes.includes("target-gap-too-tight")); + assert.ok(violationTypes.includes("target-edge-inset-too-small")); + assert.ok(violationTypes.includes("destructive-target-too-close")); +}); + +test("singleton measurable target does not emit target-unobservable when no neighbor exists", () => { + const contract = { + ...baseContract, + surfaces: [ + { + id: "surface-target-acquisition-singleton", + displayName: "Surface Target Acquisition Singleton", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-targets)"], + layout: { + maxContentWidth: 1120, + targetAcquisition: { + policy: "strict", + modality: "touch-mouse", + minHitAreaPx: 44, + minGapPx: 8, + minEdgeInsetPx: 8, + destructiveGapPx: 16, + }, + }, + }, + ], + }; + + const descriptor = { + surfaceId: "surface-target-acquisition-singleton", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-targets)" }], + colors: [{ value: "var(--color-primary)" }], + layout: { + maxContentWidth: 1000, + containers: ["contract-container"], + }, + motion: [], + interactiveTargets: [ + { + id: "solo-review", + role: "button", + source: "apps/surface/app/page.tsx", + boundingBox: { x: 24, y: 24, width: 48, height: 48 }, + hitAreaPx: 2304, + nearestNeighborGapPx: null, + edgeInsetPx: 24, + classification: "default", + }, + ], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const violationTypes = report.violations.map((violation) => violation.type); + + assert.equal(violationTypes.includes("target-unobservable"), false); + assert.equal(violationTypes.includes("target-gap-too-tight"), false); + assert.equal(violationTypes.includes("destructive-target-too-close"), false); +}); + +test("surface-level target acquisition reports unobservable when remote validation falls back to all visible controls", () => { + const contract = { + ...baseContract, + surfaces: [ + { + id: "surface-target-acquisition-fallback", + displayName: "Surface Target Acquisition Fallback", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-targets)"], + layout: { + maxContentWidth: 1120, + targetAcquisition: { + policy: "warn", + minHitAreaPx: 44, + }, + }, + }, + ], + }; + + const descriptor = { + surfaceId: "surface-target-acquisition-fallback", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-targets)" }], + colors: [{ value: "var(--color-primary)" }], + layout: { + maxContentWidth: 1000, + containers: ["contract-container"], + }, + motion: [], + interactiveTargets: [], + interactiveTargetObservation: { + source: "all-visible-fallback", + allVisibleCount: 3, + contractScopedCount: 0, + }, + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const violation = report.violations.find((item) => item.type === "target-unobservable"); + assert.ok(violation); + assert.equal(violation.details?.policy, "warn"); + assert.deepEqual(violation.details?.missingMetrics, ["contractScopedInteractiveTargets"]); + assert.equal(violation.details?.observationSource, "all-visible-fallback"); +}); + +test("target acquisition warn policy preserves warn metadata for reporting", () => { + const contract = { + ...baseContract, + surfaces: [ + { + id: "surface-target-acquisition-warn", + displayName: "Surface Target Acquisition Warn", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-targets)"], + layout: { + maxContentWidth: 1120, + targetAcquisition: { + policy: "warn", + minHitAreaPx: 44, + }, + }, + }, + ], + }; + + const descriptor = { + surfaceId: "surface-target-acquisition-warn", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-targets)" }], + colors: [{ value: "var(--color-primary)" }], + layout: { + maxContentWidth: 1000, + containers: ["contract-container"], + }, + motion: [], + interactiveTargets: [ + { + id: "missing-metrics", + role: "button", + source: "apps/surface/app/page.tsx", + nearestNeighborGapPx: null, + edgeInsetPx: null, + }, + ], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const violation = report.violations.find((item) => item.type === "target-unobservable"); + assert.ok(violation); + assert.equal(violation.details?.policy, "warn"); +}); + +test("reports feedback.state-missing for required async states that are not authored", () => { + const contract = { + ...baseContract, + surfaces: [ + { + id: "surface-feedback-missing", + displayName: "Surface Feedback Missing", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-feedback)"], + layout: { + maxContentWidth: 960, + }, + runtime: { + feedbackRecovery: { + policy: "warn", + }, + contexts: [ + { id: "loading", when: "request == pending", kind: "loading" }, + { id: "empty", when: "items.length == 0", kind: "empty" }, + { id: "error", when: "request == failed", kind: "error" }, + ], + }, + }, + ], + }; + + const descriptor = { + surfaceId: "surface-feedback-missing", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-feedback)" }], + colors: [{ value: "var(--color-primary)" }], + layout: { + maxContentWidth: 900, + containers: ["contract-container"], + }, + motion: [], + asyncStates: [ + { + id: "success", + kind: "success", + source: "apps/surface/app/page.tsx", + sectionIds: ["main.hero"], + }, + ], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const stateMissingViolations = report.violations.filter( + (violation) => violation.type === "feedback-state-missing", + ); + assert.deepEqual( + stateMissingViolations.map((violation) => violation.details?.kind).sort(), + ["empty", "error", "loading"], + ); + assert.ok(stateMissingViolations.every((violation) => violation.details?.policy === "warn")); +}); + +test("reports feedback.recovery-action-missing when error recovery affordances are absent", () => { + const contract = { + ...baseContract, + components: [ + { + id: "dashboard-actions", + intent: "actions", + slots: [{ id: "actions", kind: "action", required: true }], + interactions: [ + { + id: "retry-dashboard", + trigger: "click retry dashboard", + effect: "set-state", + }, + ], + }, + ], + surfaces: [ + { + id: "surface-feedback-recovery", + displayName: "Surface Feedback Recovery", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-feedback)"], + layout: { + maxContentWidth: 960, + }, + runtime: { + feedbackRecovery: { + policy: "warn", + }, + contexts: [ + { id: "loading", when: "request == pending", kind: "loading" }, + { id: "empty", when: "items.length == 0", kind: "empty" }, + { + id: "error", + when: "request == failed", + kind: "error", + requiredRecoveryActions: ["retry"], + }, + ], + }, + }, + ], + }; + + const descriptor = { + surfaceId: "surface-feedback-recovery", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-feedback)" }], + colors: [{ value: "var(--color-primary)" }], + layout: { + maxContentWidth: 900, + containers: ["contract-container"], + }, + motion: [], + asyncStates: [ + { + id: "loading", + kind: "loading", + source: "apps/surface/app/loading.tsx", + }, + { + id: "empty", + kind: "empty", + source: "apps/surface/app/page.tsx", + }, + { + id: "error", + kind: "error", + source: "apps/surface/app/error.tsx", + recoveryActions: [], + }, + ], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const violation = report.violations.find( + (item) => item.type === "feedback-recovery-action-missing", + ); + assert.ok(violation); + assert.deepEqual(violation.details?.missingRecoveryActions, ["retry"]); + assert.equal(violation.details?.policy, "warn"); +}); + +test("reports feedback.pending-action-not-blocked when pending submit remains enabled", () => { + const contract = { + ...baseContract, + components: [ + { + id: "dashboard-actions", + intent: "actions", + slots: [{ id: "actions", kind: "action", required: true }], + interactions: [ + { + id: "submit-refresh", + trigger: "click refresh", + effect: "submit", + }, + ], + }, + ], + surfaces: [ + { + id: "surface-feedback-pending", + displayName: "Surface Feedback Pending", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-feedback)"], + layout: { + maxContentWidth: 960, + }, + runtime: { + feedbackRecovery: { + policy: "warn", + }, + contexts: [ + { + id: "loading", + when: "request == pending", + kind: "loading", + blockedActionsWhilePending: ["submit-refresh"], + }, + { id: "empty", when: "items.length == 0", kind: "empty" }, + { id: "error", when: "request == failed", kind: "error" }, + ], + }, + }, + ], + }; + + const descriptor = { + surfaceId: "surface-feedback-pending", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-feedback)" }], + colors: [{ value: "var(--color-primary)" }], + layout: { + maxContentWidth: 900, + containers: ["contract-container"], + }, + motion: [], + asyncStates: [ + { + id: "loading", + kind: "loading", + source: "apps/surface/app/loading.tsx", + blockedActions: [ + { + interactionId: "submit-refresh", + disabled: false, + }, + ], + }, + { + id: "empty", + kind: "empty", + source: "apps/surface/app/page.tsx", + }, + { + id: "error", + kind: "error", + source: "apps/surface/app/error.tsx", + }, + ], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const violation = report.violations.find( + (item) => item.type === "feedback-pending-action-not-blocked", + ); + assert.ok(violation); + assert.deepEqual(violation.details?.missingBlockedActions, ["submit-refresh"]); + assert.equal(violation.details?.policy, "warn"); +}); + +test("reports feedback.last-good-content-missing when required preserved content is absent", () => { + const contract = { + ...baseContract, + sections: [ + ...baseContract.sections, + { + id: "main.queue", + intent: "queue", + description: "Queue section", + }, + ], + surfaces: [ + { + id: "surface-feedback-preserve", + displayName: "Surface Feedback Preserve", + type: "web", + requiredSections: ["main.hero", "main.queue"], + allowedFonts: ["var(--font-feedback)"], + layout: { + maxContentWidth: 960, + }, + runtime: { + feedbackRecovery: { + policy: "warn", + requiredStateKinds: ["error"], + }, + contexts: [ + { + id: "error", + when: "request == failed", + kind: "error", + preserveSections: ["main.hero", "main.queue"], + preserveLastGoodContent: true, + }, + ], + }, + }, + ], + }; + + const descriptor = { + surfaceId: "surface-feedback-preserve", + sections: [{ id: "main.hero" }, { id: "main.queue" }], + fonts: [{ value: "var(--font-feedback)" }], + colors: [{ value: "var(--color-primary)" }], + layout: { + maxContentWidth: 900, + containers: ["contract-container"], + }, + motion: [], + asyncStates: [ + { + id: "error", + kind: "error", + source: "apps/surface/app/error.tsx", + sectionIds: ["main.hero"], + preserveLastGoodContent: false, + }, + ], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const violation = report.violations.find( + (item) => item.type === "feedback-last-good-content-missing", + ); + assert.ok(violation); + assert.deepEqual(violation.details?.missingPreserveSections, ["main.queue"]); + assert.equal(violation.details?.preserveLastGoodContentObserved, false); + assert.equal(violation.details?.policy, "warn"); +}); + +test("feedback recovery passes when authored states and affordances satisfy the contract", () => { + const contract = { + ...baseContract, + components: [ + { + id: "dashboard-actions", + intent: "actions", + slots: [{ id: "actions", kind: "action", required: true }], + interactions: [ + { + id: "submit-refresh", + trigger: "click refresh", + effect: "submit", + }, + { + id: "retry-dashboard", + trigger: "click retry dashboard", + effect: "set-state", + }, + ], + }, + ], + sections: [ + ...baseContract.sections, + { + id: "main.queue", + intent: "queue", + description: "Queue section", + }, + ], + surfaces: [ + { + id: "surface-feedback-pass", + displayName: "Surface Feedback Pass", + type: "web", + requiredSections: ["main.hero", "main.queue"], + allowedFonts: ["var(--font-feedback)"], + layout: { + maxContentWidth: 960, + }, + runtime: { + feedbackRecovery: { + policy: "warn", + requiredStateKinds: ["loading", "empty", "error", "success"], + }, + contexts: [ + { + id: "loading", + when: "request == pending", + kind: "loading", + blockedActionsWhilePending: ["submit-refresh"], + }, + { id: "empty", when: "items.length == 0", kind: "empty" }, + { + id: "error", + when: "request == failed", + kind: "error", + requiredRecoveryActions: ["retry"], + preserveSections: ["main.hero", "main.queue"], + preserveLastGoodContent: true, + }, + { id: "success", when: "request == fulfilled", kind: "success" }, + ], + }, + }, + ], + }; + + const descriptor = { + surfaceId: "surface-feedback-pass", + sections: [{ id: "main.hero" }, { id: "main.queue" }], + fonts: [{ value: "var(--font-feedback)" }], + colors: [{ value: "var(--color-primary)" }], + layout: { + maxContentWidth: 900, + containers: ["contract-container"], + }, + motion: [], + asyncStates: [ + { + id: "loading", + kind: "loading", + source: "apps/surface/app/loading.tsx", + blockedActions: [ + { + interactionId: "submit-refresh", + disabled: true, + }, + ], + }, + { + id: "empty", + kind: "empty", + source: "apps/surface/app/page.tsx", + }, + { + id: "error", + kind: "error", + source: "apps/surface/app/error.tsx", + sectionIds: ["main.hero", "main.queue"], + recoveryActions: ["retry"], + preserveLastGoodContent: true, + }, + { + id: "success", + kind: "success", + source: "apps/surface/app/page.tsx", + sectionIds: ["main.hero", "main.queue"], + }, + ], + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const feedbackViolations = report.violations.filter((violation) => + violation.type.startsWith("feedback-"), + ); + assert.equal(feedbackViolations.length, 0, JSON.stringify(feedbackViolations, null, 2)); +}); + +test("surface-level feedback recovery reports unobservable when remote validation finds no contract-scoped async states", () => { + const contract = { + ...baseContract, + surfaces: [ + { + id: "surface-feedback-unobservable", + displayName: "Surface Feedback Unobservable", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["var(--font-feedback)"], + layout: { + maxContentWidth: 960, + }, + runtime: { + feedbackRecovery: { + policy: "warn", + }, + contexts: [ + { id: "loading", when: "request == pending", kind: "loading" }, + { id: "empty", when: "items.length == 0", kind: "empty" }, + { id: "error", when: "request == failed", kind: "error" }, + ], + }, + }, + ], + }; + + const descriptor = { + surfaceId: "surface-feedback-unobservable", + sections: [{ id: "main.hero" }], + fonts: [{ value: "var(--font-feedback)" }], + colors: [{ value: "var(--color-primary)" }], + layout: { + maxContentWidth: 900, + containers: ["contract-container"], + }, + motion: [], + asyncStates: [], + asyncStateObservation: { + source: "none-observed", + observedStateCount: 0, + }, + }; + + const report = evaluateSurfaceCompliance(contract, descriptor); + const violation = report.violations.find((item) => item.type === "feedback-unobservable"); + assert.ok(violation); + assert.equal(violation.details?.policy, "warn"); + assert.deepEqual(violation.details?.missingMetrics, ["contractScopedAsyncStates"]); +}); From c3f2225a737be4febc72f506741c4720a191eaa8 Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sun, 22 Mar 2026 14:04:48 -0700 Subject: [PATCH 2/2] feat(cli): validate observed targets, flows, and async states --- .../dist/commands/describe.d.ts.map | 2 +- .../dist/commands/describe.js | 23 +- .../dist/commands/generation-session.d.ts.map | 2 +- .../dist/commands/generation-session.js | 1 + .../dist/commands/validate.d.ts | 1 + .../dist/commands/validate.d.ts.map | 2 +- .../dist/commands/validate.js | 325 ++++- .../dist/descriptors/static-analysis.d.ts.map | 2 +- .../dist/descriptors/static-analysis.js | 370 ++++++ packages/interfacectl-cli/dist/index.js | 2 + .../dist/utils/browser-session.d.ts | 62 + .../dist/utils/browser-session.d.ts.map | 2 +- .../dist/utils/browser-session.js | 211 ++++ .../interfacectl-cli/src/commands/describe.ts | 23 +- .../src/commands/generation-session.ts | 1 + .../interfacectl-cli/src/commands/validate.ts | 377 +++++- .../src/descriptors/static-analysis.ts | 517 ++++++++ packages/interfacectl-cli/src/index.ts | 5 + .../src/utils/browser-session.ts | 351 ++++++ .../test/static-analysis.test.mjs | 368 ++++++ .../test/validate-remote-observation.test.mjs | 1107 +++++++++++++++++ .../test/violation-classifier.test.mjs | 1 + 22 files changed, 3736 insertions(+), 19 deletions(-) create mode 100644 packages/interfacectl-cli/test/validate-remote-observation.test.mjs diff --git a/packages/interfacectl-cli/dist/commands/describe.d.ts.map b/packages/interfacectl-cli/dist/commands/describe.d.ts.map index 5547a4c..1ca2edf 100644 --- a/packages/interfacectl-cli/dist/commands/describe.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/describe.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"describe.d.ts","sourceRoot":"","sources":["../../src/commands/describe.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,sBAAsB;IACrC,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA4PD;;;GAGG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CA0GjB"} \ No newline at end of file +{"version":3,"file":"describe.d.ts","sourceRoot":"","sources":["../../src/commands/describe.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,sBAAsB;IACrC,YAAY,EAAE,MAAM,CAAC;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA4PD;;;GAGG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAuHjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/describe.js b/packages/interfacectl-cli/dist/commands/describe.js index 784d400..d3c9b1f 100644 --- a/packages/interfacectl-cli/dist/commands/describe.js +++ b/packages/interfacectl-cli/dist/commands/describe.js @@ -268,11 +268,24 @@ export async function runDescribeCommand(options) { console.error(`Error: ${flowDescriptorResult.error}`); return 1; } - const descriptors = descriptorResult.descriptors.map((descriptor) => ({ - ...descriptor, - flows: flowDescriptorResult.flowsBySurface.get(descriptor.surfaceId), - flowDescriptorPath: flowDescriptorResult.paths.get(descriptor.surfaceId), - })); + const descriptors = descriptorResult.descriptors.map((descriptor) => { + const artifactFlows = flowDescriptorResult.flowsBySurface.get(descriptor.surfaceId); + const flowDescriptorPath = flowDescriptorResult.paths.get(descriptor.surfaceId); + return { + ...descriptor, + ...(artifactFlows + ? { + flows: artifactFlows, + flowObservation: { + source: "flow-descriptor-artifact", + observedFlowCount: artifactFlows.length, + ...(flowDescriptorPath ? { location: flowDescriptorPath } : {}), + }, + } + : {}), + ...(flowDescriptorPath ? { flowDescriptorPath } : {}), + }; + }); const serialized = `${JSON.stringify(descriptors, null, 2)}\n`; await writeFileWithParents(outPath, serialized); return 0; diff --git a/packages/interfacectl-cli/dist/commands/generation-session.d.ts.map b/packages/interfacectl-cli/dist/commands/generation-session.d.ts.map index 714f39a..77aaf71 100644 --- a/packages/interfacectl-cli/dist/commands/generation-session.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/generation-session.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"generation-session.d.ts","sourceRoot":"","sources":["../../src/commands/generation-session.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"generation-session.d.ts","sourceRoot":"","sources":["../../src/commands/generation-session.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/generation-session.js b/packages/interfacectl-cli/dist/commands/generation-session.js index 0be0b39..1fdd7ae 100644 --- a/packages/interfacectl-cli/dist/commands/generation-session.js +++ b/packages/interfacectl-cli/dist/commands/generation-session.js @@ -1242,6 +1242,7 @@ function inferContractPath(surfaceId, findingCode, repair) { case "restore-required-flows": case "restore-required-flow-steps": case "restore-required-transitions": + case "restore-flow-observability": return `surfaces[id=${surfaceId}].flows`; case "remove-prohibited-primitives": return `surfaces[id=${surfaceId}].shell`; diff --git a/packages/interfacectl-cli/dist/commands/validate.d.ts b/packages/interfacectl-cli/dist/commands/validate.d.ts index 267d6f0..7ea649a 100644 --- a/packages/interfacectl-cli/dist/commands/validate.d.ts +++ b/packages/interfacectl-cli/dist/commands/validate.d.ts @@ -6,6 +6,7 @@ export interface ValidateCommandOptions { schemaPath?: string; workspaceRoot?: string; surfaceFilters?: string[]; + remoteUrl?: string; descriptorOverrides?: SurfaceDescriptor[]; outputFormat?: OutputFormat; outputPath?: string; diff --git a/packages/interfacectl-cli/dist/commands/validate.d.ts.map b/packages/interfacectl-cli/dist/commands/validate.d.ts.map index ab2227b..dd77947 100644 --- a/packages/interfacectl-cli/dist/commands/validate.d.ts.map +++ b/packages/interfacectl-cli/dist/commands/validate.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAGA,OAAO,EAKL,KAAK,iBAAiB,EAIvB,MAAM,kCAAkC,CAAC;AAK1C,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAOlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAoCpC,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,mBAAmB,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAC1C,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAiUjB"} \ No newline at end of file +{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAGA,OAAO,EAKL,KAAK,iBAAiB,EAIvB,MAAM,kCAAkC,CAAC;AAS1C,OAAO,EAAsB,KAAK,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAOlF,KAAK,YAAY,GAAG,MAAM,GAAG,MAAM,CAAC;AAoCpC,MAAM,WAAW,sBAAsB;IACrC,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAC1C,YAAY,CAAC,EAAE,YAAY,CAAC;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAED,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,sBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAwWjB"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/commands/validate.js b/packages/interfacectl-cli/dist/commands/validate.js index 566fa64..a76df39 100644 --- a/packages/interfacectl-cli/dist/commands/validate.js +++ b/packages/interfacectl-cli/dist/commands/validate.js @@ -3,6 +3,7 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"; import pc from "picocolors"; import { validateContractStructure, evaluateContractCompliance, getBundledContractSchema, } from "@surfaces/interfacectl-validator"; import { collectSurfaceDescriptors, } from "../descriptors/static-analysis.js"; +import { observeRemotePage, } from "../utils/browser-session.js"; import { getExitCodeVersion } from "../utils/exit-codes.js"; import { classifyViolationType, getExitCodeForCategory, } from "../utils/violation-classifier.js"; export async function runValidateCommand(options) { @@ -211,14 +212,50 @@ export async function runValidateCommand(options) { return finalize(e0ExitCode, contract.version ?? initialContractVersion); } descriptorsWithFlowArtifacts = structuralDescriptorResult.descriptors.map((descriptor) => { + const artifactFlows = flowDescriptorResult.flowsBySurface.get(descriptor.surfaceId); const flowDescriptorPath = flowDescriptorResult.paths.get(descriptor.surfaceId); return { ...descriptor, - flows: flowDescriptorResult.flowsBySurface.get(descriptor.surfaceId), - flowDescriptorPath, + ...(artifactFlows + ? { + flows: artifactFlows, + flowObservation: { + source: "flow-descriptor-artifact", + observedFlowCount: artifactFlows.length, + ...(flowDescriptorPath ? { location: flowDescriptorPath } : {}), + }, + } + : {}), + ...(flowDescriptorPath ? { flowDescriptorPath } : {}), }; }); } + if (options.remoteUrl) { + const remoteObservationResult = await augmentDescriptorsWithRemoteObservation({ + remoteUrl: options.remoteUrl, + contract, + descriptors: descriptorsWithFlowArtifacts, + surfaceFilters, + }); + if (!remoteObservationResult.ok) { + const message = remoteObservationResult.message; + if (!isJson) { + printHeader(pc.red("âś– Remote observation failed"), textReporter); + textReporter.error(pc.red(message)); + } + findings.push({ + code: remoteObservationResult.code, + severity: "error", + category: "E0", + message, + surface: remoteObservationResult.surfaceId, + location: remoteObservationResult.location, + }); + const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; + return finalize(e0ExitCode, contract.version ?? initialContractVersion); + } + descriptorsWithFlowArtifacts = remoteObservationResult.descriptors; + } const summary = evaluateContractCompliance(contract, descriptorsWithFlowArtifacts); const violationFindings = mapViolationsToFindings(summary); findings.push(...violationFindings); @@ -308,6 +345,165 @@ function issueToFinding(issue, severity) { location: issue.location, }; } +async function augmentDescriptorsWithRemoteObservation(input) { + if (input.descriptors.length === 0) { + return { + ok: false, + code: "remote-observation.surface-missing", + message: "Remote observation requires exactly one validated surface, but none were resolved.", + }; + } + if (input.descriptors.length > 1) { + return { + ok: false, + code: "remote-observation.surface-ambiguous", + message: input.surfaceFilters.size > 0 + ? `Remote observation requires exactly one validated surface, but ${input.descriptors.length} matched the provided filters.` + : `Remote observation requires exactly one validated surface, but ${input.descriptors.length} surfaces were selected. Use --surface to narrow the target.`, + }; + } + try { + const observation = await observeRemotePage({ + url: input.remoteUrl, + }); + if (observation.sourceHealth.status !== "ok") { + return { + ok: false, + code: "remote-observation.source-unavailable", + message: `Remote observation resolved to a ${observation.sourceHealth.status} page at ${observation.finalUrl}. ` + + "Provide an accessible URL before using --remote-url validation.", + surfaceId: input.descriptors[0]?.surfaceId, + location: observation.finalUrl, + }; + } + const descriptor = input.descriptors[0]; + const surface = input.contract.surfaces.find((candidate) => candidate.id === descriptor.surfaceId); + const targetAcquisitionEnabled = Boolean(surface?.layout.targetAcquisition && + surface.layout.targetAcquisition.policy !== "off"); + const feedbackRecoveryEnabled = Boolean(surface?.runtime?.feedbackRecovery && + surface.runtime.feedbackRecovery.policy !== "off"); + const flowPolicyEnabled = Boolean(surface?.flows && + surface.flows.policy !== "off"); + const remoteTargets = mapRemoteObservationTargets(observation); + const remoteFlows = mapRemoteObservationFlows(observation); + const remoteAsyncStates = mapRemoteObservationAsyncStates(observation); + const interactiveTargets = targetAcquisitionEnabled && + remoteTargets.collection.source !== "contract-scoped" + ? [] + : remoteTargets.targets; + const shouldFallbackToFlowArtifact = descriptor.flowObservation?.source === "flow-descriptor-artifact"; + return { + ok: true, + descriptors: [ + { + ...descriptor, + ...(flowPolicyEnabled + ? remoteFlows.collection.source === "contract-scoped" + ? { + flows: remoteFlows.flows, + flowObservation: remoteFlows.collection, + } + : shouldFallbackToFlowArtifact + ? {} + : { + flows: [], + flowObservation: remoteFlows.collection, + } + : {}), + interactiveTargets, + interactiveTargetObservation: remoteTargets.collection, + ...(feedbackRecoveryEnabled + ? { + asyncStates: remoteAsyncStates.states, + asyncStateObservation: remoteAsyncStates.collection, + } + : {}), + }, + ], + }; + } + catch (error) { + return { + ok: false, + code: "remote-observation.failed", + message: error instanceof Error ? error.message : String(error), + surfaceId: input.descriptors[0]?.surfaceId, + location: input.remoteUrl, + }; + } +} +function mapRemoteObservationTargets(observation) { + const observedTargets = observation.renderedStyles.interactiveTargets.map((target) => ({ + id: target.id, + role: target.role, + source: observation.finalUrl, + ...(target.selector ? { selector: target.selector } : {}), + boundingBox: { + x: target.boundingBox.x, + y: target.boundingBox.y, + width: target.boundingBox.width, + height: target.boundingBox.height, + }, + hitAreaPx: target.hitAreaPx, + nearestNeighborGapPx: target.nearestNeighborGapPx, + ...(target.nearestNeighborClassification + ? { nearestNeighborClassification: target.nearestNeighborClassification } + : {}), + edgeInsetPx: target.edgeInsetPx, + classification: target.classification, + })); + return { + targets: observedTargets, + collection: { + source: observation.renderedStyles.interactiveTargetCollection.source, + allVisibleCount: observation.renderedStyles.interactiveTargetCollection.allVisibleCount, + contractScopedCount: observation.renderedStyles.interactiveTargetCollection.contractScopedCount, + location: observation.finalUrl, + }, + }; +} +function mapRemoteObservationFlows(observation) { + const flows = observation.renderedStyles.flows.map((flow) => ({ + flowId: flow.flowId, + steps: flow.steps.map((step) => ({ + id: step.id, + ...(step.terminal ? { terminal: true } : {}), + })), + transitions: flow.transitions.map((transition) => ({ + from: transition.from, + to: transition.to, + })), + source: observation.finalUrl, + })); + return { + flows, + collection: { + source: observation.renderedStyles.flowCollection.source, + observedFlowCount: observation.renderedStyles.flowCollection.observedFlowCount, + location: observation.finalUrl, + }, + }; +} +function mapRemoteObservationAsyncStates(observation) { + const states = observation.renderedStyles.asyncStates.map((state) => ({ + id: state.id, + kind: state.kind, + source: observation.finalUrl, + contextId: state.id, + sectionIds: state.sectionIds, + recoveryActions: state.recoveryActions, + preserveLastGoodContent: state.preserveLastGoodContent, + blockedActions: state.blockedActions, + })); + return { + states, + collection: { + source: observation.renderedStyles.asyncStateCollection.source, + observedStateCount: observation.renderedStyles.asyncStateCollection.observedStateCount, + location: observation.finalUrl, + }, + }; +} function mapViolationsToFindings(summary) { const findings = []; const { contract } = summary; @@ -346,12 +542,23 @@ function mapViolationsToFindings(summary) { "marketing-typography-role-token": "marketing.typography.role-token", "motion-duration-not-allowed": "motion.duration", "motion-timing-not-allowed": "motion.timing", + "target-hit-area-too-small": "target.hit-area-too-small", + "target-gap-too-tight": "target.gap-too-tight", + "target-edge-inset-too-small": "target.edge-inset-too-small", + "destructive-target-too-close": "target.destructive-too-close", + "target-unobservable": "target.unobservable", + "feedback-state-missing": "feedback.state-missing", + "feedback-recovery-action-missing": "feedback.recovery-action-missing", + "feedback-pending-action-not-blocked": "feedback.pending-action-not-blocked", + "feedback-last-good-content-missing": "feedback.last-good-content-missing", + "feedback-unobservable": "feedback.unobservable", "descriptor-flows-missing": "descriptor.flows.missing", "flow-required-missing": "flow.required.missing", "flow-steps-min": "flow.steps.min", "flow-steps-required": "flow.steps.required", "flow-transition-required": "flow.transition.required", "flow-terminal-invalid": "flow.terminal.invalid", + "flow-unobservable": "flow.unobservable", "shell-owned-primitive-emitted": "shell.primitive.disallowed", }; for (const report of summary.surfaceReports) { @@ -497,6 +704,16 @@ function mapViolationsToFindings(summary) { } break; } + case "flow-unobservable": { + finding.expected = Array.isArray(details.requiredMetrics) + ? details.requiredMetrics + : ["contractScopedFlows"]; + finding.found = details.missingMetrics; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } case "layout-width-undetermined": { finding.expected = details.expectedMaxWidth; finding.found = null; @@ -507,6 +724,110 @@ function mapViolationsToFindings(summary) { finding.found = details.reportedWidth; break; } + case "target-hit-area-too-small": { + finding.expected = details.minHitAreaPx; + finding.found = { + width: details.width, + height: details.height, + targetId: details.targetId, + }; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "target-gap-too-tight": { + finding.expected = details.minGapPx; + finding.found = { + nearestNeighborGapPx: details.nearestNeighborGapPx, + targetId: details.targetId, + }; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "target-edge-inset-too-small": { + finding.expected = details.minEdgeInsetPx; + finding.found = { + edgeInsetPx: details.edgeInsetPx, + targetId: details.targetId, + }; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "destructive-target-too-close": { + finding.expected = details.destructiveGapPx; + finding.found = { + nearestNeighborGapPx: details.nearestNeighborGapPx, + targetId: details.targetId, + nearestNeighborClassification: details.nearestNeighborClassification, + }; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "target-unobservable": { + finding.expected = Array.isArray(details.requiredMetrics) + ? details.requiredMetrics + : ["boundingBox", "edgeInsetPx"]; + finding.found = details.missingMetrics; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "feedback-state-missing": { + finding.expected = details.kind; + finding.found = null; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "feedback-recovery-action-missing": { + finding.expected = details.expectedRecoveryActions; + finding.found = details.missingRecoveryActions; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "feedback-pending-action-not-blocked": { + finding.expected = details.expectedBlockedActions; + finding.found = details.missingBlockedActions; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "feedback-last-good-content-missing": { + finding.expected = { + preserveLastGoodContent: details.preserveLastGoodContentRequired, + preserveSections: details.expectedPreserveSections, + }; + finding.found = { + preserveLastGoodContent: details.preserveLastGoodContentObserved, + missingPreserveSections: details.missingPreserveSections, + }; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "feedback-unobservable": { + finding.expected = Array.isArray(details.requiredMetrics) + ? details.requiredMetrics + : ["contractScopedAsyncStates"]; + finding.found = details.missingMetrics; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } case "layout-container-missing": { finding.expected = details.requiredContainers ?? details.requiredContainer; 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 a6c148d..f3e133a 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,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 +{"version":3,"file":"static-analysis.d.ts","sourceRoot":"","sources":["../../src/descriptors/static-analysis.ts"],"names":[],"mappings":"AAGA,OAAO,EAOL,KAAK,iBAAiB,EAGtB,KAAK,iBAAiB,EAgBvB,MAAM,kCAAkC,CAAC;AAmN1C,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 7cbb7cd..fb37c83 100644 --- a/packages/interfacectl-cli/dist/descriptors/static-analysis.js +++ b/packages/interfacectl-cli/dist/descriptors/static-analysis.js @@ -11,6 +11,26 @@ const SECTION_DIVIDER_MODE_ATTRIBUTE_REGEX = /data-contract-section-divider-mode 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 TARGET_ID_ATTRIBUTE_REGEX = /data-contract-target\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const INTERACTION_ID_ATTRIBUTE_REGEX = /data-contract-interaction\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const COMPONENT_ID_ATTRIBUTE_REGEX = /data-contract-component\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const ACTION_RISK_ATTRIBUTE_REGEX = /data-contract-action-risk\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const ACTION_KIND_ATTRIBUTE_REGEX = /data-contract-action-kind\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const TARGET_GAP_ATTRIBUTE_REGEX = /data-contract-target-gap\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*}|{([0-9.]+)})/; +const TARGET_EDGE_INSET_ATTRIBUTE_REGEX = /data-contract-target-edge-inset\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*}|{([0-9.]+)})/; +const TARGET_VIEWPORT_ATTRIBUTE_REGEX = /data-contract-viewport\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const TARGET_CONTEXT_ATTRIBUTE_REGEX = /data-contract-context\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const TARGET_NEIGHBOR_KIND_ATTRIBUTE_REGEX = /data-contract-nearest-kind\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const STATE_KIND_ATTRIBUTE_REGEX = /data-contract-state-kind\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const STATE_ID_ATTRIBUTE_REGEX = /data-contract-state-id\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const FLOW_ID_ATTRIBUTE_REGEX = /data-contract-flow-id\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const FLOW_STEP_ATTRIBUTE_REGEX = /data-contract-flow-step\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const FLOW_TRANSITION_TO_ATTRIBUTE_REGEX = /data-contract-flow-transition-to\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const FLOW_TERMINAL_ATTRIBUTE_REGEX = /data-contract-flow-terminal\s*=\s*(?:"true"|'true'|{true}|{\s*true\s*})/i; +const RECOVERY_ACTION_ATTRIBUTE_REGEX = /data-contract-recovery-action\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const PRESERVE_LAST_GOOD_ATTRIBUTE_REGEX = /data-contract-preserve-last-good\s*=\s*(?:"true"|'true'|{true}|{\s*true\s*})/i; +const ARIA_DISABLED_TRUE_ATTRIBUTE_REGEX = /aria-disabled\s*=\s*(?:"true"|'true'|{true}|{\s*true\s*})/i; +const DISABLED_ATTRIBUTE_REGEX = /\bdisabled(?:\s*=\s*(?:"true"|'true'|{true}|{\s*true\s*}))?\b/i; 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*})/; @@ -128,6 +148,7 @@ const RADIUS_TOKEN_MAP = new Map([ ["rounded-3xl", 24], ["rounded-full", Number.POSITIVE_INFINITY], ]); +const INTERACTIVE_TARGET_TAG_REGEX = /<(button|a|summary)\b[^>]*>/gi; export async function collectSurfaceDescriptors(options) { const structuralDescriptors = []; const warnings = []; @@ -227,6 +248,9 @@ async function extractSurfaceDescriptor(workspaceRoot, surfaceRoot, surfaceId, s const primitives = await extractPrimitives(sectionFiles, workspaceRoot, fileContentCache); const { icons, warnings: iconWarnings } = await extractIconSources(surfaceRoot, workspaceRoot, fileContentCache, surfaceId); warnings.push(...iconWarnings); + const interactiveTargets = await extractInteractiveTargets(sectionFiles, workspaceRoot, fileContentCache); + const flows = await extractFlows(sectionFiles, workspaceRoot, fileContentCache); + const asyncStates = await extractAsyncStates(sectionFiles, workspaceRoot, fileContentCache); const structuralSurfaceDescriptor = { surfaceId, sections, @@ -238,6 +262,25 @@ async function extractSurfaceDescriptor(workspaceRoot, surfaceRoot, surfaceId, s layout, motion, primitives, + ...(flows.length > 0 + ? { + flows, + flowObservation: { + source: "static-markers", + observedFlowCount: flows.length, + }, + } + : {}), + interactiveTargets, + ...(asyncStates.length > 0 + ? { + asyncStates, + asyncStateObservation: { + source: "static-markers", + observedStateCount: asyncStates.length, + }, + } + : {}), }; return { descriptor: structuralSurfaceDescriptor, warnings, errors }; } @@ -359,6 +402,247 @@ async function extractLayout(cssFilePaths, sectionFiles, workspaceRoot, fileCont warnings, }; } +async function extractInteractiveTargets(filePaths, workspaceRoot, fileContentCache) { + const targets = []; + let autoIndex = 0; + for (const filePath of filePaths) { + const content = await readFileCached(filePath, fileContentCache); + const source = path.relative(workspaceRoot, filePath); + INTERACTIVE_TARGET_TAG_REGEX.lastIndex = 0; + let match; + while ((match = INTERACTIVE_TARGET_TAG_REGEX.exec(content)) !== null) { + const tag = match[0]; + const tagName = (match[1] ?? extractTagName(tag) ?? "").toLowerCase(); + const interactiveTarget = buildInteractiveTargetDescriptor(tag, tagName, source, autoIndex); + if (interactiveTarget) { + targets.push(interactiveTarget); + autoIndex += 1; + } + } + } + return targets.sort((a, b) => a.id.localeCompare(b.id) || + (a.source ?? "").localeCompare(b.source ?? "") || + a.role.localeCompare(b.role)); +} +async function extractFlows(filePaths, workspaceRoot, fileContentCache) { + const flows = new Map(); + const ensureFlow = (flowId, source) => { + const existing = flows.get(flowId); + if (existing) { + if (!existing.source) { + existing.source = source; + } + return existing; + } + const created = { + source, + steps: new Map(), + transitions: new Map(), + }; + flows.set(flowId, created); + return created; + }; + for (const filePath of filePaths) { + const content = await readFileCached(filePath, fileContentCache); + const source = path.relative(workspaceRoot, filePath); + const stack = []; + TAG_REGEX.lastIndex = 0; + let match; + while ((match = TAG_REGEX.exec(content)) !== null) { + const tag = match[0]; + const tagName = extractTagName(tag); + if (!tagName) { + continue; + } + if (tag.startsWith("= 0; index -= 1) { + if (stack[index]?.tagName === tagName) { + stack.splice(index, 1); + break; + } + } + continue; + } + const rawFlowId = extractTagAttributeValue(tag, FLOW_ID_ATTRIBUTE_REGEX); + const rawStepId = extractTagAttributeValue(tag, FLOW_STEP_ATTRIBUTE_REGEX); + const transitionTo = extractTagAttributeValue(tag, FLOW_TRANSITION_TO_ATTRIBUTE_REGEX); + const nearestFlowId = rawFlowId ?? + [...stack] + .reverse() + .find((entry) => entry.flowId)?.flowId; + const nearestStepId = rawStepId ?? + [...stack] + .reverse() + .find((entry) => entry.stepId)?.stepId; + if (nearestFlowId && rawStepId) { + const flow = ensureFlow(nearestFlowId, source); + const existingStep = flow.steps.get(rawStepId); + if (existingStep) { + existingStep.terminal ||= FLOW_TERMINAL_ATTRIBUTE_REGEX.test(tag); + } + else { + flow.steps.set(rawStepId, { + id: rawStepId, + ...(FLOW_TERMINAL_ATTRIBUTE_REGEX.test(tag) ? { terminal: true } : {}), + }); + } + } + else if (rawFlowId) { + ensureFlow(rawFlowId, source); + } + if (nearestFlowId && nearestStepId && transitionTo) { + const flow = ensureFlow(nearestFlowId, source); + flow.transitions.set(`${nearestStepId}->${transitionTo}`, { + from: nearestStepId, + to: transitionTo, + }); + } + if (!/\/>\s*$/.test(tag)) { + stack.push({ + tagName, + ...(rawFlowId ? { flowId: rawFlowId } : {}), + ...(rawStepId ? { stepId: rawStepId } : {}), + }); + } + } + } + return [...flows.entries()] + .map(([flowId, flow]) => ({ + flowId, + steps: [...flow.steps.values()].sort((a, b) => a.id.localeCompare(b.id)), + transitions: [...flow.transitions.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)), + source: flow.source, + })) + .sort((a, b) => a.flowId.localeCompare(b.flowId)); +} +async function extractAsyncStates(filePaths, workspaceRoot, fileContentCache) { + const states = new Map(); + for (const filePath of filePaths) { + const content = await readFileCached(filePath, fileContentCache); + const source = path.relative(workspaceRoot, filePath); + TAG_REGEX.lastIndex = 0; + let match; + while ((match = TAG_REGEX.exec(content)) !== null) { + const tag = match[0]; + const stateKind = extractAsyncStateKind(extractTagAttributeValue(tag, STATE_KIND_ATTRIBUTE_REGEX)); + const stateId = extractTagAttributeValue(tag, STATE_ID_ATTRIBUTE_REGEX); + const stateKey = stateId ?? stateKind; + if (!stateKey) { + continue; + } + const existing = states.get(stateKey); + const sectionId = extractTagAttributeValue(tag, SECTION_ATTRIBUTE_REGEX); + const recoveryAction = extractRecoveryActionKind(extractTagAttributeValue(tag, RECOVERY_ACTION_ATTRIBUTE_REGEX)); + const interactionId = extractTagAttributeValue(tag, INTERACTION_ID_ATTRIBUTE_REGEX); + const disabled = interactionId && + (DISABLED_ATTRIBUTE_REGEX.test(tag) || ARIA_DISABLED_TRUE_ATTRIBUTE_REGEX.test(tag)); + const next = existing ?? { + id: stateKey, + source, + contextId: stateId, + sectionIds: [], + recoveryActions: [], + blockedActions: [], + }; + if (stateKind) { + next.kind = stateKind; + } + if (!next.source) { + next.source = source; + } + if (!next.contextId && stateId) { + next.contextId = stateId; + } + if (sectionId) { + next.sectionIds = [...new Set([...(next.sectionIds ?? []), sectionId])]; + } + if (recoveryAction) { + next.recoveryActions = [ + ...new Set([...(next.recoveryActions ?? []), recoveryAction]), + ]; + } + if (PRESERVE_LAST_GOOD_ATTRIBUTE_REGEX.test(tag)) { + next.preserveLastGoodContent = true; + } + if (interactionId) { + const blockedActions = next.blockedActions ?? []; + const existingBlockedAction = blockedActions.find((action) => action.interactionId === interactionId); + if (existingBlockedAction) { + existingBlockedAction.disabled ||= Boolean(disabled); + } + else { + blockedActions.push({ + interactionId, + disabled: Boolean(disabled), + }); + } + next.blockedActions = blockedActions; + } + states.set(stateKey, next); + } + } + return [...states.values()] + .filter((state) => Boolean(state.kind)) + .sort((a, b) => a.id.localeCompare(b.id)); +} +function buildInteractiveTargetDescriptor(tag, tagName, source, autoIndex) { + const styleText = extractTagStyleText(tag); + const interactionId = extractTagAttributeValue(tag, INTERACTION_ID_ATTRIBUTE_REGEX); + const componentId = extractTagAttributeValue(tag, COMPONENT_ID_ATTRIBUTE_REGEX); + const targetId = extractTagAttributeValue(tag, TARGET_ID_ATTRIBUTE_REGEX) ?? + interactionId ?? + `${tagName || "target"}-${autoIndex + 1}`; + const viewportId = extractTagAttributeValue(tag, TARGET_VIEWPORT_ATTRIBUTE_REGEX); + const contextId = extractTagAttributeValue(tag, TARGET_CONTEXT_ATTRIBUTE_REGEX); + const width = extractStyleNumericPx(styleText, "width", "width") ?? + extractStyleNumericPx(styleText, "min-width", "minWidth"); + const height = extractStyleNumericPx(styleText, "height", "height") ?? + extractStyleNumericPx(styleText, "min-height", "minHeight"); + const explicitGap = parseNumericLiteral(extractTagAttributeValue(tag, TARGET_GAP_ATTRIBUTE_REGEX)); + const explicitEdgeInset = parseNumericLiteral(extractTagAttributeValue(tag, TARGET_EDGE_INSET_ATTRIBUTE_REGEX)); + const inferredEdgeInset = inferEdgeInsetPx(styleText); + const edgeInsetPx = explicitEdgeInset ?? inferredEdgeInset; + const classification = extractInteractiveTargetClassification(extractTagAttributeValue(tag, ACTION_RISK_ATTRIBUTE_REGEX) ?? + extractTagAttributeValue(tag, ACTION_KIND_ATTRIBUTE_REGEX) ?? + (/\btype\s*=\s*(?:"submit"|'submit'|{`submit`}|{\s*["'`]submit["'`]\s*})/i.test(tag) + ? "primary" + : undefined)); + const nearestNeighborClassification = extractInteractiveTargetClassification(extractTagAttributeValue(tag, TARGET_NEIGHBOR_KIND_ATTRIBUTE_REGEX)); + if (!tagName) { + return null; + } + return { + id: targetId, + role: tagName === "a" ? "link" : tagName, + source, + selector: interactionId + ? `[data-contract-interaction="${interactionId}"]` + : `[data-contract-target="${targetId}"]`, + ...(componentId ? { componentId } : {}), + ...(interactionId ? { interactionId } : {}), + ...(viewportId ? { viewportId } : {}), + ...(contextId ? { contextId } : {}), + ...(Number.isFinite(width) && Number.isFinite(height) + ? { + boundingBox: { + x: 0, + y: 0, + width: Number(width), + height: Number(height), + }, + hitAreaPx: Number(width) * Number(height), + } + : { + hitAreaPx: null, + }), + nearestNeighborGapPx: explicitGap ?? null, + ...(nearestNeighborClassification + ? { nearestNeighborClassification } + : {}), + edgeInsetPx: edgeInsetPx ?? null, + ...(classification ? { classification } : {}), + }; +} async function extractChromeLayout(cssFilePaths, sectionFiles, workspaceRoot, fileContentCache, surfaceId) { const wrappers = []; for (const filePath of sectionFiles) { @@ -557,6 +841,92 @@ function extractTagStyleText(tag) { value: raw, }; } +function extractTagAttributeValue(tag, regex) { + regex.lastIndex = 0; + const match = regex.exec(tag); + regex.lastIndex = 0; + const raw = match?.[1] ?? + match?.[2] ?? + match?.[3] ?? + match?.[4] ?? + match?.[5] ?? + ""; + const value = raw.trim(); + return value.length > 0 ? value : undefined; +} +function parseNumericLiteral(value) { + if (!value) { + return undefined; + } + const normalized = value.trim().replace(/^["'`]|["'`]$/g, ""); + const match = normalized.match(/^([0-9.]+)(?:px)?$/i); + if (!match?.[1]) { + return undefined; + } + const parsed = Number.parseFloat(match[1]); + return Number.isFinite(parsed) ? parsed : undefined; +} +function extractAsyncStateKind(value) { + switch (value?.trim()) { + case "loading": + case "empty": + case "partial": + case "error": + case "success": + return value.trim(); + default: + return undefined; + } +} +function extractRecoveryActionKind(value) { + switch (value?.trim()) { + case "retry": + case "refresh": + case "dismiss": + case "contact-support": + case "navigate-home": + case "go-back": + return value.trim(); + default: + return undefined; + } +} +function extractStyleNumericPx(styleText, cssProperty, jsxProperty) { + if (!styleText) { + return undefined; + } + if (styleText.kind === "string") { + const match = new RegExp(`${cssProperty}\\s*:\\s*([0-9.]+)\\s*px`, "i").exec(styleText.value); + return parseNumericLiteral(match?.[1]); + } + const objectMatch = new RegExp(`\\b${jsxProperty}\\s*:\\s*(?:["'\`])?([0-9.]+)(?:px)?(?:["'\`])?`, "i").exec(styleText.value); + return parseNumericLiteral(objectMatch?.[1]); +} +function inferEdgeInsetPx(styleText) { + const values = [ + extractStyleNumericPx(styleText, "top", "top"), + extractStyleNumericPx(styleText, "right", "right"), + extractStyleNumericPx(styleText, "bottom", "bottom"), + extractStyleNumericPx(styleText, "left", "left"), + ].filter((value) => Number.isFinite(value)); + if (values.length === 0) { + return undefined; + } + return Math.min(...values); +} +function extractInteractiveTargetClassification(input) { + const normalized = input?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + if (normalized === "destructive" || normalized === "danger") { + return "destructive"; + } + if (normalized === "primary" || normalized === "cta") { + return "primary"; + } + return "default"; +} function parseChromeInlineSignals(styleText, source) { const radiusSignals = []; const shadowSignals = []; diff --git a/packages/interfacectl-cli/dist/index.js b/packages/interfacectl-cli/dist/index.js index f261c97..6cd17ad 100755 --- a/packages/interfacectl-cli/dist/index.js +++ b/packages/interfacectl-cli/dist/index.js @@ -33,6 +33,7 @@ program .option("--root ", "Project root (defaults to current working directory)") .option("--workspace-root ", "Workspace root (defaults to current working directory)") .option("--surface ", "Limit validation to the provided surface identifiers") + .option("--remote-url ", "Augment validation with browser-observed target metrics from the provided URL") .option("--json", "Emit machine-readable JSON instead of human-readable text output") .option("--format ", "Output format (text|json)") .option("--out ", "Write output to the provided file path instead of stdout") @@ -65,6 +66,7 @@ program schemaPath: options.schema, workspaceRoot, surfaceFilters: options.surface ?? [], + remoteUrl: options.remoteUrl, outputFormat, outputPath: options.out, configPath: requestedConfig, diff --git a/packages/interfacectl-cli/dist/utils/browser-session.d.ts b/packages/interfacectl-cli/dist/utils/browser-session.d.ts index 7a7774b..f0f07fd 100644 --- a/packages/interfacectl-cli/dist/utils/browser-session.d.ts +++ b/packages/interfacectl-cli/dist/utils/browser-session.d.ts @@ -11,6 +11,62 @@ export interface RemoteRenderedMotionObservation { durationMs: number; timingFunction: string; } +export interface RemoteInteractiveTargetObservation { + id: string; + role: string; + selector?: string; + boundingBox: { + x: number; + y: number; + width: number; + height: number; + }; + hitAreaPx: number; + nearestNeighborGapPx: number | null; + nearestNeighborClassification?: "default" | "primary" | "destructive"; + edgeInsetPx: number; + classification: "default" | "primary" | "destructive"; +} +export type RemoteInteractiveTargetCollectionSource = "contract-scoped" | "all-visible-fallback" | "none-observed"; +export interface RemoteInteractiveTargetCollectionObservation { + source: RemoteInteractiveTargetCollectionSource; + allVisibleCount: number; + contractScopedCount: number; +} +export interface RemoteAsyncStateObservation { + id: string; + kind: "loading" | "empty" | "partial" | "error" | "success"; + sectionIds: string[]; + recoveryActions: Array<"retry" | "refresh" | "dismiss" | "contact-support" | "navigate-home" | "go-back">; + preserveLastGoodContent: boolean; + blockedActions: Array<{ + interactionId: string; + disabled: boolean; + }>; +} +export interface RemoteFlowStepObservation { + id: string; + terminal?: boolean; +} +export interface RemoteFlowTransitionObservation { + from: string; + to: string; +} +export interface RemoteFlowObservation { + flowId: string; + steps: RemoteFlowStepObservation[]; + transitions: RemoteFlowTransitionObservation[]; +} +export type RemoteFlowCollectionSource = "contract-scoped" | "none-observed"; +export interface RemoteFlowCollectionObservation { + source: RemoteFlowCollectionSource; + observedFlowCount: number; +} +export type RemoteAsyncStateCollectionSource = "contract-scoped" | "none-observed"; +export interface RemoteAsyncStateCollectionObservation { + source: RemoteAsyncStateCollectionSource; + observedStateCount: number; +} export interface RemoteRenderedStyleObservation { fonts: string[]; colors: string[]; @@ -19,6 +75,12 @@ export interface RemoteRenderedStyleObservation { shadowKinds: Array<"outer" | "inset" | "mixed">; motions: RemoteRenderedMotionObservation[]; containers: string[]; + interactiveTargets: RemoteInteractiveTargetObservation[]; + interactiveTargetCollection: RemoteInteractiveTargetCollectionObservation; + flows: RemoteFlowObservation[]; + flowCollection: RemoteFlowCollectionObservation; + asyncStates: RemoteAsyncStateObservation[]; + asyncStateCollection: RemoteAsyncStateCollectionObservation; } export interface RemoteBrowserObservation { finalUrl: string; diff --git a/packages/interfacectl-cli/dist/utils/browser-session.d.ts.map b/packages/interfacectl-cli/dist/utils/browser-session.d.ts.map index e52c6a9..a500bcb 100644 --- a/packages/interfacectl-cli/dist/utils/browser-session.d.ts.map +++ b/packages/interfacectl-cli/dist/utils/browser-session.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"browser-session.d.ts","sourceRoot":"","sources":["../../src/utils/browser-session.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,kBAAkB,GAAG,IAAI,GAAG,OAAO,GAAG,eAAe,CAAC;AAClE,MAAM,MAAM,sBAAsB,GAAG,MAAM,GAAG,SAAS,CAAC;AAExD,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,UAAU,EAAE,sBAAsB,CAAC;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,QAAQ,EAAE,MAAM,GAAG,iBAAiB,CAAC;CACtC;AAED,MAAM,WAAW,+BAA+B;IAC9C,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,8BAA8B;IAC7C,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,KAAK,CAAC,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC,CAAC;IAChD,OAAO,EAAE,+BAA+B,EAAE,CAAC;IAC3C,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB;AAUD,MAAM,WAAW,wBAAwB;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,aAAa,EAAE,OAAO,CAAC;IACvB,oBAAoB,EAAE,OAAO,CAAC;IAC9B,YAAY,EAAE,kBAAkB,CAAC;IACjC,cAAc,EAAE,8BAA8B,CAAC;CAChD;AA4FD,wBAAsB,0BAA0B,CAAC,OAAO,EAAE;IACxD,GAAG,EAAE,MAAM,CAAC;CACb,GAAG,OAAO,CAAC;IACV,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC,CAgCD;AAED,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAqQpC"} \ No newline at end of file +{"version":3,"file":"browser-session.d.ts","sourceRoot":"","sources":["../../src/utils/browser-session.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,kBAAkB,GAAG,IAAI,GAAG,OAAO,GAAG,eAAe,CAAC;AAClE,MAAM,MAAM,sBAAsB,GAAG,MAAM,GAAG,SAAS,CAAC;AAExD,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,kBAAkB,CAAC;IAC3B,UAAU,EAAE,sBAAsB,CAAC;IACnC,QAAQ,EAAE,MAAM,CAAC;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,QAAQ,EAAE,MAAM,GAAG,iBAAiB,CAAC;CACtC;AAED,MAAM,WAAW,+BAA+B;IAC9C,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,kCAAkC;IACjD,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE;QACX,CAAC,EAAE,MAAM,CAAC;QACV,CAAC,EAAE,MAAM,CAAC;QACV,KAAK,EAAE,MAAM,CAAC;QACd,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC;IACF,SAAS,EAAE,MAAM,CAAC;IAClB,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;IACpC,6BAA6B,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;IACtE,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,SAAS,GAAG,SAAS,GAAG,aAAa,CAAC;CACvD;AAED,MAAM,MAAM,uCAAuC,GAC/C,iBAAiB,GACjB,sBAAsB,GACtB,eAAe,CAAC;AAEpB,MAAM,WAAW,4CAA4C;IAC3D,MAAM,EAAE,uCAAuC,CAAC;IAChD,eAAe,EAAE,MAAM,CAAC;IACxB,mBAAmB,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,2BAA2B;IAC1C,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,SAAS,GAAG,OAAO,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,CAAC;IAC5D,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,eAAe,EAAE,KAAK,CACpB,OAAO,GAAG,SAAS,GAAG,SAAS,GAAG,iBAAiB,GAAG,eAAe,GAAG,SAAS,CAClF,CAAC;IACF,uBAAuB,EAAE,OAAO,CAAC;IACjC,cAAc,EAAE,KAAK,CAAC;QACpB,aAAa,EAAE,MAAM,CAAC;QACtB,QAAQ,EAAE,OAAO,CAAC;KACnB,CAAC,CAAC;CACJ;AAED,MAAM,WAAW,yBAAyB;IACxC,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,+BAA+B;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,yBAAyB,EAAE,CAAC;IACnC,WAAW,EAAE,+BAA+B,EAAE,CAAC;CAChD;AAED,MAAM,MAAM,0BAA0B,GAClC,iBAAiB,GACjB,eAAe,CAAC;AAEpB,MAAM,WAAW,+BAA+B;IAC9C,MAAM,EAAE,0BAA0B,CAAC;IACnC,iBAAiB,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,MAAM,gCAAgC,GACxC,iBAAiB,GACjB,eAAe,CAAC;AAEpB,MAAM,WAAW,qCAAqC;IACpD,MAAM,EAAE,gCAAgC,CAAC;IACzC,kBAAkB,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,8BAA8B;IAC7C,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,KAAK,CAAC,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC,CAAC;IAChD,OAAO,EAAE,+BAA+B,EAAE,CAAC;IAC3C,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,kBAAkB,EAAE,kCAAkC,EAAE,CAAC;IACzD,2BAA2B,EAAE,4CAA4C,CAAC;IAC1E,KAAK,EAAE,qBAAqB,EAAE,CAAC;IAC/B,cAAc,EAAE,+BAA+B,CAAC;IAChD,WAAW,EAAE,2BAA2B,EAAE,CAAC;IAC3C,oBAAoB,EAAE,qCAAqC,CAAC;CAC7D;AAUD,MAAM,WAAW,wBAAwB;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACxD,aAAa,EAAE,OAAO,CAAC;IACvB,oBAAoB,EAAE,OAAO,CAAC;IAC9B,YAAY,EAAE,kBAAkB,CAAC;IACjC,cAAc,EAAE,8BAA8B,CAAC;CAChD;AA4FD,wBAAsB,0BAA0B,CAAC,OAAO,EAAE;IACxD,GAAG,EAAE,MAAM,CAAC;CACb,GAAG,OAAO,CAAC;IACV,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;CACtB,CAAC,CAgCD;AAED,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,GAAG,OAAO,CAAC,wBAAwB,CAAC,CAkhBpC"} \ No newline at end of file diff --git a/packages/interfacectl-cli/dist/utils/browser-session.js b/packages/interfacectl-cli/dist/utils/browser-session.js index 3d7b847..d3965b7 100644 --- a/packages/interfacectl-cli/dist/utils/browser-session.js +++ b/packages/interfacectl-cli/dist/utils/browser-session.js @@ -144,6 +144,22 @@ export async function observeRemotePage(options) { shadowKinds: [], motions: [], containers: [], + interactiveTargets: [], + interactiveTargetCollection: { + source: "none-observed", + allVisibleCount: 0, + contractScopedCount: 0, + }, + flows: [], + flowCollection: { + source: "none-observed", + observedFlowCount: 0, + }, + asyncStates: [], + asyncStateCollection: { + source: "none-observed", + observedStateCount: 0, + }, }, }; } @@ -210,6 +226,63 @@ export async function observeRemotePage(options) { const headingNodes = Array.from(doc.querySelectorAll("main h1, main h2, [role='main'] h1, [role='main'] h2, h1, h2")).filter((node) => isVisible(node)); const visibleForms = Array.from(doc.querySelectorAll("form")).filter((node) => isVisible(node)); const passwordInputs = Array.from(doc.querySelectorAll("input[type='password']")).filter((node) => isVisible(node)); + const allInteractiveNodes = Array.from(doc.querySelectorAll("a, button, summary, [role='button']")).filter((node) => isVisible(node)); + const contractScopedInteractiveNodes = allInteractiveNodes.filter((node) => { + const dataset = node?.dataset ?? {}; + return Boolean(String(dataset.contractTarget ?? "").trim() || + String(dataset.contractInteraction ?? "").trim()); + }); + const interactiveTargetCollection = contractScopedInteractiveNodes.length > 0 + ? { + source: "contract-scoped", + allVisibleCount: allInteractiveNodes.length, + contractScopedCount: contractScopedInteractiveNodes.length, + } + : allInteractiveNodes.length > 0 + ? { + source: "all-visible-fallback", + allVisibleCount: allInteractiveNodes.length, + contractScopedCount: 0, + } + : { + source: "none-observed", + allVisibleCount: 0, + contractScopedCount: 0, + }; + const interactiveNodes = interactiveTargetCollection.source === "contract-scoped" + ? contractScopedInteractiveNodes + : allInteractiveNodes; + const isAsyncStateKind = (value) => value === "loading" || + value === "empty" || + value === "partial" || + value === "error" || + value === "success"; + const isRecoveryActionKind = (value) => value === "retry" || + value === "refresh" || + value === "dismiss" || + value === "contact-support" || + value === "navigate-home" || + value === "go-back"; + const stateNodes = Array.from(doc.querySelectorAll("[data-contract-state-kind]")).filter((node) => isVisible(node)); + const flowNodes = Array.from(doc.querySelectorAll("[data-contract-flow-id]")).filter((node) => isVisible(node)); + const flowCollection = flowNodes.length > 0 + ? { + source: "contract-scoped", + observedFlowCount: flowNodes.length, + } + : { + source: "none-observed", + observedFlowCount: 0, + }; + const asyncStateCollection = stateNodes.length > 0 + ? { + source: "contract-scoped", + observedStateCount: stateNodes.length, + } + : { + source: "none-observed", + observedStateCount: 0, + }; const nodes = Array.from(doc.querySelectorAll("body, main, header, nav, footer, aside, section, article, form, div, h1, h2, h3, h4, h5, h6, p, a, button, input, label")); for (const node of nodes) { if (!node || typeof node !== "object" || !isVisible(node)) { @@ -248,6 +321,138 @@ export async function observeRemotePage(options) { containers.add("container"); } } + const classifyTarget = (node) => { + const dataset = node?.dataset ?? {}; + const raw = String(dataset.contractActionRisk ?? dataset.contractActionKind ?? "").toLowerCase() || + (String(node?.getAttribute?.("type") ?? "").toLowerCase() === "submit" ? "primary" : ""); + if (raw === "destructive" || raw === "danger") { + return "destructive"; + } + if (raw === "primary" || raw === "cta") { + return "primary"; + } + return "default"; + }; + const viewportWidth = typeof globalThis.innerWidth === "number" ? globalThis.innerWidth : 0; + const viewportHeight = typeof globalThis.innerHeight === "number" ? globalThis.innerHeight : 0; + const interactiveTargets = interactiveNodes.map((node, index, list) => { + const rect = node.getBoundingClientRect(); + const classification = classifyTarget(node); + let nearestNeighborGapPx = null; + let nearestNeighborClassification; + for (const other of list) { + if (other === node) + continue; + const otherRect = other.getBoundingClientRect(); + const dx = Math.max(0, otherRect.left - rect.right, rect.left - otherRect.right); + const dy = Math.max(0, otherRect.top - rect.bottom, rect.top - otherRect.bottom); + const gap = dx === 0 ? dy : dy === 0 ? dx : Math.hypot(dx, dy); + if (nearestNeighborGapPx === null || gap < nearestNeighborGapPx) { + nearestNeighborGapPx = gap; + nearestNeighborClassification = classifyTarget(other); + } + } + const edgeInsetPx = Math.min(rect.left, viewportWidth - rect.right, rect.top, viewportHeight - rect.bottom); + const dataset = node?.dataset ?? {}; + return { + id: String(dataset.contractTarget ?? dataset.contractInteraction ?? "").trim() || + `${String(node?.tagName ?? "target").toLowerCase()}-${index + 1}`, + role: String(node?.tagName ?? "target").toLowerCase() === "a" + ? "link" + : String(node?.tagName ?? "target").toLowerCase(), + selector: typeof dataset.contractInteraction === "string" + ? `[data-contract-interaction="${dataset.contractInteraction}"]` + : typeof dataset.contractTarget === "string" + ? `[data-contract-target="${dataset.contractTarget}"]` + : undefined, + boundingBox: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }, + hitAreaPx: rect.width * rect.height, + nearestNeighborGapPx, + nearestNeighborClassification, + edgeInsetPx, + classification, + }; + }); + const asyncStates = stateNodes.flatMap((node, index) => { + const dataset = node?.dataset ?? {}; + const stateId = String(dataset.contractStateId ?? "").trim() || + String(dataset.contractStateKind ?? "").trim() || + `state-${index + 1}`; + const stateKind = String(dataset.contractStateKind ?? "").trim().toLowerCase(); + if (!isAsyncStateKind(stateKind)) { + return []; + } + const selector = `[data-contract-state-id="${stateId}"]`; + const sectionIds = Array.from(doc.querySelectorAll(`${selector}[data-contract-section]`)) + .filter((candidate) => isVisible(candidate)) + .map((candidate) => String(candidate?.dataset?.contractSection ?? "").trim()) + .filter(Boolean); + const recoveryActions = Array.from(doc.querySelectorAll(`${selector}[data-contract-recovery-action]`)) + .filter((candidate) => isVisible(candidate)) + .map((candidate) => String(candidate?.dataset?.contractRecoveryAction ?? "").trim().toLowerCase()) + .filter(isRecoveryActionKind); + const preserveLastGoodContent = Array.from(doc.querySelectorAll(`${selector}[data-contract-preserve-last-good="true"]`)).some((candidate) => isVisible(candidate)); + const blockedActions = Array.from(doc.querySelectorAll(`${selector}[data-contract-interaction]`)) + .filter((candidate) => isVisible(candidate)) + .map((candidate) => { + const interactionId = String(candidate?.dataset?.contractInteraction ?? "").trim(); + return { + interactionId, + disabled: Boolean(candidate?.disabled) || + String(candidate?.getAttribute?.("aria-disabled") ?? "").toLowerCase() === "true", + }; + }) + .filter((candidate) => candidate.interactionId.length > 0); + return [{ + id: stateId, + kind: stateKind, + sectionIds: [...new Set(sectionIds)].sort((a, b) => a.localeCompare(b)), + recoveryActions: [...new Set(recoveryActions)].sort((a, b) => a.localeCompare(b)), + preserveLastGoodContent, + blockedActions, + }]; + }); + const flows = flowNodes.flatMap((flowNode, index) => { + const flowId = String(flowNode?.dataset?.contractFlowId ?? "").trim() || `flow-${index + 1}`; + const stepMap = new Map(); + const transitionMap = new Map(); + const stepNodes = Array.from(flowNode.querySelectorAll("[data-contract-flow-step]")).filter((candidate) => isVisible(candidate) && + candidate.closest?.("[data-contract-flow-id]") === flowNode); + for (const stepNode of stepNodes) { + const stepId = String(stepNode?.dataset?.contractFlowStep ?? "").trim(); + if (!stepId) { + continue; + } + stepMap.set(stepId, { + id: stepId, + ...(String(stepNode?.getAttribute?.("data-contract-flow-terminal") ?? "").toLowerCase() === "true" + ? { terminal: true } + : {}), + }); + const transitionNodes = Array.from(stepNode.querySelectorAll("[data-contract-flow-transition-to]")).filter((candidate) => isVisible(candidate) && + candidate.closest?.("[data-contract-flow-step]") === stepNode); + for (const transitionNode of transitionNodes) { + const transitionTo = String(transitionNode?.dataset?.contractFlowTransitionTo ?? "").trim(); + if (!transitionTo) { + continue; + } + transitionMap.set(`${stepId}->${transitionTo}`, { + from: stepId, + to: transitionTo, + }); + } + } + return [{ + flowId, + steps: [...stepMap.values()].sort((a, b) => a.id.localeCompare(b.id)), + transitions: [...transitionMap.values()].sort((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)), + }]; + }); return { gateObservation: { bodyText: getVisibleText(doc.body), @@ -264,6 +469,12 @@ export async function observeRemotePage(options) { shadowKinds, motions, containers: [...containers].sort((a, b) => a.localeCompare(b)), + interactiveTargets, + interactiveTargetCollection, + flows, + flowCollection, + asyncStates, + asyncStateCollection, }, }; }); diff --git a/packages/interfacectl-cli/src/commands/describe.ts b/packages/interfacectl-cli/src/commands/describe.ts index e8835eb..7651e8a 100644 --- a/packages/interfacectl-cli/src/commands/describe.ts +++ b/packages/interfacectl-cli/src/commands/describe.ts @@ -375,11 +375,24 @@ export async function runDescribeCommand( return 1; } - const descriptors = descriptorResult.descriptors.map((descriptor) => ({ - ...descriptor, - flows: flowDescriptorResult.flowsBySurface.get(descriptor.surfaceId), - flowDescriptorPath: flowDescriptorResult.paths.get(descriptor.surfaceId), - })); + const descriptors = descriptorResult.descriptors.map((descriptor) => { + const artifactFlows = flowDescriptorResult.flowsBySurface.get(descriptor.surfaceId); + const flowDescriptorPath = flowDescriptorResult.paths.get(descriptor.surfaceId); + return { + ...descriptor, + ...(artifactFlows + ? { + flows: artifactFlows, + flowObservation: { + source: "flow-descriptor-artifact", + observedFlowCount: artifactFlows.length, + ...(flowDescriptorPath ? { location: flowDescriptorPath } : {}), + }, + } + : {}), + ...(flowDescriptorPath ? { flowDescriptorPath } : {}), + }; + }); const serialized = `${JSON.stringify(descriptors, null, 2)}\n`; await writeFileWithParents(outPath, serialized); diff --git a/packages/interfacectl-cli/src/commands/generation-session.ts b/packages/interfacectl-cli/src/commands/generation-session.ts index 2793a46..cda3ff3 100644 --- a/packages/interfacectl-cli/src/commands/generation-session.ts +++ b/packages/interfacectl-cli/src/commands/generation-session.ts @@ -1990,6 +1990,7 @@ function inferContractPath(surfaceId: string, findingCode: string, repair?: Json case "restore-required-flows": case "restore-required-flow-steps": case "restore-required-transitions": + case "restore-flow-observability": return `surfaces[id=${surfaceId}].flows`; case "remove-prohibited-primitives": return `surfaces[id=${surfaceId}].shell`; diff --git a/packages/interfacectl-cli/src/commands/validate.ts b/packages/interfacectl-cli/src/commands/validate.ts index 540591b..8d14a62 100644 --- a/packages/interfacectl-cli/src/commands/validate.ts +++ b/packages/interfacectl-cli/src/commands/validate.ts @@ -15,6 +15,10 @@ import { collectSurfaceDescriptors, type DescriptorIssue, } from "../descriptors/static-analysis.js"; +import { + observeRemotePage, + type RemoteBrowserObservation, +} from "../utils/browser-session.js"; import { getExitCodeVersion, type ExitCodeVersion } from "../utils/exit-codes.js"; import { classifyViolationType, @@ -63,6 +67,7 @@ export interface ValidateCommandOptions { schemaPath?: string; workspaceRoot?: string; surfaceFilters?: string[]; + remoteUrl?: string; descriptorOverrides?: SurfaceDescriptor[]; outputFormat?: OutputFormat; outputPath?: string; @@ -333,18 +338,57 @@ export async function runValidateCommand( descriptorsWithFlowArtifacts = structuralDescriptorResult.descriptors.map( (descriptor) => { + const artifactFlows = flowDescriptorResult.flowsBySurface.get( + descriptor.surfaceId, + ); const flowDescriptorPath = flowDescriptorResult.paths.get( descriptor.surfaceId, ); return { ...descriptor, - flows: flowDescriptorResult.flowsBySurface.get(descriptor.surfaceId), - flowDescriptorPath, + ...(artifactFlows + ? { + flows: artifactFlows, + flowObservation: { + source: "flow-descriptor-artifact" as const, + observedFlowCount: artifactFlows.length, + ...(flowDescriptorPath ? { location: flowDescriptorPath } : {}), + }, + } + : {}), + ...(flowDescriptorPath ? { flowDescriptorPath } : {}), }; }, ); } + if (options.remoteUrl) { + const remoteObservationResult = await augmentDescriptorsWithRemoteObservation({ + remoteUrl: options.remoteUrl, + contract, + descriptors: descriptorsWithFlowArtifacts, + surfaceFilters, + }); + if (!remoteObservationResult.ok) { + const message = remoteObservationResult.message; + if (!isJson) { + printHeader(pc.red("âś– Remote observation failed"), textReporter); + textReporter.error(pc.red(message)); + } + findings.push({ + code: remoteObservationResult.code, + severity: "error", + category: "E0", + message, + surface: remoteObservationResult.surfaceId, + location: remoteObservationResult.location, + }); + const e0ExitCode = exitCodeVersion === "v2" ? 10 : 2; + return finalize(e0ExitCode, contract.version ?? initialContractVersion); + } + descriptorsWithFlowArtifacts = remoteObservationResult.descriptors; + } + const summary = evaluateContractCompliance( contract, descriptorsWithFlowArtifacts, @@ -461,6 +505,210 @@ function issueToFinding( }; } +async function augmentDescriptorsWithRemoteObservation(input: { + remoteUrl: string; + contract: InterfaceContract; + descriptors: SurfaceDescriptor[]; + surfaceFilters: Set; +}): Promise< + | { ok: true; descriptors: SurfaceDescriptor[] } + | { ok: false; code: string; message: string; surfaceId?: string; location?: string } +> { + if (input.descriptors.length === 0) { + return { + ok: false, + code: "remote-observation.surface-missing", + message: "Remote observation requires exactly one validated surface, but none were resolved.", + }; + } + + if (input.descriptors.length > 1) { + return { + ok: false, + code: "remote-observation.surface-ambiguous", + message: + input.surfaceFilters.size > 0 + ? `Remote observation requires exactly one validated surface, but ${input.descriptors.length} matched the provided filters.` + : `Remote observation requires exactly one validated surface, but ${input.descriptors.length} surfaces were selected. Use --surface to narrow the target.`, + }; + } + + try { + const observation = await observeRemotePage({ + url: input.remoteUrl, + }); + + if (observation.sourceHealth.status !== "ok") { + return { + ok: false, + code: "remote-observation.source-unavailable", + message: + `Remote observation resolved to a ${observation.sourceHealth.status} page at ${observation.finalUrl}. ` + + "Provide an accessible URL before using --remote-url validation.", + surfaceId: input.descriptors[0]?.surfaceId, + location: observation.finalUrl, + }; + } + + const descriptor = input.descriptors[0]!; + const surface = input.contract.surfaces.find( + (candidate) => candidate.id === descriptor.surfaceId, + ); + const targetAcquisitionEnabled = Boolean( + surface?.layout.targetAcquisition && + surface.layout.targetAcquisition.policy !== "off", + ); + const feedbackRecoveryEnabled = Boolean( + surface?.runtime?.feedbackRecovery && + surface.runtime.feedbackRecovery.policy !== "off", + ); + const flowPolicyEnabled = Boolean( + surface?.flows && + surface.flows.policy !== "off", + ); + const remoteTargets = mapRemoteObservationTargets(observation); + const remoteFlows = mapRemoteObservationFlows(observation); + const remoteAsyncStates = mapRemoteObservationAsyncStates(observation); + const interactiveTargets = + targetAcquisitionEnabled && + remoteTargets.collection.source !== "contract-scoped" + ? [] + : remoteTargets.targets; + const shouldFallbackToFlowArtifact = + descriptor.flowObservation?.source === "flow-descriptor-artifact"; + return { + ok: true, + descriptors: [ + { + ...descriptor, + ...(flowPolicyEnabled + ? remoteFlows.collection.source === "contract-scoped" + ? { + flows: remoteFlows.flows, + flowObservation: remoteFlows.collection, + } + : shouldFallbackToFlowArtifact + ? {} + : { + flows: [], + flowObservation: remoteFlows.collection, + } + : {}), + interactiveTargets, + interactiveTargetObservation: remoteTargets.collection, + ...(feedbackRecoveryEnabled + ? { + asyncStates: remoteAsyncStates.states, + asyncStateObservation: remoteAsyncStates.collection, + } + : {}), + }, + ], + }; + } catch (error) { + return { + ok: false, + code: "remote-observation.failed", + message: + error instanceof Error ? error.message : String(error), + surfaceId: input.descriptors[0]?.surfaceId, + location: input.remoteUrl, + }; + } +} + +function mapRemoteObservationTargets( + observation: RemoteBrowserObservation, +): { + targets: NonNullable; + collection: NonNullable; +} { + const observedTargets = observation.renderedStyles.interactiveTargets.map((target) => ({ + id: target.id, + role: target.role, + source: observation.finalUrl, + ...(target.selector ? { selector: target.selector } : {}), + boundingBox: { + x: target.boundingBox.x, + y: target.boundingBox.y, + width: target.boundingBox.width, + height: target.boundingBox.height, + }, + hitAreaPx: target.hitAreaPx, + nearestNeighborGapPx: target.nearestNeighborGapPx, + ...(target.nearestNeighborClassification + ? { nearestNeighborClassification: target.nearestNeighborClassification } + : {}), + edgeInsetPx: target.edgeInsetPx, + classification: target.classification, + })); + return { + targets: observedTargets, + collection: { + source: observation.renderedStyles.interactiveTargetCollection.source, + allVisibleCount: observation.renderedStyles.interactiveTargetCollection.allVisibleCount, + contractScopedCount: observation.renderedStyles.interactiveTargetCollection.contractScopedCount, + location: observation.finalUrl, + }, + }; +} + +function mapRemoteObservationFlows( + observation: RemoteBrowserObservation, +): { + flows: NonNullable; + collection: NonNullable; +} { + const flows = observation.renderedStyles.flows.map((flow) => ({ + flowId: flow.flowId, + steps: flow.steps.map((step) => ({ + id: step.id, + ...(step.terminal ? { terminal: true } : {}), + })), + transitions: flow.transitions.map((transition) => ({ + from: transition.from, + to: transition.to, + })), + source: observation.finalUrl, + })); + + return { + flows, + collection: { + source: observation.renderedStyles.flowCollection.source, + observedFlowCount: observation.renderedStyles.flowCollection.observedFlowCount, + location: observation.finalUrl, + }, + }; +} + +function mapRemoteObservationAsyncStates( + observation: RemoteBrowserObservation, +): { + states: NonNullable; + collection: NonNullable; +} { + const states = observation.renderedStyles.asyncStates.map((state) => ({ + id: state.id, + kind: state.kind, + source: observation.finalUrl, + contextId: state.id, + sectionIds: state.sectionIds, + recoveryActions: state.recoveryActions, + preserveLastGoodContent: state.preserveLastGoodContent, + blockedActions: state.blockedActions, + })); + + return { + states, + collection: { + source: observation.renderedStyles.asyncStateCollection.source, + observedStateCount: observation.renderedStyles.asyncStateCollection.observedStateCount, + location: observation.finalUrl, + }, + }; +} + function mapViolationsToFindings( summary: ValidationSummary, ): JsonFinding[] { @@ -502,12 +750,23 @@ function mapViolationsToFindings( "marketing-typography-role-token": "marketing.typography.role-token", "motion-duration-not-allowed": "motion.duration", "motion-timing-not-allowed": "motion.timing", + "target-hit-area-too-small": "target.hit-area-too-small", + "target-gap-too-tight": "target.gap-too-tight", + "target-edge-inset-too-small": "target.edge-inset-too-small", + "destructive-target-too-close": "target.destructive-too-close", + "target-unobservable": "target.unobservable", + "feedback-state-missing": "feedback.state-missing", + "feedback-recovery-action-missing": "feedback.recovery-action-missing", + "feedback-pending-action-not-blocked": "feedback.pending-action-not-blocked", + "feedback-last-good-content-missing": "feedback.last-good-content-missing", + "feedback-unobservable": "feedback.unobservable", "descriptor-flows-missing": "descriptor.flows.missing", "flow-required-missing": "flow.required.missing", "flow-steps-min": "flow.steps.min", "flow-steps-required": "flow.steps.required", "flow-transition-required": "flow.transition.required", "flow-terminal-invalid": "flow.terminal.invalid", + "flow-unobservable": "flow.unobservable", "shell-owned-primitive-emitted": "shell.primitive.disallowed", }; @@ -657,6 +916,16 @@ function mapViolationsToFindings( } break; } + case "flow-unobservable": { + finding.expected = Array.isArray(details.requiredMetrics) + ? details.requiredMetrics + : ["contractScopedFlows"]; + finding.found = details.missingMetrics; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } case "layout-width-undetermined": { finding.expected = details.expectedMaxWidth; finding.found = null; @@ -667,6 +936,110 @@ function mapViolationsToFindings( finding.found = details.reportedWidth; break; } + case "target-hit-area-too-small": { + finding.expected = details.minHitAreaPx; + finding.found = { + width: details.width, + height: details.height, + targetId: details.targetId, + }; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "target-gap-too-tight": { + finding.expected = details.minGapPx; + finding.found = { + nearestNeighborGapPx: details.nearestNeighborGapPx, + targetId: details.targetId, + }; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "target-edge-inset-too-small": { + finding.expected = details.minEdgeInsetPx; + finding.found = { + edgeInsetPx: details.edgeInsetPx, + targetId: details.targetId, + }; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "destructive-target-too-close": { + finding.expected = details.destructiveGapPx; + finding.found = { + nearestNeighborGapPx: details.nearestNeighborGapPx, + targetId: details.targetId, + nearestNeighborClassification: details.nearestNeighborClassification, + }; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "target-unobservable": { + finding.expected = Array.isArray(details.requiredMetrics) + ? details.requiredMetrics + : ["boundingBox", "edgeInsetPx"]; + finding.found = details.missingMetrics; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "feedback-state-missing": { + finding.expected = details.kind; + finding.found = null; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "feedback-recovery-action-missing": { + finding.expected = details.expectedRecoveryActions; + finding.found = details.missingRecoveryActions; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "feedback-pending-action-not-blocked": { + finding.expected = details.expectedBlockedActions; + finding.found = details.missingBlockedActions; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "feedback-last-good-content-missing": { + finding.expected = { + preserveLastGoodContent: details.preserveLastGoodContentRequired, + preserveSections: details.expectedPreserveSections, + }; + finding.found = { + preserveLastGoodContent: details.preserveLastGoodContentObserved, + missingPreserveSections: details.missingPreserveSections, + }; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } + case "feedback-unobservable": { + finding.expected = Array.isArray(details.requiredMetrics) + ? details.requiredMetrics + : ["contractScopedAsyncStates"]; + finding.found = details.missingMetrics; + if (details.policy === "warn") { + finding.severity = "warning"; + } + break; + } case "layout-container-missing": { finding.expected = details.requiredContainers ?? details.requiredContainer; diff --git a/packages/interfacectl-cli/src/descriptors/static-analysis.ts b/packages/interfacectl-cli/src/descriptors/static-analysis.ts index 16f4d45..23b79a9 100644 --- a/packages/interfacectl-cli/src/descriptors/static-analysis.ts +++ b/packages/interfacectl-cli/src/descriptors/static-analysis.ts @@ -2,12 +2,18 @@ import path from "node:path"; import { readFile, stat } from "node:fs/promises"; import { globby } from "globby"; import { + type AsyncStateKind, type ChromeLayoutDescriptor, type ChromePolicyTarget, type ChromeShadowKind, + type InteractiveTargetClassification, + type InteractiveTargetDescriptor, type InterfaceContract, + type RecoveryActionKind, + type SurfaceAsyncStateDescriptor, type SurfaceDescriptor, type SurfaceFontDescriptor, + type SurfaceFlowDescriptor, type SurfaceColorDescriptor, type SurfaceIconDescriptor, type SurfaceLayoutDescriptor, @@ -48,6 +54,46 @@ 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 TARGET_ID_ATTRIBUTE_REGEX = + /data-contract-target\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const INTERACTION_ID_ATTRIBUTE_REGEX = + /data-contract-interaction\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const COMPONENT_ID_ATTRIBUTE_REGEX = + /data-contract-component\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const ACTION_RISK_ATTRIBUTE_REGEX = + /data-contract-action-risk\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const ACTION_KIND_ATTRIBUTE_REGEX = + /data-contract-action-kind\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const TARGET_GAP_ATTRIBUTE_REGEX = + /data-contract-target-gap\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*}|{([0-9.]+)})/; +const TARGET_EDGE_INSET_ATTRIBUTE_REGEX = + /data-contract-target-edge-inset\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*}|{([0-9.]+)})/; +const TARGET_VIEWPORT_ATTRIBUTE_REGEX = + /data-contract-viewport\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const TARGET_CONTEXT_ATTRIBUTE_REGEX = + /data-contract-context\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const TARGET_NEIGHBOR_KIND_ATTRIBUTE_REGEX = + /data-contract-nearest-kind\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const STATE_KIND_ATTRIBUTE_REGEX = + /data-contract-state-kind\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const STATE_ID_ATTRIBUTE_REGEX = + /data-contract-state-id\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const FLOW_ID_ATTRIBUTE_REGEX = + /data-contract-flow-id\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const FLOW_STEP_ATTRIBUTE_REGEX = + /data-contract-flow-step\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const FLOW_TRANSITION_TO_ATTRIBUTE_REGEX = + /data-contract-flow-transition-to\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const FLOW_TERMINAL_ATTRIBUTE_REGEX = + /data-contract-flow-terminal\s*=\s*(?:"true"|'true'|{true}|{\s*true\s*})/i; +const RECOVERY_ACTION_ATTRIBUTE_REGEX = + /data-contract-recovery-action\s*=\s*(?:"([^"]+)"|'([^']+)'|{`([^`]+)`}|{\s*["'`]([^"'`]+)["'`]\s*})/; +const PRESERVE_LAST_GOOD_ATTRIBUTE_REGEX = + /data-contract-preserve-last-good\s*=\s*(?:"true"|'true'|{true}|{\s*true\s*})/i; +const ARIA_DISABLED_TRUE_ATTRIBUTE_REGEX = + /aria-disabled\s*=\s*(?:"true"|'true'|{true}|{\s*true\s*})/i; +const DISABLED_ATTRIBUTE_REGEX = + /\bdisabled(?:\s*=\s*(?:"true"|'true'|{true}|{\s*true\s*}))?\b/i; 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; @@ -190,6 +236,7 @@ const RADIUS_TOKEN_MAP = new Map([ ["rounded-3xl", 24], ["rounded-full", Number.POSITIVE_INFINITY], ]); +const INTERACTIVE_TARGET_TAG_REGEX = /<(button|a|summary)\b[^>]*>/gi; export interface DescriptorIssue { surfaceId?: string; @@ -389,6 +436,21 @@ async function extractSurfaceDescriptor( surfaceId, ); warnings.push(...iconWarnings); + const interactiveTargets = await extractInteractiveTargets( + sectionFiles, + workspaceRoot, + fileContentCache, + ); + const flows = await extractFlows( + sectionFiles, + workspaceRoot, + fileContentCache, + ); + const asyncStates = await extractAsyncStates( + sectionFiles, + workspaceRoot, + fileContentCache, + ); const structuralSurfaceDescriptor: SurfaceDescriptor = { surfaceId, @@ -401,6 +463,25 @@ async function extractSurfaceDescriptor( layout, motion, primitives, + ...(flows.length > 0 + ? { + flows, + flowObservation: { + source: "static-markers", + observedFlowCount: flows.length, + }, + } + : {}), + interactiveTargets, + ...(asyncStates.length > 0 + ? { + asyncStates, + asyncStateObservation: { + source: "static-markers", + observedStateCount: asyncStates.length, + }, + } + : {}), }; return { descriptor: structuralSurfaceDescriptor, warnings, errors }; @@ -620,6 +701,327 @@ async function extractLayout( }; } +async function extractInteractiveTargets( + filePaths: string[], + workspaceRoot: string, + fileContentCache: Map, +): Promise { + const targets: InteractiveTargetDescriptor[] = []; + let autoIndex = 0; + + for (const filePath of filePaths) { + const content = await readFileCached(filePath, fileContentCache); + const source = path.relative(workspaceRoot, filePath); + INTERACTIVE_TARGET_TAG_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = INTERACTIVE_TARGET_TAG_REGEX.exec(content)) !== null) { + const tag = match[0]; + const tagName = (match[1] ?? extractTagName(tag) ?? "").toLowerCase(); + const interactiveTarget = buildInteractiveTargetDescriptor( + tag, + tagName, + source, + autoIndex, + ); + if (interactiveTarget) { + targets.push(interactiveTarget); + autoIndex += 1; + } + } + } + + return targets.sort((a, b) => + a.id.localeCompare(b.id) || + (a.source ?? "").localeCompare(b.source ?? "") || + a.role.localeCompare(b.role), + ); +} + +async function extractFlows( + filePaths: string[], + workspaceRoot: string, + fileContentCache: Map, +): Promise { + const flows = new Map< + string, + { + source?: string; + steps: Map; + transitions: Map; + } + >(); + + const ensureFlow = (flowId: string, source: string) => { + const existing = flows.get(flowId); + if (existing) { + if (!existing.source) { + existing.source = source; + } + return existing; + } + const created = { + source, + steps: new Map(), + transitions: new Map(), + }; + flows.set(flowId, created); + return created; + }; + + for (const filePath of filePaths) { + const content = await readFileCached(filePath, fileContentCache); + const source = path.relative(workspaceRoot, filePath); + const stack: Array<{ tagName: string; flowId?: string; stepId?: string }> = []; + + TAG_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = TAG_REGEX.exec(content)) !== null) { + const tag = match[0]; + const tagName = extractTagName(tag); + if (!tagName) { + continue; + } + + if (tag.startsWith("= 0; index -= 1) { + if (stack[index]?.tagName === tagName) { + stack.splice(index, 1); + break; + } + } + continue; + } + + const rawFlowId = extractTagAttributeValue(tag, FLOW_ID_ATTRIBUTE_REGEX); + const rawStepId = extractTagAttributeValue(tag, FLOW_STEP_ATTRIBUTE_REGEX); + const transitionTo = extractTagAttributeValue(tag, FLOW_TRANSITION_TO_ATTRIBUTE_REGEX); + const nearestFlowId = + rawFlowId ?? + [...stack] + .reverse() + .find((entry) => entry.flowId)?.flowId; + const nearestStepId = + rawStepId ?? + [...stack] + .reverse() + .find((entry) => entry.stepId)?.stepId; + + if (nearestFlowId && rawStepId) { + const flow = ensureFlow(nearestFlowId, source); + const existingStep = flow.steps.get(rawStepId); + if (existingStep) { + existingStep.terminal ||= FLOW_TERMINAL_ATTRIBUTE_REGEX.test(tag); + } else { + flow.steps.set(rawStepId, { + id: rawStepId, + ...(FLOW_TERMINAL_ATTRIBUTE_REGEX.test(tag) ? { terminal: true } : {}), + }); + } + } else if (rawFlowId) { + ensureFlow(rawFlowId, source); + } + + if (nearestFlowId && nearestStepId && transitionTo) { + const flow = ensureFlow(nearestFlowId, source); + flow.transitions.set(`${nearestStepId}->${transitionTo}`, { + from: nearestStepId, + to: transitionTo, + }); + } + + if (!/\/>\s*$/.test(tag)) { + stack.push({ + tagName, + ...(rawFlowId ? { flowId: rawFlowId } : {}), + ...(rawStepId ? { stepId: rawStepId } : {}), + }); + } + } + } + + return [...flows.entries()] + .map(([flowId, flow]) => ({ + flowId, + steps: [...flow.steps.values()].sort((a, b) => a.id.localeCompare(b.id)), + transitions: [...flow.transitions.values()].sort((a, b) => + a.from.localeCompare(b.from) || a.to.localeCompare(b.to), + ), + source: flow.source, + })) + .sort((a, b) => a.flowId.localeCompare(b.flowId)); +} + +async function extractAsyncStates( + filePaths: string[], + workspaceRoot: string, + fileContentCache: Map, +): Promise { + const states = new Map< + string, + Omit & { kind?: AsyncStateKind } + >(); + + for (const filePath of filePaths) { + const content = await readFileCached(filePath, fileContentCache); + const source = path.relative(workspaceRoot, filePath); + TAG_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = TAG_REGEX.exec(content)) !== null) { + const tag = match[0]; + const stateKind = extractAsyncStateKind( + extractTagAttributeValue(tag, STATE_KIND_ATTRIBUTE_REGEX), + ); + const stateId = extractTagAttributeValue(tag, STATE_ID_ATTRIBUTE_REGEX); + const stateKey = stateId ?? stateKind; + if (!stateKey) { + continue; + } + + const existing = states.get(stateKey); + const sectionId = extractTagAttributeValue(tag, SECTION_ATTRIBUTE_REGEX); + const recoveryAction = extractRecoveryActionKind( + extractTagAttributeValue(tag, RECOVERY_ACTION_ATTRIBUTE_REGEX), + ); + const interactionId = extractTagAttributeValue(tag, INTERACTION_ID_ATTRIBUTE_REGEX); + const disabled = + interactionId && + (DISABLED_ATTRIBUTE_REGEX.test(tag) || ARIA_DISABLED_TRUE_ATTRIBUTE_REGEX.test(tag)); + + const next = existing ?? { + id: stateKey, + source, + contextId: stateId, + sectionIds: [], + recoveryActions: [], + blockedActions: [], + }; + + if (stateKind) { + next.kind = stateKind; + } + if (!next.source) { + next.source = source; + } + if (!next.contextId && stateId) { + next.contextId = stateId; + } + if (sectionId) { + next.sectionIds = [...new Set([...(next.sectionIds ?? []), sectionId])]; + } + if (recoveryAction) { + next.recoveryActions = [ + ...new Set([...(next.recoveryActions ?? []), recoveryAction]), + ]; + } + if (PRESERVE_LAST_GOOD_ATTRIBUTE_REGEX.test(tag)) { + next.preserveLastGoodContent = true; + } + if (interactionId) { + const blockedActions = next.blockedActions ?? []; + const existingBlockedAction = blockedActions.find( + (action) => action.interactionId === interactionId, + ); + if (existingBlockedAction) { + existingBlockedAction.disabled ||= Boolean(disabled); + } else { + blockedActions.push({ + interactionId, + disabled: Boolean(disabled), + }); + } + next.blockedActions = blockedActions; + } + + states.set(stateKey, next); + } + } + + return [...states.values()] + .filter((state): state is SurfaceAsyncStateDescriptor => Boolean(state.kind)) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +function buildInteractiveTargetDescriptor( + tag: string, + tagName: string, + source: string, + autoIndex: number, +): InteractiveTargetDescriptor | null { + const styleText = extractTagStyleText(tag); + const interactionId = extractTagAttributeValue(tag, INTERACTION_ID_ATTRIBUTE_REGEX); + const componentId = extractTagAttributeValue(tag, COMPONENT_ID_ATTRIBUTE_REGEX); + const targetId = + extractTagAttributeValue(tag, TARGET_ID_ATTRIBUTE_REGEX) ?? + interactionId ?? + `${tagName || "target"}-${autoIndex + 1}`; + const viewportId = extractTagAttributeValue(tag, TARGET_VIEWPORT_ATTRIBUTE_REGEX); + const contextId = extractTagAttributeValue(tag, TARGET_CONTEXT_ATTRIBUTE_REGEX); + const width = + extractStyleNumericPx(styleText, "width", "width") ?? + extractStyleNumericPx(styleText, "min-width", "minWidth"); + const height = + extractStyleNumericPx(styleText, "height", "height") ?? + extractStyleNumericPx(styleText, "min-height", "minHeight"); + const explicitGap = parseNumericLiteral( + extractTagAttributeValue(tag, TARGET_GAP_ATTRIBUTE_REGEX), + ); + const explicitEdgeInset = parseNumericLiteral( + extractTagAttributeValue(tag, TARGET_EDGE_INSET_ATTRIBUTE_REGEX), + ); + const inferredEdgeInset = inferEdgeInsetPx(styleText); + const edgeInsetPx = explicitEdgeInset ?? inferredEdgeInset; + const classification = extractInteractiveTargetClassification( + extractTagAttributeValue(tag, ACTION_RISK_ATTRIBUTE_REGEX) ?? + extractTagAttributeValue(tag, ACTION_KIND_ATTRIBUTE_REGEX) ?? + (/\btype\s*=\s*(?:"submit"|'submit'|{`submit`}|{\s*["'`]submit["'`]\s*})/i.test(tag) + ? "primary" + : undefined), + ); + const nearestNeighborClassification = + extractInteractiveTargetClassification( + extractTagAttributeValue(tag, TARGET_NEIGHBOR_KIND_ATTRIBUTE_REGEX), + ); + + if (!tagName) { + return null; + } + + return { + id: targetId, + role: tagName === "a" ? "link" : tagName, + source, + selector: interactionId + ? `[data-contract-interaction="${interactionId}"]` + : `[data-contract-target="${targetId}"]`, + ...(componentId ? { componentId } : {}), + ...(interactionId ? { interactionId } : {}), + ...(viewportId ? { viewportId } : {}), + ...(contextId ? { contextId } : {}), + ...(Number.isFinite(width) && Number.isFinite(height) + ? { + boundingBox: { + x: 0, + y: 0, + width: Number(width), + height: Number(height), + }, + hitAreaPx: Number(width) * Number(height), + } + : { + hitAreaPx: null, + }), + nearestNeighborGapPx: explicitGap ?? null, + ...(nearestNeighborClassification + ? { nearestNeighborClassification } + : {}), + edgeInsetPx: edgeInsetPx ?? null, + ...(classification ? { classification } : {}), + }; +} + interface ChromeTargetWrapper { targetType: ChromePolicyTarget; source: string; @@ -898,6 +1300,121 @@ function extractTagStyleText( }; } +function extractTagAttributeValue(tag: string, regex: RegExp): string | undefined { + regex.lastIndex = 0; + const match = regex.exec(tag); + regex.lastIndex = 0; + const raw = + match?.[1] ?? + match?.[2] ?? + match?.[3] ?? + match?.[4] ?? + match?.[5] ?? + ""; + const value = raw.trim(); + return value.length > 0 ? value : undefined; +} + +function parseNumericLiteral(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + + const normalized = value.trim().replace(/^["'`]|["'`]$/g, ""); + const match = normalized.match(/^([0-9.]+)(?:px)?$/i); + if (!match?.[1]) { + return undefined; + } + + const parsed = Number.parseFloat(match[1]); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function extractAsyncStateKind(value: string | undefined): AsyncStateKind | undefined { + switch (value?.trim()) { + case "loading": + case "empty": + case "partial": + case "error": + case "success": + return value.trim() as AsyncStateKind; + default: + return undefined; + } +} + +function extractRecoveryActionKind( + value: string | undefined, +): RecoveryActionKind | undefined { + switch (value?.trim()) { + case "retry": + case "refresh": + case "dismiss": + case "contact-support": + case "navigate-home": + case "go-back": + return value.trim() as RecoveryActionKind; + default: + return undefined; + } +} + +function extractStyleNumericPx( + styleText: ChromeTargetWrapper["styleText"], + cssProperty: string, + jsxProperty: string, +): number | undefined { + if (!styleText) { + return undefined; + } + + if (styleText.kind === "string") { + const match = new RegExp(`${cssProperty}\\s*:\\s*([0-9.]+)\\s*px`, "i").exec( + styleText.value, + ); + return parseNumericLiteral(match?.[1]); + } + + const objectMatch = new RegExp( + `\\b${jsxProperty}\\s*:\\s*(?:["'\`])?([0-9.]+)(?:px)?(?:["'\`])?`, + "i", + ).exec(styleText.value); + return parseNumericLiteral(objectMatch?.[1]); +} + +function inferEdgeInsetPx( + styleText: ChromeTargetWrapper["styleText"], +): number | undefined { + const values = [ + extractStyleNumericPx(styleText, "top", "top"), + extractStyleNumericPx(styleText, "right", "right"), + extractStyleNumericPx(styleText, "bottom", "bottom"), + extractStyleNumericPx(styleText, "left", "left"), + ].filter((value): value is number => Number.isFinite(value)); + + if (values.length === 0) { + return undefined; + } + + return Math.min(...values); +} + +function extractInteractiveTargetClassification( + input: string | undefined, +): InteractiveTargetClassification | undefined { + const normalized = input?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + if (normalized === "destructive" || normalized === "danger") { + return "destructive"; + } + if (normalized === "primary" || normalized === "cta") { + return "primary"; + } + return "default"; +} + function parseChromeInlineSignals( styleText: ChromeTargetWrapper["styleText"], source: string, diff --git a/packages/interfacectl-cli/src/index.ts b/packages/interfacectl-cli/src/index.ts index 8625d69..f8af224 100644 --- a/packages/interfacectl-cli/src/index.ts +++ b/packages/interfacectl-cli/src/index.ts @@ -68,6 +68,10 @@ program "--surface ", "Limit validation to the provided surface identifiers", ) + .option( + "--remote-url ", + "Augment validation with browser-observed target metrics from the provided URL", + ) .option( "--json", "Emit machine-readable JSON instead of human-readable text output", @@ -119,6 +123,7 @@ program schemaPath: options.schema, workspaceRoot, surfaceFilters: options.surface ?? [], + remoteUrl: options.remoteUrl, outputFormat, outputPath: options.out, configPath: requestedConfig, diff --git a/packages/interfacectl-cli/src/utils/browser-session.ts b/packages/interfacectl-cli/src/utils/browser-session.ts index 0220a33..aa5b1ec 100644 --- a/packages/interfacectl-cli/src/utils/browser-session.ts +++ b/packages/interfacectl-cli/src/utils/browser-session.ts @@ -18,6 +18,82 @@ export interface RemoteRenderedMotionObservation { timingFunction: string; } +export interface RemoteInteractiveTargetObservation { + id: string; + role: string; + selector?: string; + boundingBox: { + x: number; + y: number; + width: number; + height: number; + }; + hitAreaPx: number; + nearestNeighborGapPx: number | null; + nearestNeighborClassification?: "default" | "primary" | "destructive"; + edgeInsetPx: number; + classification: "default" | "primary" | "destructive"; +} + +export type RemoteInteractiveTargetCollectionSource = + | "contract-scoped" + | "all-visible-fallback" + | "none-observed"; + +export interface RemoteInteractiveTargetCollectionObservation { + source: RemoteInteractiveTargetCollectionSource; + allVisibleCount: number; + contractScopedCount: number; +} + +export interface RemoteAsyncStateObservation { + id: string; + kind: "loading" | "empty" | "partial" | "error" | "success"; + sectionIds: string[]; + recoveryActions: Array< + "retry" | "refresh" | "dismiss" | "contact-support" | "navigate-home" | "go-back" + >; + preserveLastGoodContent: boolean; + blockedActions: Array<{ + interactionId: string; + disabled: boolean; + }>; +} + +export interface RemoteFlowStepObservation { + id: string; + terminal?: boolean; +} + +export interface RemoteFlowTransitionObservation { + from: string; + to: string; +} + +export interface RemoteFlowObservation { + flowId: string; + steps: RemoteFlowStepObservation[]; + transitions: RemoteFlowTransitionObservation[]; +} + +export type RemoteFlowCollectionSource = + | "contract-scoped" + | "none-observed"; + +export interface RemoteFlowCollectionObservation { + source: RemoteFlowCollectionSource; + observedFlowCount: number; +} + +export type RemoteAsyncStateCollectionSource = + | "contract-scoped" + | "none-observed"; + +export interface RemoteAsyncStateCollectionObservation { + source: RemoteAsyncStateCollectionSource; + observedStateCount: number; +} + export interface RemoteRenderedStyleObservation { fonts: string[]; colors: string[]; @@ -26,6 +102,12 @@ export interface RemoteRenderedStyleObservation { shadowKinds: Array<"outer" | "inset" | "mixed">; motions: RemoteRenderedMotionObservation[]; containers: string[]; + interactiveTargets: RemoteInteractiveTargetObservation[]; + interactiveTargetCollection: RemoteInteractiveTargetCollectionObservation; + flows: RemoteFlowObservation[]; + flowCollection: RemoteFlowCollectionObservation; + asyncStates: RemoteAsyncStateObservation[]; + asyncStateCollection: RemoteAsyncStateCollectionObservation; } interface RemoteRenderedGateObservation { @@ -261,6 +343,22 @@ export async function observeRemotePage(options: { shadowKinds: [], motions: [], containers: [], + interactiveTargets: [], + interactiveTargetCollection: { + source: "none-observed" as const, + allVisibleCount: 0, + contractScopedCount: 0, + }, + flows: [], + flowCollection: { + source: "none-observed" as const, + observedFlowCount: 0, + }, + asyncStates: [], + asyncStateCollection: { + source: "none-observed" as const, + observedStateCount: 0, + }, }, }; } @@ -335,6 +433,80 @@ export async function observeRemotePage(options: { const passwordInputs = Array.from(doc.querySelectorAll("input[type='password']")).filter((node) => isVisible(node), ); + const allInteractiveNodes = Array.from( + doc.querySelectorAll("a, button, summary, [role='button']"), + ).filter((node) => isVisible(node)); + const contractScopedInteractiveNodes = allInteractiveNodes.filter((node: any) => { + const dataset = node?.dataset ?? {}; + return Boolean( + String(dataset.contractTarget ?? "").trim() || + String(dataset.contractInteraction ?? "").trim(), + ); + }); + const interactiveTargetCollection = + contractScopedInteractiveNodes.length > 0 + ? { + source: "contract-scoped" as const, + allVisibleCount: allInteractiveNodes.length, + contractScopedCount: contractScopedInteractiveNodes.length, + } + : allInteractiveNodes.length > 0 + ? { + source: "all-visible-fallback" as const, + allVisibleCount: allInteractiveNodes.length, + contractScopedCount: 0, + } + : { + source: "none-observed" as const, + allVisibleCount: 0, + contractScopedCount: 0, + }; + const interactiveNodes = interactiveTargetCollection.source === "contract-scoped" + ? contractScopedInteractiveNodes + : allInteractiveNodes; + const isAsyncStateKind = ( + value: string, + ): value is "loading" | "empty" | "partial" | "error" | "success" => + value === "loading" || + value === "empty" || + value === "partial" || + value === "error" || + value === "success"; + const isRecoveryActionKind = ( + value: string, + ): value is "retry" | "refresh" | "dismiss" | "contact-support" | "navigate-home" | "go-back" => + value === "retry" || + value === "refresh" || + value === "dismiss" || + value === "contact-support" || + value === "navigate-home" || + value === "go-back"; + const stateNodes = Array.from( + doc.querySelectorAll("[data-contract-state-kind]"), + ).filter((node) => isVisible(node)); + const flowNodes = Array.from( + doc.querySelectorAll("[data-contract-flow-id]"), + ).filter((node) => isVisible(node)); + const flowCollection = + flowNodes.length > 0 + ? { + source: "contract-scoped" as const, + observedFlowCount: flowNodes.length, + } + : { + source: "none-observed" as const, + observedFlowCount: 0, + }; + const asyncStateCollection = + stateNodes.length > 0 + ? { + source: "contract-scoped" as const, + observedStateCount: stateNodes.length, + } + : { + source: "none-observed" as const, + observedStateCount: 0, + }; const nodes = Array.from( doc.querySelectorAll( "body, main, header, nav, footer, aside, section, article, form, div, h1, h2, h3, h4, h5, h6, p, a, button, input, label", @@ -390,6 +562,179 @@ export async function observeRemotePage(options: { } } + const classifyTarget = (node: any): "default" | "primary" | "destructive" => { + const dataset = node?.dataset ?? {}; + const raw = + String(dataset.contractActionRisk ?? dataset.contractActionKind ?? "").toLowerCase() || + (String(node?.getAttribute?.("type") ?? "").toLowerCase() === "submit" ? "primary" : ""); + if (raw === "destructive" || raw === "danger") { + return "destructive"; + } + if (raw === "primary" || raw === "cta") { + return "primary"; + } + return "default"; + }; + const viewportWidth = typeof globalThis.innerWidth === "number" ? globalThis.innerWidth : 0; + const viewportHeight = typeof globalThis.innerHeight === "number" ? globalThis.innerHeight : 0; + const interactiveTargets = (interactiveNodes as any[]).map((node, index, list) => { + const rect = node.getBoundingClientRect(); + const classification = classifyTarget(node); + let nearestNeighborGapPx: number | null = null; + let nearestNeighborClassification: "default" | "primary" | "destructive" | undefined; + + for (const other of list) { + if (other === node) continue; + const otherRect = other.getBoundingClientRect(); + const dx = Math.max(0, otherRect.left - rect.right, rect.left - otherRect.right); + const dy = Math.max(0, otherRect.top - rect.bottom, rect.top - otherRect.bottom); + const gap = dx === 0 ? dy : dy === 0 ? dx : Math.hypot(dx, dy); + if (nearestNeighborGapPx === null || gap < nearestNeighborGapPx) { + nearestNeighborGapPx = gap; + nearestNeighborClassification = classifyTarget(other); + } + } + + const edgeInsetPx = Math.min( + rect.left, + viewportWidth - rect.right, + rect.top, + viewportHeight - rect.bottom, + ); + const dataset = node?.dataset ?? {}; + + return { + id: + String(dataset.contractTarget ?? dataset.contractInteraction ?? "").trim() || + `${String(node?.tagName ?? "target").toLowerCase()}-${index + 1}`, + role: + String(node?.tagName ?? "target").toLowerCase() === "a" + ? "link" + : String(node?.tagName ?? "target").toLowerCase(), + selector: + typeof dataset.contractInteraction === "string" + ? `[data-contract-interaction="${dataset.contractInteraction}"]` + : typeof dataset.contractTarget === "string" + ? `[data-contract-target="${dataset.contractTarget}"]` + : undefined, + boundingBox: { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }, + hitAreaPx: rect.width * rect.height, + nearestNeighborGapPx, + nearestNeighborClassification, + edgeInsetPx, + classification, + }; + }); + const asyncStates = (stateNodes as any[]).flatMap((node, index) => { + const dataset = node?.dataset ?? {}; + const stateId = + String(dataset.contractStateId ?? "").trim() || + String(dataset.contractStateKind ?? "").trim() || + `state-${index + 1}`; + const stateKind = String(dataset.contractStateKind ?? "").trim().toLowerCase(); + if (!isAsyncStateKind(stateKind)) { + return []; + } + const selector = `[data-contract-state-id="${stateId}"]`; + const sectionIds = Array.from( + doc.querySelectorAll(`${selector}[data-contract-section]`), + ) + .filter((candidate) => isVisible(candidate)) + .map((candidate: any) => String(candidate?.dataset?.contractSection ?? "").trim()) + .filter(Boolean); + const recoveryActions = Array.from( + doc.querySelectorAll(`${selector}[data-contract-recovery-action]`), + ) + .filter((candidate) => isVisible(candidate)) + .map((candidate: any) => + String(candidate?.dataset?.contractRecoveryAction ?? "").trim().toLowerCase(), + ) + .filter(isRecoveryActionKind); + const preserveLastGoodContent = Array.from( + doc.querySelectorAll(`${selector}[data-contract-preserve-last-good="true"]`), + ).some((candidate) => isVisible(candidate)); + const blockedActions = Array.from( + doc.querySelectorAll(`${selector}[data-contract-interaction]`), + ) + .filter((candidate) => isVisible(candidate)) + .map((candidate: any) => { + const interactionId = String(candidate?.dataset?.contractInteraction ?? "").trim(); + return { + interactionId, + disabled: + Boolean(candidate?.disabled) || + String(candidate?.getAttribute?.("aria-disabled") ?? "").toLowerCase() === "true", + }; + }) + .filter((candidate) => candidate.interactionId.length > 0); + + return [{ + id: stateId, + kind: stateKind, + sectionIds: [...new Set(sectionIds)].sort((a, b) => a.localeCompare(b)), + recoveryActions: [...new Set(recoveryActions)].sort((a, b) => a.localeCompare(b)), + preserveLastGoodContent, + blockedActions, + }]; + }); + const flows = (flowNodes as any[]).flatMap((flowNode, index) => { + const flowId = String(flowNode?.dataset?.contractFlowId ?? "").trim() || `flow-${index + 1}`; + const stepMap = new Map(); + const transitionMap = new Map(); + const stepNodes = Array.from( + flowNode.querySelectorAll("[data-contract-flow-step]"), + ).filter((candidate) => + isVisible(candidate) && + (candidate as any).closest?.("[data-contract-flow-id]") === flowNode, + ); + + for (const stepNode of stepNodes as any[]) { + const stepId = String(stepNode?.dataset?.contractFlowStep ?? "").trim(); + if (!stepId) { + continue; + } + stepMap.set(stepId, { + id: stepId, + ...(String(stepNode?.getAttribute?.("data-contract-flow-terminal") ?? "").toLowerCase() === "true" + ? { terminal: true } + : {}), + }); + + const transitionNodes = Array.from( + stepNode.querySelectorAll("[data-contract-flow-transition-to]"), + ).filter((candidate) => + isVisible(candidate) && + (candidate as any).closest?.("[data-contract-flow-step]") === stepNode, + ); + + for (const transitionNode of transitionNodes as any[]) { + const transitionTo = String( + transitionNode?.dataset?.contractFlowTransitionTo ?? "", + ).trim(); + if (!transitionTo) { + continue; + } + transitionMap.set(`${stepId}->${transitionTo}`, { + from: stepId, + to: transitionTo, + }); + } + } + + return [{ + flowId, + steps: [...stepMap.values()].sort((a, b) => a.id.localeCompare(b.id)), + transitions: [...transitionMap.values()].sort((a, b) => + a.from.localeCompare(b.from) || a.to.localeCompare(b.to), + ), + }]; + }); + return { gateObservation: { bodyText: getVisibleText(doc.body), @@ -406,6 +751,12 @@ export async function observeRemotePage(options: { shadowKinds, motions, containers: [...containers].sort((a, b) => a.localeCompare(b)), + interactiveTargets, + interactiveTargetCollection, + flows, + flowCollection, + asyncStates, + asyncStateCollection, }, }; }); diff --git a/packages/interfacectl-cli/test/static-analysis.test.mjs b/packages/interfacectl-cli/test/static-analysis.test.mjs index 6f2ea97..8e5bd56 100644 --- a/packages/interfacectl-cli/test/static-analysis.test.mjs +++ b/packages/interfacectl-cli/test/static-analysis.test.mjs @@ -596,6 +596,374 @@ test("collectSurfaceDescriptors captures deterministic icon sources from surface } }); +test("collectSurfaceDescriptors extracts interactive target metrics and classifications", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-target-static-")); + const surfaceId = "target-surface"; + const surfaceRoot = path.join(tempRoot, "apps", surfaceId); + + try { + await mkdir(path.join(surfaceRoot, "app"), { recursive: true }); + + await writeFile( + path.join(surfaceRoot, "app", "page.tsx"), + ` + export default function Page() { + return ( +
+ + Start + + +
+ ); + } + `, + "utf-8", + ); + + const contract = { + contractId: "target.contract", + version: "1.0.0", + sections: [ + { id: "main.hero", intent: "hero", description: "Hero section" }, + ], + constraints: { + motion: { allowedDurationsMs: [120], allowedTimingFunctions: ["linear"] }, + }, + surfaces: [ + { + id: surfaceId, + displayName: "Target Surface", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["Inter"], + layout: { + maxContentWidth: 1120, + targetAcquisition: { + policy: "warn", + }, + }, + }, + ], + color: { + policy: "off", + allowedValues: [], + }, + }; + + const result = await collectSurfaceDescriptors({ + workspaceRoot: tempRoot, + contract, + surfaceFilters: new Set(), + surfaceRootMap: new Map(), + }); + + assert.equal(result.errors.length, 0); + const descriptor = result.descriptors[0]; + assert.ok(descriptor); + assert.equal(descriptor.interactiveTargets?.length, 2); + assert.deepEqual( + descriptor.interactiveTargets?.map((target) => target.id), + ["delete-workspace", "hero-primary"], + ); + assert.equal( + descriptor.interactiveTargets?.find((target) => target.id === "hero-primary")?.boundingBox?.width, + 44, + ); + assert.equal( + descriptor.interactiveTargets?.find((target) => target.id === "hero-primary")?.classification, + "primary", + ); + assert.equal( + descriptor.interactiveTargets?.find((target) => target.id === "delete-workspace")?.classification, + "destructive", + ); + assert.equal( + descriptor.interactiveTargets?.find((target) => target.id === "delete-workspace")?.edgeInsetPx, + 4, + ); + assert.equal( + descriptor.interactiveTargets?.find((target) => target.id === "delete-workspace")?.nearestNeighborClassification, + "primary", + ); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("collectSurfaceDescriptors extracts contract-scoped async states and recovery affordances", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-feedback-static-")); + const surfaceId = "feedback-surface"; + const surfaceRoot = path.join(tempRoot, "apps", surfaceId); + + try { + await mkdir(path.join(surfaceRoot, "app"), { recursive: true }); + + await writeFile( + path.join(surfaceRoot, "app", "page.tsx"), + ` + export default function Page() { + return ( +
+
+

Dashboard ready

+
+
+

No queued items remain.

+
+
+ +
+
+ +
+
+ ); + } + `, + "utf-8", + ); + + const contract = { + contractId: "feedback.contract", + version: "1.0.0", + sections: [ + { id: "main.hero", intent: "hero", description: "Hero section" }, + ], + constraints: { + motion: { allowedDurationsMs: [120], allowedTimingFunctions: ["linear"] }, + }, + surfaces: [ + { + id: surfaceId, + displayName: "Feedback Surface", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["Inter"], + layout: { + maxContentWidth: 960, + }, + runtime: { + feedbackRecovery: { + policy: "warn", + }, + contexts: [ + { id: "loading", when: "request == pending", kind: "loading" }, + { id: "empty", when: "items.length == 0", kind: "empty" }, + { id: "error", when: "request == failed", kind: "error" }, + { id: "success", when: "request == fulfilled", kind: "success" }, + ], + }, + }, + ], + color: { + policy: "off", + allowedValues: [], + }, + }; + + const result = await collectSurfaceDescriptors({ + workspaceRoot: tempRoot, + contract, + surfaceFilters: new Set(), + surfaceRootMap: new Map(), + }); + + assert.equal(result.errors.length, 0); + const descriptor = result.descriptors[0]; + assert.ok(descriptor); + assert.equal(descriptor.asyncStateObservation?.source, "static-markers"); + assert.equal(descriptor.asyncStates?.length, 4); + assert.deepEqual( + descriptor.asyncStates?.map((state) => state.id).sort(), + ["empty", "error", "loading", "success"], + ); + assert.deepEqual( + descriptor.asyncStates?.find((state) => state.id === "error")?.recoveryActions, + ["retry"], + ); + assert.equal( + descriptor.asyncStates?.find((state) => state.id === "error")?.preserveLastGoodContent, + true, + ); + assert.deepEqual( + descriptor.asyncStates?.find((state) => state.id === "loading")?.blockedActions, + [{ interactionId: "submit-refresh", disabled: true }], + ); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); + +test("collectSurfaceDescriptors extracts contract-scoped flow markers", async () => { + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-flow-static-")); + const surfaceId = "flow-surface"; + const surfaceRoot = path.join(tempRoot, "apps", surfaceId); + + try { + await mkdir(path.join(surfaceRoot, "app"), { recursive: true }); + + await writeFile( + path.join(surfaceRoot, "app", "page.tsx"), + ` + export default function Page() { + return ( +
+
+
+

Flow fixture

+
+
+ +
+
+ +
+
+

Confirm deletion

+
+
+
+ ); + } + `, + "utf-8", + ); + + await writeFile( + path.join(surfaceRoot, "app", "globals.css"), + ` + :root { + --contract-max-width: 960px; + } + `, + "utf-8", + ); + + const contract = { + contractId: "flow.contract", + version: "1.0.0", + sections: [ + { id: "main.hero", intent: "hero", description: "Hero section" }, + ], + constraints: { + motion: { allowedDurationsMs: [120], allowedTimingFunctions: ["linear"] }, + }, + surfaces: [ + { + id: surfaceId, + displayName: "Flow Surface", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["Inter"], + layout: { + maxContentWidth: 960, + }, + flows: { + policy: "warn", + requirements: [ + { + flowId: "workspace-delete", + minSteps: 2, + requiredSteps: ["request", "review", "confirm"], + requiredTransitions: [ + { from: "request", to: "review" }, + { from: "review", to: "confirm" }, + ], + terminalSteps: ["confirm"], + }, + ], + }, + }, + ], + color: { + policy: "off", + allowedValues: [], + }, + }; + + const result = await collectSurfaceDescriptors({ + workspaceRoot: tempRoot, + contract, + surfaceFilters: new Set(), + surfaceRootMap: new Map(), + }); + + assert.equal(result.errors.length, 0); + const descriptor = result.descriptors[0]; + assert.ok(descriptor); + assert.equal(descriptor.flowObservation?.source, "static-markers"); + assert.equal(descriptor.flows?.length, 1); + assert.deepEqual( + descriptor.flows?.[0]?.steps, + [ + { id: "confirm", terminal: true }, + { id: "request" }, + { id: "review" }, + ], + ); + assert.deepEqual( + descriptor.flows?.[0]?.transitions, + [ + { from: "request", to: "review" }, + { from: "review", to: "confirm" }, + ], + ); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +}); + test("collectSurfaceDescriptors captures portable chrome markers and deterministic chrome signals", async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-chrome-static-")); const surfaceId = "demo-chrome"; diff --git a/packages/interfacectl-cli/test/validate-remote-observation.test.mjs b/packages/interfacectl-cli/test/validate-remote-observation.test.mjs new file mode 100644 index 0000000..e8d4034 --- /dev/null +++ b/packages/interfacectl-cli/test/validate-remote-observation.test.mjs @@ -0,0 +1,1107 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { + mkdtemp, + mkdir, + rm, + writeFile, +} from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const cliExecutable = path.resolve(__dirname, "..", "dist", "index.js"); + +let chromiumAvailability; + +async function ensureChromiumAvailable(t) { + if (chromiumAvailability === undefined) { + chromiumAvailability = await new Promise((resolve) => { + const child = spawn( + "node", + [ + "-e", + "import('playwright').then(async ({ chromium }) => { const browser = await chromium.launch({ headless: true }); await browser.close(); }).then(() => process.exit(0)).catch(() => process.exit(1));", + ], + { + cwd: path.resolve(__dirname, ".."), + env: process.env, + }, + ); + child.on("exit", (code) => resolve(code === 0)); + }); + } + + if (!chromiumAvailability) { + t.skip("Playwright Chromium is not installed."); + } +} + +async function withServer(handler, callback) { + const server = http.createServer(handler); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + const origin = `http://127.0.0.1:${address.port}`; + try { + return await callback(origin); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +async function runValidate(workspaceRoot, contractPath, remoteUrl) { + const child = spawn( + "node", + [ + cliExecutable, + "validate", + "--contract", + contractPath, + "--workspace-root", + workspaceRoot, + "--surface", + "test-surface", + "--remote-url", + remoteUrl, + "--format", + "json", + "--exit-codes", + "v2", + ], + { + cwd: workspaceRoot, + env: { + ...process.env, + INTERFACECTL_PLAYWRIGHT_HEADLESS: "1", + }, + }, + ); + + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + const [exitCode] = await once(child, "exit"); + return { + exitCode: Number(exitCode), + stdout, + stderr, + }; +} + +async function createWorkspace(options = {}) { + const { + targetAcquisition = true, + feedbackRecovery = false, + flows = false, + } = options; + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "interfacectl-validate-remote-")); + const surfaceId = "test-surface"; + const appDir = path.join(tempRoot, "apps", surfaceId, "app"); + const contractPath = path.join(tempRoot, "contract.json"); + + await mkdir(appDir, { recursive: true }); + + await writeFile( + path.join(tempRoot, "interfacectl.config.json"), + JSON.stringify( + { + surfaceRoots: { + [surfaceId]: `apps/${surfaceId}`, + }, + }, + null, + 2, + ), + "utf-8", + ); + + await writeFile( + path.join(appDir, "layout.tsx"), + ` + import "./globals.css"; + export default function Layout({ children }) { + return {children}; + } + `, + "utf-8", + ); + + await writeFile( + path.join(appDir, "page.tsx"), + ` + export default function Page() { + return ( +
+
+

Remote validate test

+
+
+ ); + } + `, + "utf-8", + ); + + await writeFile( + path.join(appDir, "globals.css"), + ` + :root { + --font-inter: "Inter", sans-serif; + --color-primary: #0f172a; + --contract-max-width: 960px; + } + body { + font-family: var(--font-inter), Inter, sans-serif; + color: var(--color-primary); + background: #ffffff; + } + main { + max-width: var(--contract-max-width); + } + .surfaceTarget { + transition: border-color 120ms linear; + } + `, + "utf-8", + ); + + const surface = { + id: surfaceId, + displayName: "Remote Observation Surface", + type: "web", + requiredSections: ["main.hero"], + allowedFonts: ["Inter", "sans-serif", "var(--font-inter)"], + layout: { + maxContentWidth: 960, + ...(targetAcquisition + ? { + targetAcquisition: { + policy: "warn", + modality: "touch-mouse", + minHitAreaPx: 44, + minGapPx: 8, + minEdgeInsetPx: 8, + destructiveGapPx: 16, + }, + } + : {}), + }, + ...(feedbackRecovery + ? { + runtime: { + feedbackRecovery: { + policy: "warn", + requiredStateKinds: ["loading", "empty", "error", "success"], + }, + contexts: [ + { + id: "loading", + when: "request == pending", + kind: "loading", + blockedActionsWhilePending: ["submit-refresh"], + }, + { + id: "empty", + when: "items.length == 0", + kind: "empty", + }, + { + id: "error", + when: "request == failed", + kind: "error", + requiredRecoveryActions: ["retry"], + preserveSections: ["main.hero"], + preserveLastGoodContent: true, + }, + { + id: "success", + when: "request == fulfilled", + kind: "success", + }, + ], + }, + } + : {}), + ...(flows + ? { + flows: { + policy: "warn", + requirements: [ + { + flowId: "workspace-delete", + minSteps: 2, + requiredSteps: ["request", "review", "confirm"], + requiredTransitions: [ + { from: "request", to: "review" }, + { from: "review", to: "confirm" }, + ], + terminalSteps: ["confirm"], + }, + ], + }, + } + : {}), + }; + + await writeFile( + contractPath, + JSON.stringify( + { + contractId: "remote-observation-test", + version: "1.0.0", + surfaces: [surface], + sections: [ + { + id: "main.hero", + intent: "hero", + description: "Main hero section", + }, + ], + components: feedbackRecovery + ? [ + { + id: "dashboard-actions", + intent: "actions", + slots: [{ id: "actions", kind: "action", required: true }], + interactions: [ + { + id: "submit-refresh", + trigger: "click refresh", + effect: "submit", + }, + { + id: "retry-dashboard", + trigger: "click retry dashboard", + effect: "set-state", + }, + ], + }, + ] + : undefined, + constraints: { + motion: { + allowedDurationsMs: [120], + allowedTimingFunctions: ["linear"], + }, + }, + color: { + policy: "off", + allowedValues: [], + }, + }, + null, + 2, + ), + "utf-8", + ); + + return { tempRoot, contractPath }; +} + +function buildFixtureHtml(content) { + return ` + + + + + + +
${content}
+ + + `; +} + +function normalizeFindingCodes(payload) { + return Array.isArray(payload?.findings) + ? payload.findings + .map((entry) => String(entry?.code ?? "").trim()) + .filter(Boolean) + : []; +} + +async function assertRemoteObservationWarning(t, fixtureHtml, expectedCode, expectedFound) { + await ensureChromiumAvailable(t); + const { tempRoot, contractPath } = await createWorkspace(); + + try { + await withServer((request, response) => { + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(fixtureHtml); + }, async (origin) => { + const result = await runValidate(tempRoot, contractPath, origin); + assert.equal(result.exitCode, 0, result.stderr); + + const payload = JSON.parse(result.stdout); + const finding = payload.findings.find((entry) => entry.code === expectedCode); + assert.ok(finding, `missing ${expectedCode} finding: ${result.stdout}`); + assert.equal(finding.severity, "warning"); + assert.equal(finding.category, "E2"); + assert.equal(finding.location, origin + "/"); + assert.deepEqual(finding.found, expectedFound); + assert.equal( + payload.findings.some((entry) => entry.code === "target.unobservable"), + false, + `unexpected target.unobservable finding: ${result.stdout}`, + ); + }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +} + +async function assertRemoteObservationPass(t, fixtureHtml) { + await ensureChromiumAvailable(t); + const { tempRoot, contractPath } = await createWorkspace(); + + try { + await withServer((request, response) => { + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(fixtureHtml); + }, async (origin) => { + const result = await runValidate(tempRoot, contractPath, origin); + assert.equal(result.exitCode, 0, result.stderr); + + const payload = JSON.parse(result.stdout); + assert.equal(normalizeFindingCodes(payload).length, 0, result.stdout); + }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +} + +async function assertRemoteObservationUnobservable(t, fixtureHtml) { + await ensureChromiumAvailable(t); + const { tempRoot, contractPath } = await createWorkspace(); + + try { + await withServer((request, response) => { + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(fixtureHtml); + }, async (origin) => { + const result = await runValidate(tempRoot, contractPath, origin); + assert.equal(result.exitCode, 0, result.stderr); + + const payload = JSON.parse(result.stdout); + assert.deepEqual([...new Set(normalizeFindingCodes(payload))], ["target.unobservable"]); + const finding = payload.findings.find((entry) => entry.code === "target.unobservable"); + assert.ok(finding, `missing target.unobservable finding: ${result.stdout}`); + assert.equal(finding.severity, "warning"); + assert.equal(finding.category, "E2"); + assert.equal(finding.location, origin + "/"); + assert.deepEqual(finding.expected, ["contractScopedInteractiveTargets"]); + assert.deepEqual(finding.found, ["contractScopedInteractiveTargets"]); + }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +} + +async function assertRemoteFeedbackPass(t, fixtureHtml) { + await ensureChromiumAvailable(t); + const { tempRoot, contractPath } = await createWorkspace({ + targetAcquisition: false, + feedbackRecovery: true, + }); + + try { + await withServer((request, response) => { + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(fixtureHtml); + }, async (origin) => { + const result = await runValidate(tempRoot, contractPath, origin); + assert.equal(result.exitCode, 0, result.stderr); + + const payload = JSON.parse(result.stdout); + assert.equal(normalizeFindingCodes(payload).length, 0, result.stdout); + }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +} + +async function assertRemoteFeedbackWarning(t, fixtureHtml, expectedCode, expectedFound) { + await ensureChromiumAvailable(t); + const { tempRoot, contractPath } = await createWorkspace({ + targetAcquisition: false, + feedbackRecovery: true, + }); + + try { + await withServer((request, response) => { + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(fixtureHtml); + }, async (origin) => { + const result = await runValidate(tempRoot, contractPath, origin); + assert.equal(result.exitCode, 0, result.stderr); + + const payload = JSON.parse(result.stdout); + const finding = payload.findings.find((entry) => entry.code === expectedCode); + assert.ok(finding, `missing ${expectedCode} finding: ${result.stdout}`); + assert.equal(finding.severity, "warning"); + assert.equal(finding.category, "E2"); + assert.equal(finding.location, origin + "/"); + assert.deepEqual(finding.found, expectedFound); + assert.equal( + payload.findings.some((entry) => entry.code === "feedback.unobservable"), + false, + `unexpected feedback.unobservable finding: ${result.stdout}`, + ); + }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +} + +async function assertRemoteFeedbackUnobservable(t, fixtureHtml) { + await ensureChromiumAvailable(t); + const { tempRoot, contractPath } = await createWorkspace({ + targetAcquisition: false, + feedbackRecovery: true, + }); + + try { + await withServer((request, response) => { + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(fixtureHtml); + }, async (origin) => { + const result = await runValidate(tempRoot, contractPath, origin); + assert.equal(result.exitCode, 0, result.stderr); + + const payload = JSON.parse(result.stdout); + assert.deepEqual([...new Set(normalizeFindingCodes(payload))], ["feedback.unobservable"]); + const finding = payload.findings.find((entry) => entry.code === "feedback.unobservable"); + assert.ok(finding, `missing feedback.unobservable finding: ${result.stdout}`); + assert.equal(finding.severity, "warning"); + assert.equal(finding.category, "E2"); + assert.equal(finding.location, origin + "/"); + assert.deepEqual(finding.expected, ["contractScopedAsyncStates"]); + assert.deepEqual(finding.found, ["contractScopedAsyncStates"]); + }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +} + +async function assertRemoteFlowPass(t, fixtureHtml) { + await ensureChromiumAvailable(t); + const { tempRoot, contractPath } = await createWorkspace({ + targetAcquisition: false, + feedbackRecovery: false, + flows: true, + }); + + try { + await withServer((request, response) => { + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(fixtureHtml); + }, async (origin) => { + const result = await runValidate(tempRoot, contractPath, origin); + assert.equal(result.exitCode, 0, result.stderr); + + const payload = JSON.parse(result.stdout); + assert.equal(normalizeFindingCodes(payload).length, 0, result.stdout); + }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +} + +async function assertRemoteFlowWarning(t, fixtureHtml, expectedCode, expectedFound) { + await ensureChromiumAvailable(t); + const { tempRoot, contractPath } = await createWorkspace({ + targetAcquisition: false, + feedbackRecovery: false, + flows: true, + }); + + try { + await withServer((request, response) => { + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(fixtureHtml); + }, async (origin) => { + const result = await runValidate(tempRoot, contractPath, origin); + assert.equal(result.exitCode, 0, result.stderr); + + const payload = JSON.parse(result.stdout); + const finding = payload.findings.find((entry) => entry.code === expectedCode); + assert.ok(finding, `missing ${expectedCode} finding: ${result.stdout}`); + assert.equal(finding.severity, "warning"); + assert.equal(finding.category, "E2"); + assert.equal(finding.location, origin + "/"); + assert.deepEqual(finding.found, expectedFound); + assert.equal( + payload.findings.some((entry) => entry.code === "flow.unobservable"), + false, + `unexpected flow.unobservable finding: ${result.stdout}`, + ); + }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +} + +async function assertRemoteFlowUnobservable(t, fixtureHtml) { + await ensureChromiumAvailable(t); + const { tempRoot, contractPath } = await createWorkspace({ + targetAcquisition: false, + feedbackRecovery: false, + flows: true, + }); + + try { + await withServer((request, response) => { + response.setHeader("content-type", "text/html; charset=utf-8"); + response.end(fixtureHtml); + }, async (origin) => { + const result = await runValidate(tempRoot, contractPath, origin); + assert.equal(result.exitCode, 0, result.stderr); + + const payload = JSON.parse(result.stdout); + assert.deepEqual([...new Set(normalizeFindingCodes(payload))], ["flow.unobservable"]); + const finding = payload.findings.find((entry) => entry.code === "flow.unobservable"); + assert.ok(finding, `missing flow.unobservable finding: ${result.stdout}`); + assert.equal(finding.severity, "warning"); + assert.equal(finding.category, "E2"); + assert.equal(finding.location, origin + "/"); + assert.deepEqual(finding.expected, ["contractScopedFlows"]); + assert.deepEqual(finding.found, ["contractScopedFlows"]); + }); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +} + +test("validate: --remote-url reports target.hit-area-too-small from browser-observed controls", async (t) => { + await assertRemoteObservationWarning( + t, + buildFixtureHtml(` + + S + + + Safe + + `), + "target.hit-area-too-small", + { + width: 36, + height: 36, + targetId: "cta.small", + }, + ); +}); + +test("validate: --remote-url reports target.gap-too-tight from browser-observed controls", async (t) => { + await assertRemoteObservationWarning( + t, + buildFixtureHtml(` + + A + + + B + + `), + "target.gap-too-tight", + { + nearestNeighborGapPx: 4, + targetId: "cta.tight-a", + }, + ); +}); + +test("validate: --remote-url reports target.edge-inset-too-small from browser-observed controls", async (t) => { + await assertRemoteObservationWarning( + t, + buildFixtureHtml(` + + Safe + + + Edge + + `), + "target.edge-inset-too-small", + { + edgeInsetPx: 0, + targetId: "cta.edge-pinned", + }, + ); +}); + +test("validate: --remote-url passes a singleton contract-scoped target when hit area and edge inset are measurable", async (t) => { + await assertRemoteObservationPass( + t, + buildFixtureHtml(` + + Solo + + `), + ); +}); + +test("validate: --remote-url reports target.unobservable when only fallback all-visible controls are observed", async (t) => { + await assertRemoteObservationUnobservable( + t, + buildFixtureHtml(` + + A + + + `), + ); +}); + +test("validate: --remote-url reports target.destructive-too-close from browser-observed controls", async (t) => { + await assertRemoteObservationWarning( + t, + buildFixtureHtml(` + + + `), + "target.destructive-too-close", + { + nearestNeighborGapPx: 12, + targetId: "cta.destroy", + nearestNeighborClassification: "default", + }, + ); +}); + +test("validate: --remote-url passes a contract-scoped success async state", async (t) => { + await assertRemoteFeedbackPass( + t, + buildFixtureHtml(` +
+

Dashboard ready

+
+ `), + ); +}); + +test("validate: --remote-url passes a contract-scoped loading async state with blocked actions", async (t) => { + await assertRemoteFeedbackPass( + t, + buildFixtureHtml(` +
+

Loading dashboard…

+ +
+ `), + ); +}); + +test("validate: --remote-url passes a contract-scoped empty async state", async (t) => { + await assertRemoteFeedbackPass( + t, + buildFixtureHtml(` +
+

No results remain.

+
+ `), + ); +}); + +test("validate: --remote-url passes a contract-scoped error async state with recovery affordances", async (t) => { + await assertRemoteFeedbackPass( + t, + buildFixtureHtml(` +
+

Request failed.

+ +
+ `), + ); +}); + +test("validate: --remote-url reports feedback.recovery-action-missing from browser-observed async states", async (t) => { + await assertRemoteFeedbackWarning( + t, + buildFixtureHtml(` +
+

Request failed.

+
+ `), + "feedback.recovery-action-missing", + ["retry"], + ); +}); + +test("validate: --remote-url reports feedback.pending-action-not-blocked from browser-observed async states", async (t) => { + await assertRemoteFeedbackWarning( + t, + buildFixtureHtml(` +
+

Loading dashboard…

+ +
+ `), + "feedback.pending-action-not-blocked", + ["submit-refresh"], + ); +}); + +test("validate: --remote-url reports feedback.last-good-content-missing from browser-observed async states", async (t) => { + await assertRemoteFeedbackWarning( + t, + buildFixtureHtml(` +
+

Request failed.

+ +
+ `), + "feedback.last-good-content-missing", + { + preserveLastGoodContent: false, + missingPreserveSections: [], + }, + ); +}); + +test("validate: --remote-url reports feedback.unobservable when no contract-scoped async states are observed", async (t) => { + await assertRemoteFeedbackUnobservable( + t, + buildFixtureHtml(` +
+ +
+ `), + ); +}); + +test("validate: --remote-url passes when browser-observed flow markers satisfy the flow policy", async (t) => { + await assertRemoteFlowPass( + t, + buildFixtureHtml(` +
+
+ +
+
+ +
+
+

Confirm deletion

+
+
+ `), + ); +}); + +test("validate: --remote-url reports flow.steps.required from browser-observed flow markers", async (t) => { + await assertRemoteFlowWarning( + t, + buildFixtureHtml(` +
+
+ +
+
+

Review copy without flow markers.

+
+
+

Confirm deletion

+
+
+ `), + "flow.steps.required", + ["review"], + ); +}); + +test("validate: --remote-url reports flow.transition.required from browser-observed flow markers", async (t) => { + await assertRemoteFlowWarning( + t, + buildFixtureHtml(` +
+
+ +
+
+

Review without confirm transition.

+
+
+

Confirm deletion

+
+
+ `), + "flow.transition.required", + [{ from: "review", to: "confirm" }], + ); +}); + +test("validate: --remote-url reports flow.terminal.invalid from browser-observed flow markers", async (t) => { + await assertRemoteFlowWarning( + t, + buildFixtureHtml(` +
+
+ +
+
+ +
+
+ +
+
+ `), + "flow.terminal.invalid", + [{ from: "confirm", to: "request" }], + ); +}); + +test("validate: --remote-url reports flow.unobservable when no contract-scoped flow markers are observed", async (t) => { + await assertRemoteFlowUnobservable( + t, + buildFixtureHtml(` +
+
+

Visible staged content without flow markers.

+
+
+ `), + ); +}); diff --git a/packages/interfacectl-cli/test/violation-classifier.test.mjs b/packages/interfacectl-cli/test/violation-classifier.test.mjs index 658267b..bbe6f3f 100644 --- a/packages/interfacectl-cli/test/violation-classifier.test.mjs +++ b/packages/interfacectl-cli/test/violation-classifier.test.mjs @@ -30,6 +30,7 @@ test("classifyViolationType: E2 violations", () => { assert.equal(classifyViolationType("flow-steps-required"), "E2"); assert.equal(classifyViolationType("flow-transition-required"), "E2"); assert.equal(classifyViolationType("flow-terminal-invalid"), "E2"); + assert.equal(classifyViolationType("flow-unobservable"), "E2"); }); test("getExitCodeForCategory: v2 exit codes", () => {