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 Portable interaction reference Theme Preference system light dark Copy npm install -g @opencoven/cli Copy Views Overview Evidence
Overview Visible without JavaScript.
Evidence Also visible without JavaScript.
Help Essential explanation is already visible. More
Supplemental detail. Status ✓ Verified from a pinned revision
Progress Verification 2 of 3 Dialog Read inline or open modal Modal reference A true modal contains and restores focus.
Close 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.`,
+);