@agent-native/core 0.80.9 → 0.80.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/production-agent.ts +108 -15
  5. package/corpus/core/src/cli/design-connect.ts +28 -2
  6. package/corpus/core/src/cli/skills.ts +45 -24
  7. package/corpus/core/src/client/AssistantChat.tsx +123 -26
  8. package/corpus/core/src/client/sse-event-processor.ts +9 -0
  9. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +9 -2
  10. package/corpus/templates/assets/AGENTS.md +4 -0
  11. package/corpus/templates/assets/actions/_tool-activity.ts +46 -0
  12. package/corpus/templates/assets/actions/generate-image-batch.ts +26 -5
  13. package/corpus/templates/assets/actions/generate-image.ts +118 -67
  14. package/corpus/templates/assets/actions/list-draft-assets.ts +50 -0
  15. package/corpus/templates/assets/actions/rerun-generation-run.ts +52 -31
  16. package/corpus/templates/assets/app/components/create/RecentDraftsSection.tsx +100 -0
  17. package/corpus/templates/assets/app/i18n/zh-TW.ts +7 -0
  18. package/corpus/templates/assets/app/i18n-data.ts +61 -0
  19. package/corpus/templates/assets/app/routes/_index.tsx +4 -0
  20. package/corpus/templates/assets/app/routes/library.tsx +145 -38
  21. package/corpus/templates/assets/changelog/2026-06-29-image-generation-can-now-render-requested-text-and-respect-b.md +6 -0
  22. package/corpus/templates/assets/changelog/2026-06-30-image-generation-no-longer-looks-stuck-while-the-provider-is-still-working.md +6 -0
  23. package/corpus/templates/assets/changelog/2026-06-30-tagged-presets-now-control-the-image-aspect-ratio.md +6 -0
  24. package/corpus/templates/assets/server/lib/generation.ts +135 -11
  25. package/corpus/templates/assets/shared/api.ts +4 -0
  26. package/corpus/templates/clips/actions/list-ai-requests.ts +60 -0
  27. package/corpus/templates/clips/app/hooks/use-auto-title.ts +24 -25
  28. package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
  29. package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
  30. package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
  31. package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
  32. package/corpus/templates/design/actions/apply-component-prop-edit.ts +441 -0
  33. package/corpus/templates/design/actions/apply-design-state.ts +213 -0
  34. package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
  35. package/corpus/templates/design/actions/apply-motion-edit.ts +413 -0
  36. package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
  37. package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
  38. package/corpus/templates/design/actions/capture-design-state.ts +224 -0
  39. package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
  40. package/corpus/templates/design/actions/create-component.ts +447 -0
  41. package/corpus/templates/design/actions/create-design-branch.ts +263 -0
  42. package/corpus/templates/design/actions/create-design-state.ts +162 -0
  43. package/corpus/templates/design/actions/delete-design-state.ts +55 -0
  44. package/corpus/templates/design/actions/delete-design.ts +20 -0
  45. package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
  46. package/corpus/templates/design/actions/get-component-details.ts +251 -0
  47. package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
  48. package/corpus/templates/design/actions/get-design-review.ts +314 -0
  49. package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
  50. package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
  51. package/corpus/templates/design/actions/index-components.ts +258 -0
  52. package/corpus/templates/design/actions/index-design-tokens.ts +277 -0
  53. package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
  54. package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
  55. package/corpus/templates/design/actions/list-design-states.ts +90 -0
  56. package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
  57. package/corpus/templates/design/actions/open-component-source.ts +242 -0
  58. package/corpus/templates/design/actions/preview-component-prop-edit.ts +260 -0
  59. package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
  60. package/corpus/templates/design/actions/preview-shader-fill.ts +187 -0
  61. package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
  62. package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
  63. package/corpus/templates/design/actions/run-design-audit.ts +436 -0
  64. package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
  65. package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
  66. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1550 -82
  67. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +649 -43
  68. package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
  69. package/corpus/templates/design/app/components/design/EditPanel.tsx +1788 -52
  70. package/corpus/templates/design/app/components/design/LayersPanel.tsx +114 -33
  71. package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
  72. package/corpus/templates/design/app/components/design/MotionDock.tsx +1071 -0
  73. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1053 -99
  74. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +766 -0
  75. package/corpus/templates/design/app/components/design/StatesPanel.tsx +581 -0
  76. package/corpus/templates/design/app/components/design/TokensPanel.tsx +578 -0
  77. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
  78. package/corpus/templates/design/app/components/design/index.ts +16 -0
  79. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
  80. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
  81. package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
  82. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
  83. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
  84. package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
  85. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
  86. package/corpus/templates/design/app/i18n/zh-TW.ts +32 -0
  87. package/corpus/templates/design/app/i18n-data.ts +280 -0
  88. package/corpus/templates/design/app/pages/DesignEditor.tsx +3087 -490
  89. package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
  90. package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
  91. package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
  92. package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
  93. package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
  94. package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
  95. package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
  96. package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
  97. package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
  98. package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
  99. package/corpus/templates/design/changelog/2026-06-30-design-token-edits-now-stay-visible-in-previews-and-token-li.md +6 -0
  100. package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
  101. package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
  102. package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
  103. package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
  104. package/corpus/templates/design/e2e/global-setup.ts +94 -2
  105. package/corpus/templates/design/e2e/helpers.ts +10 -4
  106. package/corpus/templates/design/server/db/schema.ts +123 -0
  107. package/corpus/templates/design/server/plugins/db.ts +90 -0
  108. package/corpus/templates/design/shared/builder-app.ts +297 -0
  109. package/corpus/templates/design/shared/capability-resolver.ts +123 -0
  110. package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
  111. package/corpus/templates/design/shared/code-layer.ts +1016 -71
  112. package/corpus/templates/design/shared/component-model.ts +274 -0
  113. package/corpus/templates/design/shared/design-review.ts +275 -0
  114. package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
  115. package/corpus/templates/design/shared/design-state.ts +112 -0
  116. package/corpus/templates/design/shared/design-surface-index.ts +258 -0
  117. package/corpus/templates/design/shared/motion-compiler.ts +349 -0
  118. package/corpus/templates/design/shared/motion-timeline.ts +193 -0
  119. package/corpus/templates/design/shared/resolve-tweaks.ts +32 -9
  120. package/corpus/templates/design/shared/responsive-classes.ts +452 -0
  121. package/corpus/templates/design/shared/shader-fill.ts +323 -0
  122. package/corpus/templates/design/shared/source-mode.ts +245 -0
  123. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
  124. package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
  125. package/dist/agent/production-agent.d.ts.map +1 -1
  126. package/dist/agent/production-agent.js +94 -10
  127. package/dist/agent/production-agent.js.map +1 -1
  128. package/dist/cli/design-connect.d.ts.map +1 -1
  129. package/dist/cli/design-connect.js +28 -2
  130. package/dist/cli/design-connect.js.map +1 -1
  131. package/dist/cli/skills.d.ts.map +1 -1
  132. package/dist/cli/skills.js +37 -21
  133. package/dist/cli/skills.js.map +1 -1
  134. package/dist/client/AssistantChat.d.ts +14 -0
  135. package/dist/client/AssistantChat.d.ts.map +1 -1
  136. package/dist/client/AssistantChat.js +115 -22
  137. package/dist/client/AssistantChat.js.map +1 -1
  138. package/dist/client/sse-event-processor.d.ts.map +1 -1
  139. package/dist/client/sse-event-processor.js +10 -0
  140. package/dist/client/sse-event-processor.js.map +1 -1
  141. package/dist/collab/routes.d.ts +1 -1
  142. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  143. package/dist/notifications/routes.d.ts +2 -2
  144. package/dist/observability/routes.d.ts +8 -8
  145. package/dist/resources/handlers.d.ts +2 -2
  146. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  147. package/dist/server/transcribe-voice.d.ts +1 -1
  148. package/package.json +1 -1
@@ -0,0 +1,196 @@
1
+ /**
2
+ * remove-motion-timeline — ATOMIC delete of a motion timeline row and its
3
+ * managed `<style data-agent-native-motion>` block (§6.3).
4
+ *
5
+ * Steps performed atomically:
6
+ * 1. Verify the timeline belongs to this design and the caller has editor access.
7
+ * 2. Delete the `motion_timeline` row.
8
+ * 3. Remove the managed CSS block from the design's HTML content.
9
+ * 4. Persist the cleaned HTML via the same Yjs/collab + SQL path used by
10
+ * apply-visual-edit.
11
+ */
12
+
13
+ import { defineAction } from "@agent-native/core";
14
+ import {
15
+ agentEnterDocument,
16
+ agentLeaveDocument,
17
+ applyText,
18
+ getText,
19
+ hasCollabState,
20
+ seedFromText,
21
+ } from "@agent-native/core/collab";
22
+ import { accessFilter, assertAccess } from "@agent-native/core/sharing";
23
+ import { and, eq } from "drizzle-orm";
24
+ import { z } from "zod";
25
+
26
+ import { getDb, schema } from "../server/db/index.js";
27
+ import "../server/db/index.js"; // ensure registerShareableResource runs
28
+
29
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
30
+
31
+ const MOTION_STYLE_OPEN = "<style data-agent-native-motion>";
32
+ const MOTION_STYLE_CLOSE = "</style>";
33
+
34
+ function removeMotionStyleBlock(html: string): string {
35
+ const openIdx = html.indexOf(MOTION_STYLE_OPEN);
36
+ if (openIdx === -1) return html;
37
+ const closeIdx = html.indexOf(
38
+ MOTION_STYLE_CLOSE,
39
+ openIdx + MOTION_STYLE_OPEN.length,
40
+ );
41
+ if (closeIdx === -1) return html;
42
+ // Also trim the optional newline that injectMotionStyle adds after the block.
43
+ const end = closeIdx + MOTION_STYLE_CLOSE.length;
44
+ const tail = html[end] === "\n" ? end + 1 : end;
45
+ return html.slice(0, openIdx) + html.slice(tail);
46
+ }
47
+
48
+ async function liveFileContent(
49
+ fileId: string,
50
+ storedContent: string,
51
+ ): Promise<string> {
52
+ try {
53
+ if (await hasCollabState(fileId)) {
54
+ const live = await getText(fileId, "content");
55
+ if (typeof live === "string") return live;
56
+ }
57
+ } catch {
58
+ // Best-effort; SQL is the fallback.
59
+ }
60
+ return storedContent;
61
+ }
62
+
63
+ // ─── Action ───────────────────────────────────────────────────────────────────
64
+
65
+ export default defineAction({
66
+ description:
67
+ "Atomically delete a motion timeline row and remove the managed " +
68
+ "<style data-agent-native-motion> block from the design's HTML. " +
69
+ "This is the inverse of apply-motion-edit. The HTML is persisted via " +
70
+ "the same Yjs/collab path so live editors see the change immediately.",
71
+ schema: z.object({
72
+ designId: z.string().describe("Design project ID."),
73
+ timelineId: z.string().describe("motion_timeline.id to delete."),
74
+ fileId: z
75
+ .string()
76
+ .optional()
77
+ .describe(
78
+ "Target design_files.id. Defaults to the design's primary index.html " +
79
+ "when omitted.",
80
+ ),
81
+ }),
82
+ run: async ({ designId, timelineId, fileId: fileIdInput }) => {
83
+ await assertAccess("design", designId, "editor");
84
+
85
+ const db = getDb();
86
+ const now = new Date().toISOString();
87
+
88
+ // ── 1. Verify timeline exists and belongs to this design ────────────────
89
+ const [timeline] = await db
90
+ .select({ id: schema.motionTimeline.id })
91
+ .from(schema.motionTimeline)
92
+ .where(
93
+ and(
94
+ eq(schema.motionTimeline.id, timelineId),
95
+ eq(schema.motionTimeline.designId, designId),
96
+ ),
97
+ )
98
+ .limit(1);
99
+
100
+ if (!timeline) {
101
+ throw new Error(
102
+ `motion_timeline not found for this design: ${timelineId}`,
103
+ );
104
+ }
105
+
106
+ // ── 2. Resolve the target HTML file ─────────────────────────────────────
107
+ const fileConditions = [
108
+ accessFilter(schema.designs, schema.designShares),
109
+ eq(schema.designFiles.designId, designId),
110
+ ];
111
+ if (fileIdInput) {
112
+ fileConditions.push(eq(schema.designFiles.id, fileIdInput));
113
+ } else {
114
+ fileConditions.push(eq(schema.designFiles.filename, "index.html"));
115
+ }
116
+
117
+ const [file] = await db
118
+ .select({
119
+ id: schema.designFiles.id,
120
+ content: schema.designFiles.content,
121
+ })
122
+ .from(schema.designFiles)
123
+ .innerJoin(
124
+ schema.designs,
125
+ eq(schema.designFiles.designId, schema.designs.id),
126
+ )
127
+ .where(and(...fileConditions))
128
+ .limit(1);
129
+
130
+ if (!file) {
131
+ // The timeline row can still be deleted even if the file is gone.
132
+ await db
133
+ .delete(schema.motionTimeline)
134
+ .where(eq(schema.motionTimeline.id, timelineId));
135
+ return {
136
+ timelineId,
137
+ designId,
138
+ deleted: true,
139
+ htmlPatched: false,
140
+ reason: "File not found — timeline row deleted without HTML cleanup.",
141
+ };
142
+ }
143
+
144
+ // ── 3. Remove the managed CSS block from the HTML ───────────────────────
145
+ const currentContent = await liveFileContent(file.id, file.content ?? "");
146
+ const bytesBefore = currentContent.length;
147
+ const cleanedContent = removeMotionStyleBlock(currentContent);
148
+ const bytesAfter = cleanedContent.length;
149
+ const htmlChanged = cleanedContent !== currentContent;
150
+
151
+ // ── 4. Clean the HTML/style block FIRST, then delete the row LAST ───────
152
+ // Ordering matters: if the HTML cleanup persists but the row delete fails,
153
+ // re-running this action is idempotent (the block is already gone, the row
154
+ // is still deletable). The reverse order could leave an orphaned managed
155
+ // <style> block with no row to track it.
156
+ if (htmlChanged) {
157
+ agentEnterDocument(file.id);
158
+ try {
159
+ await db
160
+ .update(schema.designFiles)
161
+ .set({ content: cleanedContent, updatedAt: now })
162
+ .where(eq(schema.designFiles.id, file.id));
163
+
164
+ if (await hasCollabState(file.id)) {
165
+ await applyText(file.id, cleanedContent, "content", "agent");
166
+ } else {
167
+ await seedFromText(file.id, cleanedContent);
168
+ }
169
+
170
+ await db
171
+ .update(schema.designs)
172
+ .set({ updatedAt: now })
173
+ .where(eq(schema.designs.id, designId));
174
+ } finally {
175
+ agentLeaveDocument(file.id);
176
+ }
177
+ }
178
+
179
+ // ── 5. Delete the motion_timeline row LAST (so a failure leaves no
180
+ // orphaned row pointing at HTML that was already cleaned). ────────
181
+ await db
182
+ .delete(schema.motionTimeline)
183
+ .where(eq(schema.motionTimeline.id, timelineId));
184
+
185
+ return {
186
+ timelineId,
187
+ designId,
188
+ fileId: file.id,
189
+ deleted: true,
190
+ htmlPatched: htmlChanged,
191
+ bytesBefore,
192
+ bytesAfter,
193
+ bytesDelta: bytesAfter - bytesBefore,
194
+ };
195
+ },
196
+ });
@@ -0,0 +1,436 @@
1
+ /**
2
+ * run-design-audit — read-only a11y audit over a design's rendered HTML/DOM.
3
+ *
4
+ * Checks contrast ratios, tap-target sizes, missing alt/labels, focus
5
+ * visibility, and reduced-motion concerns by static analysis of the stored
6
+ * HTML. Does NOT perform writes. Results are returned as `A11yFinding[]`
7
+ * and may be persisted by the caller via `create-design-review-snapshot`.
8
+ *
9
+ * See DESIGN-STUDIO-PLAN.md §6.5 + §7 (Review surface).
10
+ */
11
+
12
+ import { defineAction } from "@agent-native/core";
13
+ import { getText, hasCollabState } from "@agent-native/core/collab";
14
+ import { accessFilter } from "@agent-native/core/sharing";
15
+ import { and, eq } from "drizzle-orm";
16
+ import { z } from "zod";
17
+
18
+ import { getDb, schema } from "../server/db/index.js";
19
+ import "../server/db/index.js"; // ensure registerShareableResource runs
20
+ import type {
21
+ A11yFinding,
22
+ A11yFindingCategory,
23
+ A11ySeverity,
24
+ } from "../shared/design-review.js";
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // HTML helpers (static analysis — no DOM runtime available server-side)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /** Extract all attribute values matching a simple regex over raw HTML. */
31
+ function extractAttrs(
32
+ html: string,
33
+ tagPattern: RegExp,
34
+ attrName: string,
35
+ ): string[] {
36
+ const attrRegex = new RegExp(
37
+ `${attrName}\\s*=\\s*(?:"([^"]*?)"|'([^']*?)')`,
38
+ "gi",
39
+ );
40
+ const results: string[] = [];
41
+ let match: RegExpExecArray | null;
42
+ const tagMatches = [...html.matchAll(tagPattern)];
43
+ for (const tm of tagMatches) {
44
+ attrRegex.lastIndex = 0;
45
+ while ((match = attrRegex.exec(tm[0])) !== null) {
46
+ results.push(match[1] ?? match[2] ?? "");
47
+ }
48
+ }
49
+ return results;
50
+ }
51
+
52
+ /** Pull a node id from a raw tag string (data-agent-native-node-id attr). */
53
+ function extractNodeId(tagHtml: string): string | undefined {
54
+ const m = tagHtml.match(
55
+ /data-agent-native-node-id\s*=\s*(?:"([^"]*?)"|'([^']*?)')/i,
56
+ );
57
+ return m ? (m[1] ?? m[2] ?? undefined) : undefined;
58
+ }
59
+
60
+ /** Pull a CSS selector hint from a raw tag string (id or class). */
61
+ function extractSelector(tagHtml: string, tagName: string): string | undefined {
62
+ const idMatch = tagHtml.match(/\bid\s*=\s*(?:"([^"]*?)"|'([^']*?)')/i);
63
+ if (idMatch) return `#${idMatch[1] ?? idMatch[2]}`;
64
+ const classMatch = tagHtml.match(/\bclass\s*=\s*(?:"([^"]*?)"|'([^']*?)')/i);
65
+ if (classMatch) {
66
+ const first = (classMatch[1] ?? classMatch[2] ?? "").trim().split(/\s+/)[0];
67
+ if (first) return `${tagName.toLowerCase()}.${first}`;
68
+ }
69
+ return undefined;
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Individual audit checks
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /** Check <img> tags without a meaningful alt attribute. */
77
+ function checkMissingAlt(html: string): A11yFinding[] {
78
+ const findings: A11yFinding[] = [];
79
+ const imgPattern = /<img\b[^>]*>/gi;
80
+ let idx = 0;
81
+ for (const m of html.matchAll(imgPattern)) {
82
+ const tag = m[0];
83
+ // Missing alt entirely, or empty alt on a non-decorative image (heuristic)
84
+ const altMatch = tag.match(/\balt\s*=\s*(?:"([^"]*?)"|'([^']*?)')/i);
85
+ if (!altMatch) {
86
+ findings.push({
87
+ id: `missing-alt:img-${idx}`,
88
+ severity: "error" as A11ySeverity,
89
+ category: "missing-alt" as A11yFindingCategory,
90
+ message: "<img> is missing an alt attribute.",
91
+ detail:
92
+ 'Add alt="" for decorative images or a descriptive alt for informative images.',
93
+ nodeId: extractNodeId(tag),
94
+ selector: extractSelector(tag, "img"),
95
+ wcag: "1.1.1",
96
+ fixAvailable: false,
97
+ });
98
+ }
99
+ idx++;
100
+ }
101
+ return findings;
102
+ }
103
+
104
+ /** Check form inputs without an associated <label> or aria-label/aria-labelledby. */
105
+ function checkMissingLabels(html: string): A11yFinding[] {
106
+ const findings: A11yFinding[] = [];
107
+ const inputPattern = /<(?:input|select|textarea)\b[^>]*(?:\/>|>)/gi;
108
+ let idx = 0;
109
+ for (const m of html.matchAll(inputPattern)) {
110
+ const tag = m[0];
111
+ const typeMatch = tag.match(/\btype\s*=\s*(?:"([^"]*?)"|'([^']*?)')/i);
112
+ const type = (typeMatch?.[1] ?? typeMatch?.[2] ?? "text").toLowerCase();
113
+ // Hidden and submit/button/image inputs don't need visible labels
114
+ if (["hidden", "submit", "button", "image", "reset"].includes(type))
115
+ continue;
116
+
117
+ const hasAriaLabel =
118
+ /\baria-label\s*=/i.test(tag) ||
119
+ /\baria-labelledby\s*=/i.test(tag) ||
120
+ /\btitle\s*=/i.test(tag);
121
+ const idMatch = tag.match(/\bid\s*=\s*(?:"([^"]*?)"|'([^']*?)')/i);
122
+ const inputId = idMatch?.[1] ?? idMatch?.[2];
123
+ const hasExplicitLabel = inputId
124
+ ? new RegExp(`for\\s*=\\s*(?:"${inputId}"|'${inputId}')`, "i").test(html)
125
+ : false;
126
+
127
+ if (!hasAriaLabel && !hasExplicitLabel) {
128
+ findings.push({
129
+ id: `missing-label:input-${idx}`,
130
+ severity: "error" as A11ySeverity,
131
+ category: "missing-label" as A11yFindingCategory,
132
+ message: "Form control is missing an accessible label.",
133
+ detail:
134
+ "Associate a <label for> or add aria-label / aria-labelledby to identify this field.",
135
+ nodeId: extractNodeId(tag),
136
+ selector: extractSelector(tag, "input"),
137
+ wcag: "1.3.1",
138
+ fixAvailable: false,
139
+ });
140
+ }
141
+ idx++;
142
+ }
143
+ return findings;
144
+ }
145
+
146
+ /** Check interactive elements that are likely too small for touch targets (< ~44px heuristic via Tailwind class). */
147
+ function checkTapTargets(html: string): A11yFinding[] {
148
+ const findings: A11yFinding[] = [];
149
+ // Heuristic: buttons/links with explicit tiny size classes (h-4, h-5, w-4, w-5, size-4, size-5)
150
+ // and no explicit larger override or sr-only are flagged.
151
+ const interactivePattern = /<(?:button|a)\b[^>]*>/gi;
152
+ const tinyPattern = /\b(?:h|w|size)-[345]\b/;
153
+ const largePattern = /\b(?:h|w|size)-(?:[6-9]|[1-9]\d)/;
154
+ const srOnlyPattern = /\bsr-only\b/;
155
+ let idx = 0;
156
+ for (const m of html.matchAll(interactivePattern)) {
157
+ const tag = m[0];
158
+ if (
159
+ tinyPattern.test(tag) &&
160
+ !largePattern.test(tag) &&
161
+ !srOnlyPattern.test(tag)
162
+ ) {
163
+ findings.push({
164
+ id: `tap-target:interactive-${idx}`,
165
+ severity: "warning" as A11ySeverity,
166
+ category: "tap-target" as A11yFindingCategory,
167
+ message: "Interactive element may be too small for a touch target.",
168
+ detail:
169
+ "Minimum recommended tap target size is 44×44 px (WCAG 2.5.5). Consider increasing padding or size.",
170
+ nodeId: extractNodeId(tag),
171
+ selector: extractSelector(tag, "button"),
172
+ wcag: "2.5.5",
173
+ fixAvailable: true,
174
+ });
175
+ }
176
+ idx++;
177
+ }
178
+ return findings;
179
+ }
180
+
181
+ /** Check for animations/transitions without a prefers-reduced-motion guard. */
182
+ function checkReducedMotion(html: string): A11yFinding[] {
183
+ const findings: A11yFinding[] = [];
184
+ // Look for <style> blocks that animate but don't include @media (prefers-reduced-motion)
185
+ const stylePattern = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
186
+ let styleIdx = 0;
187
+ for (const m of html.matchAll(stylePattern)) {
188
+ const css = m[1] ?? "";
189
+ const hasAnimation =
190
+ /\banimation\b/i.test(css) || /\btransition\b/i.test(css);
191
+ const hasReducedMotionGuard = /prefers-reduced-motion/i.test(css);
192
+ if (hasAnimation && !hasReducedMotionGuard) {
193
+ findings.push({
194
+ id: `reduced-motion:style-${styleIdx}`,
195
+ severity: "warning" as A11ySeverity,
196
+ category: "reduced-motion" as A11yFindingCategory,
197
+ message:
198
+ "CSS animations or transitions are present without a prefers-reduced-motion media query.",
199
+ detail:
200
+ "Wrap animation declarations in @media (prefers-reduced-motion: no-preference) { … } to respect user motion preferences.",
201
+ wcag: "2.3.3",
202
+ fixAvailable: false,
203
+ });
204
+ }
205
+ styleIdx++;
206
+ }
207
+ // Also check inline style attrs with animation/transition
208
+ const inlineAnimPattern =
209
+ /style\s*=\s*(?:"[^"]*(?:animation|transition)[^"]*"|'[^']*(?:animation|transition)[^']*')/gi;
210
+ const allTags = [...html.matchAll(/<[a-z][^>]*>/gi)];
211
+ for (const m of allTags) {
212
+ if (inlineAnimPattern.test(m[0])) {
213
+ findings.push({
214
+ id: `reduced-motion:inline-${findings.length}`,
215
+ severity: "info" as A11ySeverity,
216
+ category: "reduced-motion" as A11yFindingCategory,
217
+ message:
218
+ "Inline style contains animation or transition. Verify it respects prefers-reduced-motion.",
219
+ nodeId: extractNodeId(m[0]),
220
+ selector: extractSelector(m[0], m[0].match(/^<([a-z]+)/i)?.[1] ?? "*"),
221
+ wcag: "2.3.3",
222
+ fixAvailable: false,
223
+ });
224
+ }
225
+ inlineAnimPattern.lastIndex = 0;
226
+ }
227
+ return findings;
228
+ }
229
+
230
+ /** Check for focus-visibility — elements with outline:none/outline:0 and no :focus-visible alternative. */
231
+ function checkFocusVisibility(html: string): A11yFinding[] {
232
+ const findings: A11yFinding[] = [];
233
+ const stylePattern = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
234
+ let idx = 0;
235
+ for (const m of html.matchAll(stylePattern)) {
236
+ const css = m[1] ?? "";
237
+ const hasFocusOutlineRemoval =
238
+ /:focus\s*\{[^}]*outline\s*:\s*(?:none|0)/i.test(css) ||
239
+ /:focus-within\s*\{[^}]*outline\s*:\s*(?:none|0)/i.test(css);
240
+ const hasFocusVisibleReplacement = /:focus-visible/i.test(css);
241
+ if (hasFocusOutlineRemoval && !hasFocusVisibleReplacement) {
242
+ findings.push({
243
+ id: `focus-visibility:style-${idx}`,
244
+ severity: "warning" as A11ySeverity,
245
+ category: "focus-visibility" as A11yFindingCategory,
246
+ message:
247
+ "outline:none on :focus detected without a :focus-visible alternative.",
248
+ detail:
249
+ "Remove the outline only for pointer users via :focus:not(:focus-visible), and supply a visible style on :focus-visible.",
250
+ wcag: "2.4.7",
251
+ fixAvailable: false,
252
+ });
253
+ }
254
+ idx++;
255
+ }
256
+ // Tailwind outline-none/ring-0 on interactive elements (heuristic)
257
+ const outlineNonePattern =
258
+ /<(?:button|a|input|select|textarea)\b[^>]*\boutline-none\b[^>]*>/gi;
259
+ let inlineIdx = 0;
260
+ for (const m of html.matchAll(outlineNonePattern)) {
261
+ const tag = m[0];
262
+ // Check it's also not carrying a focus-visible ring class
263
+ if (!/\bfocus-visible:/i.test(tag)) {
264
+ findings.push({
265
+ id: `focus-visibility:inline-${inlineIdx}`,
266
+ severity: "info" as A11ySeverity,
267
+ category: "focus-visibility" as A11yFindingCategory,
268
+ message:
269
+ "Interactive element uses outline-none without a focus-visible ring.",
270
+ detail:
271
+ "Add a focus-visible:ring-* class so keyboard users can see which element is focused.",
272
+ nodeId: extractNodeId(tag),
273
+ selector: extractSelector(tag, tag.match(/^<([a-z]+)/i)?.[1] ?? "*"),
274
+ wcag: "2.4.7",
275
+ fixAvailable: true,
276
+ });
277
+ }
278
+ inlineIdx++;
279
+ }
280
+ return findings;
281
+ }
282
+
283
+ /** Check for inline style color declarations that are opaque but very low-contrast (rough heuristic). */
284
+ function checkContrastHint(html: string): A11yFinding[] {
285
+ const findings: A11yFinding[] = [];
286
+ // Static analysis cannot compute real contrast ratios without a DOM/CSS
287
+ // cascade resolver. We flag the presence of explicit low-opacity text colors
288
+ // as a human-review hint — the UI will show these as "info" prompts.
289
+ const tagPattern = /<([a-z][a-z0-9:-]*)\b[^>]*>/gi;
290
+ const textColorPattern = /\btext-(?:white|black|gray-\d+)\b/i;
291
+ const lowOpacityPattern = /\b(?:opacity-[0-3]\d|text-opacity-[0-3]\d)\b/i;
292
+ let idx = 0;
293
+ for (const m of html.matchAll(tagPattern)) {
294
+ const tag = m[0];
295
+ const classMatch = tag.match(/\bclass\s*=\s*(?:"([^"]*?)"|'([^']*?)')/i);
296
+ const className = classMatch?.[1] ?? classMatch?.[2] ?? "";
297
+ if (
298
+ !textColorPattern.test(className) ||
299
+ !lowOpacityPattern.test(className)
300
+ ) {
301
+ continue;
302
+ }
303
+ const tagName = m[1] ?? "*";
304
+ const nodeId = extractNodeId(tag);
305
+ const selector = extractSelector(tag, tagName);
306
+ findings.push({
307
+ id: `contrast:low-opacity-${idx}`,
308
+ severity: "info" as A11ySeverity,
309
+ category: "contrast" as A11yFindingCategory,
310
+ message:
311
+ "Text element has a low-opacity modifier — verify contrast ratio meets 4.5:1 minimum.",
312
+ detail:
313
+ "Low-opacity text can fail WCAG 1.4.3. Run a live contrast check in the browser.",
314
+ nodeId,
315
+ selector,
316
+ wcag: "1.4.3",
317
+ fixAvailable: false,
318
+ });
319
+ idx++;
320
+ }
321
+ return findings;
322
+ }
323
+
324
+ // ---------------------------------------------------------------------------
325
+ // Live-content helper (matches the pattern in other actions)
326
+ // ---------------------------------------------------------------------------
327
+
328
+ async function liveContent(
329
+ fileId: string,
330
+ storedContent: string,
331
+ ): Promise<string> {
332
+ try {
333
+ if (await hasCollabState(fileId)) {
334
+ const live = await getText(fileId, "content");
335
+ if (typeof live === "string") return live;
336
+ }
337
+ } catch {
338
+ // SQL content is the deterministic fallback.
339
+ }
340
+ return storedContent;
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // Action definition
345
+ // ---------------------------------------------------------------------------
346
+
347
+ export default defineAction({
348
+ description:
349
+ "Run a read-only accessibility audit over a design's rendered HTML. " +
350
+ "Checks contrast hints, tap-target sizes, missing alt attributes, missing " +
351
+ "form labels, focus-visibility gaps, and reduced-motion coverage. " +
352
+ "Returns A11yFinding[] that can be shown in the Review panel or persisted " +
353
+ "via create-design-review-snapshot. No writes are performed.",
354
+ schema: z.object({
355
+ designId: z.string().describe("Design project ID to audit"),
356
+ fileId: z
357
+ .string()
358
+ .optional()
359
+ .describe(
360
+ "Specific design_files.id to audit. Defaults to the primary index.html when omitted.",
361
+ ),
362
+ filename: z
363
+ .string()
364
+ .optional()
365
+ .default("index.html")
366
+ .describe(
367
+ "Filename to audit when fileId is not provided. Defaults to index.html.",
368
+ ),
369
+ }),
370
+ readOnly: true,
371
+ http: { method: "POST" },
372
+ run: async ({ designId, fileId, filename }) => {
373
+ const db = getDb();
374
+
375
+ const conditions = [
376
+ accessFilter(schema.designs, schema.designShares),
377
+ eq(schema.designFiles.designId, designId),
378
+ ...(fileId
379
+ ? [eq(schema.designFiles.id, fileId)]
380
+ : [eq(schema.designFiles.filename, filename ?? "index.html")]),
381
+ ];
382
+
383
+ const [file] = await db
384
+ .select({
385
+ id: schema.designFiles.id,
386
+ designId: schema.designFiles.designId,
387
+ filename: schema.designFiles.filename,
388
+ fileType: schema.designFiles.fileType,
389
+ content: schema.designFiles.content,
390
+ })
391
+ .from(schema.designFiles)
392
+ .innerJoin(
393
+ schema.designs,
394
+ eq(schema.designFiles.designId, schema.designs.id),
395
+ )
396
+ .where(and(...conditions))
397
+ .limit(1);
398
+
399
+ if (!file) {
400
+ const err = new Error("Design file not found") as Error & {
401
+ statusCode: number;
402
+ };
403
+ err.statusCode = 404;
404
+ throw err;
405
+ }
406
+
407
+ const html = await liveContent(file.id, file.content ?? "");
408
+
409
+ // Run all audit checks over the static HTML.
410
+ const findings: A11yFinding[] = [
411
+ ...checkMissingAlt(html),
412
+ ...checkMissingLabels(html),
413
+ ...checkTapTargets(html),
414
+ ...checkReducedMotion(html),
415
+ ...checkFocusVisibility(html),
416
+ ...checkContrastHint(html),
417
+ ];
418
+
419
+ // Summarise by severity for the agent context.
420
+ const summary = {
421
+ errors: findings.filter((f) => f.severity === "error").length,
422
+ warnings: findings.filter((f) => f.severity === "warning").length,
423
+ info: findings.filter((f) => f.severity === "info").length,
424
+ total: findings.length,
425
+ };
426
+
427
+ return {
428
+ designId,
429
+ fileId: file.id,
430
+ filename: file.filename,
431
+ auditedAt: new Date().toISOString(),
432
+ findings,
433
+ summary,
434
+ };
435
+ },
436
+ });