diff --git a/.prettierignore b/.prettierignore index cd48a03..2178fb9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,4 @@ registry.json pnpm-lock.yaml index.html Components.dc.html +contracts diff --git a/contracts/CHANGELOG.md b/contracts/CHANGELOG.md new file mode 100644 index 0000000..6924119 --- /dev/null +++ b/contracts/CHANGELOG.md @@ -0,0 +1,8 @@ +# Portable interaction contract changelog + +## 1.0.0 — 2026-08-30 + +- Added 14 framework-neutral web primitives. +- Added stable conformance hooks and shared state vocabulary. +- Added 24 mutation-guarded keyboard, focus, no-JavaScript, failure, reduced-motion, forced-colors, target-size, and reflow vectors. +- Added a native-first reference fixture. diff --git a/contracts/README.md b/contracts/README.md new file mode 100644 index 0000000..929ec70 --- /dev/null +++ b/contracts/README.md @@ -0,0 +1,19 @@ +# Portable web interaction contract v1.0.0 + +`web-interactions.v1.json` is the framework-neutral semantic and interaction contract owned by `OpenCoven/ui`. `test-vectors.v1.json` is the shared conformance suite. `fixtures/reference.html` demonstrates the native-first baseline without importing React, Tailwind, Base UI, or the specimen app. + +Visual identity and token values remain owned by `OpenCoven/brand`. Product state, authority, protocol, and runtime behavior remain owned by the corresponding product repositories. + +## Stable hooks + +Consumers expose `data-oc-primitive`, `data-oc-part`, and optional `data-oc-state` hooks named by the contract. These are testing semantics, not a styling API, and never replace native attributes such as `href`, `open`, `disabled`, `aria-expanded`, `aria-selected`, or `aria-busy`. + +## Conformance + +Run every applicable vector with JavaScript enabled and disabled, keyboard-only input, 320 px width, 200% zoom/reflow, reduced motion, and forced colors where supported. Each vector returns `pass`, `fail`, or `unsupported-with-reason`; unsupported is never counted as pass. + +Ordinary navigation is not an application menu. Mobile navigation is not a modal. Tabs do not perform remote mutation. Tooltips contain no essential or interactive content. Progress is measured, not decorative. Download links remain browser-native; page JavaScript never owns installer bytes. Dialogs are reserved for true modal tasks. Guided proof remains semantic and complete without canvas, WebGL, drag, or motion. + +## Versioning + +Patch changes clarify wording without changing behavior. Minor changes add backward-compatible primitives, parts, states, or vectors. Major changes remove/rename hooks or alter required keyboard, focus, failure, or progressive-enhancement behavior. Consumers pin an immutable revision and run the shared vectors before updating. diff --git a/contracts/fixtures/reference.html b/contracts/fixtures/reference.html new file mode 100644 index 0000000..36b8929 --- /dev/null +++ b/contracts/fixtures/reference.html @@ -0,0 +1 @@ +OpenCoven portable interaction reference
OpenCoven
Menu

Portable interaction reference

Actions

Start locally Understand the protocol

Theme

Copy

npm install -g @opencoven/cli

Views

Overview

Visible without JavaScript.

Evidence

Also visible without JavaScript.

Help

Essential explanation is already visible.

Status

✓ Verified from a pinned revision

Progress

2 of 3

Download

macOS Windows Linux

All release artifacts

Identity → authority → execution

Inspect a state

All three states remain below.

Identity bound

A familiar revision is named.

Authority checked

Protected change is permitted, degraded, or rejected.

Execution receipted

The runtime records what happened.

Dialog

Read inline or

Modal reference

A true modal contains and restores focus.

Inline fallback

Available without JavaScript.

Evidence unavailable

No verified value was returned, so no zero is shown.

Inspect source

diff --git a/contracts/fixtures/reference.js b/contracts/fixtures/reference.js new file mode 100644 index 0000000..50864e9 --- /dev/null +++ b/contracts/fixtures/reference.js @@ -0,0 +1,11 @@ +document.documentElement.classList.add("enhanced"); +const mobile=document.querySelector('[data-oc-primitive="mobile-navigation"]'); +if(mobile instanceof HTMLDetailsElement){const trigger=mobile.querySelector('[data-oc-part="trigger"]');const sync=()=>mobile.dataset.ocState=mobile.open?"expanded":"collapsed";mobile.addEventListener("toggle",sync);mobile.addEventListener("keydown",event=>{if(event.key==="Escape"&&mobile.open){mobile.open=false;trigger?.focus()}});document.addEventListener("pointerdown",event=>{if(mobile.open&&!mobile.contains(event.target))mobile.open=false});sync()} +const theme=document.querySelector('[data-oc-primitive="theme-control"] select'); +if(theme instanceof HTMLSelectElement){const allowed=new Set(["system","light","dark"]);const stored=localStorage.getItem("oc-theme");theme.value=allowed.has(stored)?stored:"system";const apply=()=>{const value=allowed.has(theme.value)?theme.value:"system";document.documentElement.dataset.theme=value;localStorage.setItem("oc-theme",value)};theme.addEventListener("change",apply);apply()} +const copy=document.querySelector('[data-oc-primitive="copy-control"]'); +if(copy){const source=copy.querySelector('[data-oc-part="source"]');const trigger=copy.querySelector('[data-oc-part="trigger"]');const status=copy.querySelector('[data-oc-part="status"]');if(trigger instanceof HTMLButtonElement&&source&&status){trigger.hidden=false;trigger.addEventListener("click",async()=>{trigger.disabled=true;try{await navigator.clipboard.writeText(source.textContent??"");trigger.textContent="Copied";status.textContent="Command copied.";setTimeout(()=>{trigger.textContent="Copy";status.textContent="";trigger.disabled=false},2500)}catch{trigger.textContent="Copy";status.textContent="Could not copy. Select manually.";trigger.disabled=false}})}} +for(const tabs of document.querySelectorAll('[data-oc-primitive="tabs"]')){const list=[...tabs.querySelectorAll('[role="tab"]')];const panels=[...tabs.querySelectorAll('[role="tabpanel"]')];const select=(next,focus=true)=>{for(const tab of list){const selected=tab===next;tab.setAttribute("aria-selected",String(selected));tab.tabIndex=selected?0:-1;const panel=panels.find(item=>item.id===tab.getAttribute("aria-controls"));if(panel)panel.hidden=!selected}if(focus)next.focus()};list.forEach((tab,index)=>{tab.addEventListener("click",()=>select(tab,false));tab.addEventListener("keydown",event=>{let next=null;if(event.key==="ArrowRight")next=(index+1)%list.length;if(event.key==="ArrowLeft")next=(index-1+list.length)%list.length;if(event.key==="Home")next=0;if(event.key==="End")next=list.length-1;if(next!==null){event.preventDefault();select(list[next])}})});select(list.find(tab=>tab.getAttribute("aria-selected")==="true")??list[0],false)} +for(const surface of document.querySelectorAll('[data-oc-primitive="tooltip"]')){const trigger=surface.querySelector('[data-oc-part="trigger"]');const content=surface.querySelector('[data-oc-part="content"]');if(!trigger||!content)continue;const show=()=>content.hidden=false;const hide=()=>content.hidden=true;trigger.addEventListener("focus",show);trigger.addEventListener("blur",hide);trigger.addEventListener("pointerenter",show);trigger.addEventListener("pointerleave",hide);trigger.addEventListener("keydown",event=>{if(event.key==="Escape")hide()})} +const dialog=document.querySelector('[data-oc-primitive="dialog"]');const open=document.querySelector("[data-dialog-open]"); +if(dialog instanceof HTMLDialogElement&&open instanceof HTMLButtonElement){const title=dialog.querySelector('[data-oc-part="title"]');const close=dialog.querySelector('[data-oc-part="close"]');let opener=null;open.addEventListener("click",()=>{opener=open;dialog.showModal();title?.focus()});close?.addEventListener("click",()=>dialog.close());dialog.addEventListener("close",()=>opener?.focus())} diff --git a/contracts/test-vectors.v1.json b/contracts/test-vectors.v1.json new file mode 100644 index 0000000..e5263a2 --- /dev/null +++ b/contracts/test-vectors.v1.json @@ -0,0 +1 @@ +{"schemaVersion":"opencoven.ui-test-vectors/v1","contractVersion":"1.0.0","result":"pass|fail|unsupported-with-reason; unsupported is never pass","vectors":[{"id":"NAV-001","primitive":"global-navigation","modes":["js","no-js"],"steps":["inspect landmarks/links"],"assertions":["one named global nav","brand and Start locally are links","one aria-current when represented","no duplicate focusable nav"],"mutationGuard":"remove label or expose duplicate"},{"id":"NAV-002","primitive":"global-navigation","modes":["js","no-js"],"steps":["Tab forward/back","Enter links"],"assertions":["visible focus","reading order","focus exits both directions","href followed"],"mutationGuard":"preventDefault Tab"},{"id":"MOB-001","primitive":"mobile-navigation","modes":["js"],"steps":["open","Tab inside","Escape"],"assertions":["44×44 trigger","open state synced","not modal/inert","Escape closes and restores trigger"],"mutationGuard":"leave expanded or lose focus"},{"id":"MOB-002","primitive":"mobile-navigation","modes":["no-js"],"steps":["open details if used","traverse links"],"assertions":["all destinations present","Start locally present","no script overlay"],"mutationGuard":"hide panel pending JS"},{"id":"MOB-003","primitive":"mobile-navigation","modes":["js"],"steps":["open","outside click","reopen","activate link"],"assertions":["outside closes","inside link navigates once"],"mutationGuard":"backdrop swallows link"},{"id":"ACT-001","primitive":"action","modes":["js","no-js","320px"],"steps":["inspect/activate each"],"assertions":["a for navigation","button for operation","44×44 targets","one filled action"],"mutationGuard":"clickable div or two filled actions"},{"id":"ACT-002","primitive":"action","modes":["js"],"steps":["activate async twice"],"assertions":["one operation","name retained","busy exposed","visible result/recovery"],"mutationGuard":"duplicate request or blank busy label"},{"id":"THEME-001","primitive":"theme-control","modes":["js"],"steps":["select light/dark/system","reload","change OS"],"assertions":["only three stored values","explicit survives","system tracks OS","pre-paint state matches preference"],"mutationGuard":"two-state toggle"},{"id":"THEME-002","primitive":"theme-control","modes":["no-js","forced-colors"],"steps":["load light/dark/high contrast"],"assertions":["system remains legible","content/navigation complete"],"mutationGuard":"hard-code unusable theme"},{"id":"COPY-001","primitive":"copy-control","modes":["js"],"steps":["grant clipboard","activate"],"assertions":["source equals copy","focus retained","Copied visible 2–4s","one polite announcement"],"mutationGuard":"move focus to toast"},{"id":"COPY-002","primitive":"copy-control","modes":["js","no-js"],"steps":["reject clipboard or disable JS"],"assertions":["failure wording","source selectable","no false success","static source visible"],"mutationGuard":"hide source"},{"id":"TABS-001","primitive":"tabs","modes":["js"],"steps":["ArrowRight","End","Home","Tab"],"assertions":["one selected/tabindex0","keys select correctly","matching panel","Tab exits widget"],"mutationGuard":"all tabs tabindex0"},{"id":"TABS-002","primitive":"tabs","modes":["no-js"],"steps":["inspect source order"],"assertions":["every panel visible","each panel labelled"],"mutationGuard":"ship inactive panels hidden"},{"id":"TIP-001","primitive":"tooltip","modes":["js"],"steps":["focus/Escape","hover/leave"],"assertions":["same supplement","tooltip not focusable","control already named","essential text outside"],"mutationGuard":"put link inside tooltip"},{"id":"STATUS-001","primitive":"status-indicator","modes":["js","no-js","forced-colors"],"steps":["inspect static/dynamic"],"assertions":["visible wording/non-color cue","routine update polite once","unavailable not zero"],"mutationGuard":"colored dot only"},{"id":"PROGRESS-001","primitive":"progress","modes":["js","reduced-motion"],"steps":["inspect determinate/indeterminate/unavailable"],"assertions":["valid measured value","busy wording","no fabricated percent","motion not required"],"mutationGuard":"hard-code fake 73%"},{"id":"DOWN-001","primitive":"download-chooser","modes":["js","no-js"],"steps":["traverse artifacts/fallback"],"assertions":["ordinary hrefs","all alternatives","activation required","page does not fetch bytes"],"mutationGuard":"fetch/blob installer"},{"id":"DOWN-002","primitive":"download-chooser","modes":["js"],"steps":["fail resolver/stream"],"assertions":["direct release fallback or error surface","no endless retry"],"mutationGuard":"swallow failure"},{"id":"DIALOG-001","primitive":"dialog","modes":["js"],"steps":["open","Tab cycle","Escape"],"assertions":["labelled","initial focus inside","focus contained","background inert","focus restored"],"mutationGuard":"restore to body"},{"id":"DIALOG-002","primitive":"dialog","modes":["no-js"],"steps":["inspect fallback"],"assertions":["content inline or ordinary link","nothing essential modal-only"],"mutationGuard":"hide all dialog content"},{"id":"PROOF-001","primitive":"guided-proof","modes":["js","reduced-motion","320px"],"steps":["select three states"],"assertions":["three named states","non-color selection","labelled evidence","no remote mutation","no WebGL requirement"],"mutationGuard":"canvas-only meaning"},{"id":"PROOF-002","primitive":"guided-proof","modes":["no-js"],"steps":["read sequence"],"assertions":["all three visible","headings/evidence for each"],"mutationGuard":"render first only"},{"id":"ERROR-001","primitive":"error-surface","modes":["js","no-js"],"steps":["render busy/error/unavailable","traverse recovery"],"assertions":["heading/description","fallback/next step","routine update keeps focus","recovery follows action"],"mutationGuard":"blank spinner"},{"id":"REFLOW-001","primitive":"action","modes":["320px","200%-zoom","forced-colors"],"steps":["traverse all controls"],"assertions":["44×44 targets","no overlap","no two-dimensional scroll","focus not obscured","system focus visible"],"mutationGuard":"shrink or hide focused target"}]} diff --git a/contracts/web-interactions.v1.json b/contracts/web-interactions.v1.json new file mode 100644 index 0000000..b8df2fe --- /dev/null +++ b/contracts/web-interactions.v1.json @@ -0,0 +1 @@ +{"schemaVersion":"opencoven.ui-web-interactions/v1","contractVersion":"1.0.0","status":"stable","owner":"OpenCoven/ui","scope":"Framework-neutral web semantics and interaction behavior; brand values and product behavior are out of scope.","hooks":{"primitive":"data-oc-primitive","part":"data-oc-part","state":"data-oc-state"},"global":{"nativeFirst":true,"staticFirst":true,"oneFilledAction":true,"targetMinimumCssPx":[44,44],"visibleFocus":true,"nonColorState":true,"modalOnlyFocusTrap":true,"unsupportedIsNotPass":true,"modes":["js","no-js","reduced-motion","forced-colors","320px","200%-zoom"],"states":["idle","hover","focus","pressed","selected","expanded","collapsed","busy","success","error","disabled","unavailable"]},"primitives":{"action":{"selector":"[data-oc-primitive=\"action\"]","semantics":["a[href]=navigation","button=operation"],"parts":["label"],"states":["idle","focus","pressed","busy","success","error","disabled"],"keyboard":["Tab","Enter","Space(button)"],"focus":"natural; never trapped; stays on trigger after async result","noJavaScript":"links work; enhanced-only buttons omitted or have fallback","failure":"visible success/error and recovery; no duplicate activation","forbidden":["clickable div","disabled href","more than one filled action per decision"]},"disclosure":{"selector":"[data-oc-primitive=\"disclosure\"]","semantics":["details/summary or button[aria-expanded][aria-controls]"],"parts":["trigger","panel"],"states":["collapsed","expanded","focus"],"keyboard":["Enter","Space","Escape(enhanced)","Tab"],"focus":"trigger owns open; no trap; Escape restores trigger","noJavaScript":"details works or panel stays visible","failure":"state/ARIA stay synchronized","forbidden":["role=menu for site content","arrow-only use"]},"global-navigation":{"selector":"[data-oc-primitive=\"global-navigation\"]","semantics":["header","nav[aria-label]","ordinary links","aria-current=page"],"parts":["brand-link","primary-links","primary-action"],"states":["idle","focus"],"keyboard":["Tab","Shift+Tab","Enter"],"focus":"reading order; exits both directions","noJavaScript":"all destinations and Start locally are links","failure":"no duplicate hidden nav is focusable","forbidden":["application-menu semantics"]},"mobile-navigation":{"selector":"[data-oc-primitive=\"mobile-navigation\"]","semantics":["details/summary preferred; otherwise disclosure"],"parts":["trigger","panel","links"],"states":["collapsed","expanded","focus"],"keyboard":["Enter","Space","Escape","Tab"],"focus":"not modal; no trap; Escape restores trigger","noJavaScript":"all links remain reachable","failure":"outside click may close; inside link still navigates","forbidden":["dialog semantics","body lock"]},"tabs":{"selector":"[data-oc-primitive=\"tabs\"]","semantics":["tablist","button[role=tab]","tabpanel[aria-labelledby]"],"parts":["list","tab","panel"],"states":["idle","selected","focus","disabled"],"keyboard":["Arrows","Home","End","Enter/Space if manual","Tab"],"focus":"one tab tabindex=0; roving only; no trap","noJavaScript":"all panels visible with headings","failure":"selected tab/panel/ARIA stay synchronized","forbidden":["sequential steps","remote mutation"]},"tooltip":{"selector":"[data-oc-primitive=\"tooltip\"]","semantics":["named focusable trigger","role=tooltip","aria-describedby"],"parts":["trigger","content"],"states":["collapsed","expanded"],"keyboard":["Focus","Escape","Blur"],"focus":"tooltip never receives focus","noJavaScript":"essential explanation is adjacent","failure":"same supplement on focus and hover","forbidden":["interactive content","sole accessible name"]},"status-indicator":{"selector":"[data-oc-primitive=\"status-indicator\"]","semantics":["text; role=status only for meaningful async change"],"parts":["label"],"states":["busy","success","error","unavailable","selected"],"keyboard":[],"focus":"not focusable unless also action","noJavaScript":"wording remains visible","failure":"polite atomic announcement once; unavailable is not zero","forbidden":["color-only dot","routine role=alert"]},"progress":{"selector":"[data-oc-primitive=\"progress\"]","semantics":["progress[value][max] or labelled progressbar; aria-busy for indeterminate"],"parts":["label","value-or-state"],"states":["busy","success","error","unavailable"],"keyboard":[],"focus":"not focusable unless paired with action","noJavaScript":"last verified or explicit unavailable","failure":"no fabricated percent; wording survives reduced motion","forbidden":["decorative/fake progress"]},"copy-control":{"selector":"[data-oc-primitive=\"copy-control\"]","semantics":["button + visible source + polite atomic status"],"parts":["source","trigger","status"],"states":["idle","busy","success","error"],"keyboard":["Enter","Space"],"focus":"stays on trigger","noJavaScript":"source visible/selectable; button optional","failure":"Copied 2–4s or Could not copy; source retained","forbidden":["hidden source","copying secrets"]},"download-chooser":{"selector":"[data-oc-primitive=\"download-chooser\"]","semantics":["ordinary artifact links; optional native recommendation control"],"parts":["artifact-links","fallback"],"states":["idle","selected","error","unavailable"],"keyboard":["Tab","Enter","native select/radio"],"focus":"no trap","noJavaScript":"all artifacts and releases fallback visible","failure":"resolver failure reaches direct fallback; page JS never owns installer bytes","forbidden":["fetch/blob installer","auto-download","hidden alternatives"]},"theme-control":{"selector":"[data-oc-primitive=\"theme-control\"]","semantics":["fieldset/radios or select with system/light/dark"],"parts":["label","control"],"states":["selected","focus"],"keyboard":["native control keys"],"focus":"natural","noJavaScript":"system remains valid","failure":"store only system/light/dark; pre-paint apply; system tracks OS","forbidden":["ambiguous two-state toggle","animated page flash"]},"dialog":{"selector":"[data-oc-primitive=\"dialog\"]","semantics":["dialog.showModal; aria-modal only for true fallback"],"parts":["title","content","close"],"states":["collapsed","expanded","focus"],"keyboard":["Enter/Space(open)","Tab/Shift+Tab(contained)","Escape"],"focus":"initial inside; trap only while modal; every close restores opener","noJavaScript":"content inline or normal destination","failure":"background inert; labelled; close always available","forbidden":["mobile nav dialog","nested modal","lost opener"]},"guided-proof":{"selector":"[data-oc-primitive=\"guided-proof\"]","semantics":["tabs or native radios + semantic static states"],"parts":["controls","state-label","evidence-region"],"states":["selected","focus","unavailable"],"keyboard":["native tabs/radios"],"focus":"no trap","noJavaScript":"all three states sequentially readable","failure":"deterministic; meaning independent of canvas/WebGL/motion","forbidden":["fake app","drag-only 3D"]},"error-surface":{"selector":"[data-oc-primitive=\"error-surface\"]","semantics":["section/aside heading; alert only urgent new error; status for routine async"],"parts":["title","description","recovery-or-fallback"],"states":["busy","success","error","unavailable"],"keyboard":["normal action keys"],"focus":"routine update never steals focus","noJavaScript":"fallback remains","failure":"recovery/next step visible; no endless retry","forbidden":["blank spinner","alert for every load"]}}} diff --git a/eslint.config.js b/eslint.config.js index 64553a8..9368801 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,12 @@ export default defineConfig([ globals: globals.node, }, }, + { + files: ["contracts/fixtures/**/*.js"], + languageOptions: { + globals: globals.browser, + }, + }, { files: ["**/*.{ts,tsx}"], extends: [ diff --git a/scripts/verify-contracts.mjs b/scripts/verify-contracts.mjs index 4b19463..87d495c 100644 --- a/scripts/verify-contracts.mjs +++ b/scripts/verify-contracts.mjs @@ -12,6 +12,11 @@ const [ button, tooltip, menu, + portableJson, + vectorsJson, + fixture, + fixtureScript, + portableDocs, ] = await Promise.all([ read("components.json"), read("packages/ui/package.json"), @@ -20,10 +25,17 @@ const [ read("packages/ui/src/components/ui/button.tsx"), read("packages/ui/src/components/ui/tooltip.tsx"), read("packages/ui/src/components/ui/dropdown-menu.tsx"), + read("contracts/web-interactions.v1.json"), + read("contracts/test-vectors.v1.json"), + read("contracts/fixtures/reference.html"), + read("contracts/fixtures/reference.js"), + read("contracts/README.md"), ]); const config = JSON.parse(componentsJson); const manifest = JSON.parse(packageJson); +const portable = JSON.parse(portableJson); +const vectors = JSON.parse(vectorsJson); const assertions = [ ["style is base-nova", config.style === "base-nova"], ["base color is zinc", config.tailwind.baseColor === "zinc"], @@ -91,11 +103,180 @@ const assertions = [ ], ]; -const failed = assertions.filter(([, passed]) => !passed); +const requiredPrimitives = [ + "action", + "disclosure", + "global-navigation", + "mobile-navigation", + "tabs", + "tooltip", + "status-indicator", + "progress", + "copy-control", + "download-chooser", + "theme-control", + "dialog", + "guided-proof", + "error-surface", +]; +const portableFailures = []; +const requirePortable = (condition, message) => { + if (!condition) portableFailures.push(message); +}; + +requirePortable( + portable.schemaVersion === "opencoven.ui-web-interactions/v1", + "portable schemaVersion must be v1", +); +requirePortable( + vectors.schemaVersion === "opencoven.ui-test-vectors/v1", + "vector schemaVersion must be v1", +); +requirePortable( + portable.contractVersion === vectors.contractVersion && + /^\d+\.\d+\.\d+$/.test(portable.contractVersion), + "portable contract and vector semver must match", +); +requirePortable( + portable.owner === "OpenCoven/ui", + "portable owner must be OpenCoven/ui", +); +for (const law of [ + "nativeFirst", + "staticFirst", + "oneFilledAction", + "visibleFocus", + "nonColorState", + "modalOnlyFocusTrap", + "unsupportedIsNotPass", +]) { + requirePortable( + portable.global?.[law] === true, + `missing global law: ${law}`, + ); +} +requirePortable( + portable.global?.targetMinimumCssPx?.every((value) => value >= 44), + "target minimum must be 44×44 CSS px", +); + +const allowedStates = new Set(portable.global?.states ?? []); +for (const id of requiredPrimitives) { + const primitive = portable.primitives?.[id]; + requirePortable(Boolean(primitive), `missing primitive: ${id}`); + if (!primitive) continue; + requirePortable( + primitive.selector === `[data-oc-primitive="${id}"]`, + `${id} selector drifted`, + ); + for (const field of [ + "semantics", + "parts", + "states", + "keyboard", + "focus", + "noJavaScript", + "failure", + "forbidden", + ]) { + requirePortable(primitive[field] !== undefined, `${id} missing ${field}`); + } + for (const state of primitive.states ?? []) { + requirePortable( + allowedStates.has(state), + `${id} has unknown state ${state}`, + ); + } +} + +const vectorIds = new Set(); +const covered = new Set(); +for (const vector of vectors.vectors ?? []) { + requirePortable(!vectorIds.has(vector.id), `duplicate vector ${vector.id}`); + vectorIds.add(vector.id); + covered.add(vector.primitive); + requirePortable( + Boolean(portable.primitives?.[vector.primitive]), + `${vector.id} has unknown primitive`, + ); + for (const field of ["modes", "steps", "assertions", "mutationGuard"]) { + requirePortable( + vector[field] !== undefined, + `${vector.id} missing ${field}`, + ); + } +} +requirePortable( + vectorIds.size >= 24, + "at least 24 shared vectors are required", +); +for (const id of requiredPrimitives.filter((id) => id !== "disclosure")) { + requirePortable(covered.has(id), `no vector covers ${id}`); +} +for (const mode of [ + "no-js", + "reduced-motion", + "forced-colors", + "320px", + "200%-zoom", +]) { + requirePortable( + vectors.vectors.some((vector) => vector.modes.includes(mode)), + `no vector covers ${mode}`, + ); +} + +for (const id of requiredPrimitives.filter((id) => id !== "disclosure")) { + requirePortable( + fixture.includes(`data-oc-primitive="${id}"`), + `reference fixture is missing ${id}`, + ); +} +requirePortable( + fixture.includes(" !passed).map(([name]) => name), + ...portableFailures, +]; if (failed.length > 0) { throw new Error( - `Contract verification failed:\n${failed.map(([name]) => `- ${name}`).join("\n")}`, + `Contract verification failed:\n${failed.map((name) => `- ${name}`).join("\n")}`, ); } -console.log(`Verified ${assertions.length} architecture and design contracts.`); +console.log( + `Verified ${assertions.length} architecture contracts and ${requiredPrimitives.length} portable primitives across ${vectorIds.size} shared vectors.`, +);