Setting the file. One moment. Generate Control State Contract · Wix Headless Replatform · wix/skills · Skills Docs28.10
Workflow
Script Finalize Migration Website
scripts/generate-control-state-contract.mjs
JavaScript·169 lines·9 KB
contract
=
await
generateControlStateContract
({ outputDir });
9 if (args.json) process.stdout.write(`${JSON.stringify(contract, null, 2)}\n`);
10}
11
12export async function generateControlStateContract({ outputDir, interactionMap: suppliedInteractionMap } = {}) {
13 const docs = docsDir(outputDir);
14 const interactionMap = suppliedInteractionMap || await readJson(path.join(docs, "interaction-map.json"));
15 const controls = new Map();
16 for (const interaction of interactionMap.interactions || []) {
17 const trigger = typeof interaction.trigger === "string" ? interaction.trigger : interaction.trigger?.type;
18 if (!interaction.controlRole && !["hover", "focus", "press", "click"].includes(trigger)) continue;
19 const key = `${interaction.controlScope || "content"}\u0000${interaction.controlRole || "control"}\u0000${interaction.controlId || interaction.label || interaction.id}`;
20 const control = controls.get(key) || {
21 id: `control-${String(controls.size + 1).padStart(3, "0")}`,
22 label: interaction.label || "Unlabelled control",
23 role: interaction.controlRole || "control",
24 scope: interaction.controlScope || "content",
25 sourceInteractionIds: [],
26 states: {},
27 transition: null,
28 ownerTransitions: [],
29 iconMotion: [],
30 };
31 control.sourceInteractionIds.push(interaction.id);
32 const states = interaction.states || [];
33 if (!control.states.rest && states[0]) control.states.rest = { values: baseControlValues(states[0]), sourceObserved: true };
34 const stateName = trigger === "focus" ? "focus-visible" : trigger === "press" ? "pressed" : trigger === "click" ? "activated" : trigger;
35 if (stateName && states[1]) {
36 control.states[stateName] = {
37 values: compactChangedValues(states[1], interaction.changedProperties || []),
38 changedProperties: interaction.changedProperties || [],
39 sourceObserved: (interaction.changedProperties || []).length > 0 || interaction.textChanged,
40 };
41 }
42 const rest = states[0] || {};
43 if (!control.states.current && (rest.ariaCurrent || /(?:^|\s)(active|current|selected)(?:\s|$)/i.test(rest.className || ""))) {
44 control.states.current = { values: baseControlValues(rest), sourceObserved: true };
45 }
46 if (!control.states.disabled && rest.disabled) control.states.disabled = { values: baseControlValues(rest), sourceObserved: true };
47 if (!control.transition && states[0]?.styles) {
48 control.transition = {
49 property: states[0].styles.transitionProperty || "all",
50 duration: states[0].styles.transitionDuration || "0s",
51 easing: states[0].styles.transitionTimingFunction || "ease",
52 };
53 }
54 for (const child of states[0]?.visualChildren || []) {
55 const duration = child.styles?.transitionDuration || "0s";
56 if (duration === "0s" || control.ownerTransitions.some((item) => item.owner === `child:${child.key}`)) continue;
57 control.ownerTransitions.push({ owner: `child:${child.key}`, property: child.styles?.transitionProperty || "all", duration, easing: child.styles?.transitionTimingFunction || "ease" });
58 }
59 for (const pseudo of ["before", "after"]) {
60 const duration = states[0]?.pseudo?.[pseudo]?.transitionDuration || "0s";
61 if (duration !== "0s" && !control.ownerTransitions.some((item) => item.owner === `::${pseudo}`)) {
62 control.ownerTransitions.push({ owner: `::${pseudo}`, property: states[0].pseudo[pseudo].transitionProperty || "all", duration, easing: states[0].pseudo[pseudo].transitionTimingFunction || "ease" });
63 }
64 }
65 for (const property of interaction.changedProperties || []) {
66 if (/^child:|^::/.test(property) && /transform|translate|scale|rotate|top|left|right|bottom/.test(property)) {
67 if (!control.iconMotion.includes(property)) control.iconMotion.push(property);
68 }
69 }
70 controls.set(key, control);
71 }
72 const compiledControls = mergeEquivalentControls(Array.from(controls.values()));
73 const contract = {
74 schemaVersion: 1,
75 generatedAt: new Date().toISOString(),
76 sourceUrl: interactionMap.sourceUrl || "",
77 terminology: {
78 rest: "Pointer absent and control not focused or pressed.",
79 hover: "Pointer is over the control.",
80 "focus-visible": "Keyboard-visible focus state; must remain perceivable even when absent from the source.",
81 pressed: "Pointer or key is held down; CSS :active equivalent.",
82 activated: "State immediately after activation/click.",
83 current: "Current route, tab, or selected control.",
84 disabled: "Unavailable control.",
85 },
86 precedence: ["source-observed-state", "accessible-focus-fallback", "identity-preserving-normalization"],
87 controls: compiledControls,
88 requirements: [
89 "Implement every sourceObserved state delta on the target, pseudo-element, or child that owns it.",
90 "Do not apply an icon transform to the whole button when only the nested icon moved in source evidence.",
91 "Preserve source transition duration and easing; otherwise use a subtle eased transition and respect prefers-reduced-motion.",
92 "Add a perceivable focus-visible state when the source exposes none, without replacing captured hover/current styling.",
93 ],
94 };
95 await writeJson(path.join(docs, "control-state-contract.json"), contract);
96 await writeText(path.join(docs, "control-state-contract.md"), renderMarkdown(contract));
97 return contract;
98}
99
100function mergeEquivalentControls(controls) {
101 const groups = new Map();
102 for (const control of controls) {
103 const signature = JSON.stringify({ role: control.role, scope: control.scope, states: control.states, transition: control.transition, ownerTransitions: control.ownerTransitions, iconMotion: control.iconMotion });
104 const existing = groups.get(signature);
105 if (!existing) {
106 groups.set(signature, { ...control, members: [control.label] });
107 continue;
108 }
109 if (!existing.members.includes(control.label)) existing.members.push(control.label);
110 existing.sourceInteractionIds.push(...control.sourceInteractionIds);
111 }
112 return Array.from(groups.values()).map((control, index) => ({
113 ...control,
114 id: `control-${String(index + 1).padStart(3, "0")}`,
115 sourceInteractionIds: Array.from(new Set(control.sourceInteractionIds)),
116 }));
117}
118
119function baseControlValues(snapshot = {}) {
120 const style = snapshot.styles || {};
121 return {
122 color: style.color,
123 backgroundColor: style.backgroundColor,
124 borderColor: style.borderColor,
125 borderWidth: [style.borderTopWidth, style.borderRightWidth, style.borderBottomWidth, style.borderLeftWidth],
126 textDecoration: {
127 line: style.textDecorationLine,
128 color: style.textDecorationColor,
129 thickness: style.textDecorationThickness,
130 offset: style.textUnderlineOffset,
131 },
132 outline: {
133 color: style.outlineColor,
134 style: style.outlineStyle,
135 width: style.outlineWidth,
136 offset: style.outlineOffset,
137 },
138 cursor: style.cursor,
139 ariaCurrent: snapshot.ariaCurrent || "",
140 ariaSelected: snapshot.ariaSelected || "",
141 ariaExpanded: snapshot.ariaExpanded || "",
142 disabled: Boolean(snapshot.disabled),
143 };
144}
145
146function compactChangedValues(snapshot, properties) {
147 return Object.fromEntries((properties || []).map((property) => [property, snapshotValue(snapshot, property)]));
148}
149
150function snapshotValue(snapshot = {}, property) {
151 const child = String(property).match(/^child:(.+?)(?:::(before|after))?\.([^.]+)$/);
152 if (child) {
153 const record = (snapshot.visualChildren || []).find((item) => item.key === child[1]);
154 return child[2] ? record?.pseudo?.[child[2]]?.[child[3]] : record?.styles?.[child[3]];
155 }
156 const pseudo = String(property).match(/^::(before|after)\.([^.]+)$/);
157 if (pseudo) return snapshot.pseudo?.[pseudo[1]]?.[pseudo[2]];
158 if (snapshot.styles && property in snapshot.styles) return snapshot.styles[property];
159 return snapshot[property];
160}
161
162function renderMarkdown(contract) {
163 return `# Control State Contract\n\nSource: ${contract.sourceUrl}\n\nState precedence: ${contract.precedence.map((item) => `\`${item}\``).join(" → ")}\n\n${contract.requirements.map((item) => `- ${item}`).join("\n")}\n\n## Representative controls\n\n${contract.controls.map((control) => `### ${control.label}\n\n- role/scope: \`${control.role}\` / \`${control.scope}\`\n- members: ${control.members.join(", ")}\n- states: ${Object.keys(control.states).map((state) => `\`${state}\``).join(", ") || "none observed"}\n- icon-owned motion: ${control.iconMotion.map((item) => `\`${item}\``).join(", ") || "none"}\n- owner transitions: ${control.ownerTransitions.map((item) => `\`${item.owner} ${item.duration} ${item.easing}\``).join(", ") || "none"}\n- evidence: ${control.sourceInteractionIds.map((id) => `\`interaction-map.json#${id}\``).join(", ")}`).join("\n\n") || "_No representative controls captured._"}\n`;
164}
165
166if (import.meta.url === `file://${process.argv[1]}`) main().catch((error) => {
167 console.error(error.stack || error.message);
168 process.exit(1);
169});