@agent-native/core 0.80.9 → 0.80.10
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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +7 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/design-connect.ts +28 -2
- package/corpus/core/src/cli/skills.ts +45 -24
- package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
- package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
- package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
- package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +433 -0
- package/corpus/templates/design/actions/apply-design-state.ts +200 -0
- package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
- package/corpus/templates/design/actions/apply-motion-edit.ts +450 -0
- package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
- package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
- package/corpus/templates/design/actions/capture-design-state.ts +224 -0
- package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
- package/corpus/templates/design/actions/create-component.ts +447 -0
- package/corpus/templates/design/actions/create-design-branch.ts +263 -0
- package/corpus/templates/design/actions/create-design-state.ts +162 -0
- package/corpus/templates/design/actions/delete-design-state.ts +55 -0
- package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
- package/corpus/templates/design/actions/get-component-details.ts +242 -0
- package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
- package/corpus/templates/design/actions/get-design-review.ts +314 -0
- package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
- package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
- package/corpus/templates/design/actions/index-components.ts +258 -0
- package/corpus/templates/design/actions/index-design-tokens.ts +273 -0
- package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
- package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
- package/corpus/templates/design/actions/list-design-states.ts +72 -0
- package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
- package/corpus/templates/design/actions/open-component-source.ts +237 -0
- package/corpus/templates/design/actions/preview-component-prop-edit.ts +254 -0
- package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
- package/corpus/templates/design/actions/preview-shader-fill.ts +189 -0
- package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
- package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
- package/corpus/templates/design/actions/run-design-audit.ts +421 -0
- package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
- package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1414 -71
- package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +632 -43
- package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
- package/corpus/templates/design/app/components/design/EditPanel.tsx +1391 -52
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +4 -3
- package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
- package/corpus/templates/design/app/components/design/MotionDock.tsx +1062 -0
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1052 -98
- package/corpus/templates/design/app/components/design/ReviewPanel.tsx +759 -0
- package/corpus/templates/design/app/components/design/StatesPanel.tsx +579 -0
- package/corpus/templates/design/app/components/design/TokensPanel.tsx +573 -0
- package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
- package/corpus/templates/design/app/components/design/index.ts +16 -0
- package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
- package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
- package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
- package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
- package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
- package/corpus/templates/design/app/i18n/zh-TW.ts +31 -0
- package/corpus/templates/design/app/i18n-data.ts +269 -0
- package/corpus/templates/design/app/pages/DesignEditor.tsx +2478 -386
- package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
- package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
- package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
- package/corpus/templates/design/e2e/helpers.ts +10 -4
- package/corpus/templates/design/server/db/schema.ts +123 -0
- package/corpus/templates/design/server/plugins/db.ts +90 -0
- package/corpus/templates/design/shared/builder-app.ts +297 -0
- package/corpus/templates/design/shared/capability-resolver.ts +123 -0
- package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
- package/corpus/templates/design/shared/code-layer.ts +1016 -71
- package/corpus/templates/design/shared/component-model.ts +239 -0
- package/corpus/templates/design/shared/design-review.ts +275 -0
- package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
- package/corpus/templates/design/shared/design-state.ts +112 -0
- package/corpus/templates/design/shared/design-surface-index.ts +258 -0
- package/corpus/templates/design/shared/motion-compiler.ts +278 -0
- package/corpus/templates/design/shared/motion-timeline.ts +193 -0
- package/corpus/templates/design/shared/responsive-classes.ts +452 -0
- package/corpus/templates/design/shared/shader-fill.ts +323 -0
- package/corpus/templates/design/shared/source-mode.ts +245 -0
- package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
- package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
- package/dist/cli/design-connect.d.ts.map +1 -1
- package/dist/cli/design-connect.js +28 -2
- package/dist/cli/design-connect.js.map +1 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +37 -21
- package/dist/cli/skills.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +7 -7
- package/dist/resources/handlers.d.ts +2 -2
- package/dist/server/transcribe-voice.d.ts +1 -1
- 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,421 @@
|
|
|
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: false,
|
|
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: false,
|
|
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 lowOpacityTextPattern =
|
|
290
|
+
/class\s*=\s*"[^"]*\btext-(?:white|black|gray-\d+)\s+(?:opacity-[0-3]\d|text-opacity-[0-3]\d)[^"]*"/gi;
|
|
291
|
+
let idx = 0;
|
|
292
|
+
for (const m of html.matchAll(lowOpacityTextPattern)) {
|
|
293
|
+
findings.push({
|
|
294
|
+
id: `contrast:low-opacity-${idx}`,
|
|
295
|
+
severity: "info" as A11ySeverity,
|
|
296
|
+
category: "contrast" as A11yFindingCategory,
|
|
297
|
+
message:
|
|
298
|
+
"Text element has a low-opacity modifier — verify contrast ratio meets 4.5:1 minimum.",
|
|
299
|
+
detail:
|
|
300
|
+
"Low-opacity text can fail WCAG 1.4.3. Run a live contrast check in the browser.",
|
|
301
|
+
wcag: "1.4.3",
|
|
302
|
+
fixAvailable: false,
|
|
303
|
+
});
|
|
304
|
+
idx++;
|
|
305
|
+
}
|
|
306
|
+
return findings;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Live-content helper (matches the pattern in other actions)
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
async function liveContent(
|
|
314
|
+
fileId: string,
|
|
315
|
+
storedContent: string,
|
|
316
|
+
): Promise<string> {
|
|
317
|
+
try {
|
|
318
|
+
if (await hasCollabState(fileId)) {
|
|
319
|
+
const live = await getText(fileId, "content");
|
|
320
|
+
if (typeof live === "string") return live;
|
|
321
|
+
}
|
|
322
|
+
} catch {
|
|
323
|
+
// SQL content is the deterministic fallback.
|
|
324
|
+
}
|
|
325
|
+
return storedContent;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Action definition
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
export default defineAction({
|
|
333
|
+
description:
|
|
334
|
+
"Run a read-only accessibility audit over a design's rendered HTML. " +
|
|
335
|
+
"Checks contrast hints, tap-target sizes, missing alt attributes, missing " +
|
|
336
|
+
"form labels, focus-visibility gaps, and reduced-motion coverage. " +
|
|
337
|
+
"Returns A11yFinding[] that can be shown in the Review panel or persisted " +
|
|
338
|
+
"via create-design-review-snapshot. No writes are performed.",
|
|
339
|
+
schema: z.object({
|
|
340
|
+
designId: z.string().describe("Design project ID to audit"),
|
|
341
|
+
fileId: z
|
|
342
|
+
.string()
|
|
343
|
+
.optional()
|
|
344
|
+
.describe(
|
|
345
|
+
"Specific design_files.id to audit. Defaults to the primary index.html when omitted.",
|
|
346
|
+
),
|
|
347
|
+
filename: z
|
|
348
|
+
.string()
|
|
349
|
+
.optional()
|
|
350
|
+
.default("index.html")
|
|
351
|
+
.describe(
|
|
352
|
+
"Filename to audit when fileId is not provided. Defaults to index.html.",
|
|
353
|
+
),
|
|
354
|
+
}),
|
|
355
|
+
readOnly: true,
|
|
356
|
+
http: { method: "GET" },
|
|
357
|
+
run: async ({ designId, fileId, filename }) => {
|
|
358
|
+
const db = getDb();
|
|
359
|
+
|
|
360
|
+
const conditions = [
|
|
361
|
+
accessFilter(schema.designs, schema.designShares),
|
|
362
|
+
eq(schema.designFiles.designId, designId),
|
|
363
|
+
...(fileId
|
|
364
|
+
? [eq(schema.designFiles.id, fileId)]
|
|
365
|
+
: [eq(schema.designFiles.filename, filename ?? "index.html")]),
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
const [file] = await db
|
|
369
|
+
.select({
|
|
370
|
+
id: schema.designFiles.id,
|
|
371
|
+
designId: schema.designFiles.designId,
|
|
372
|
+
filename: schema.designFiles.filename,
|
|
373
|
+
fileType: schema.designFiles.fileType,
|
|
374
|
+
content: schema.designFiles.content,
|
|
375
|
+
})
|
|
376
|
+
.from(schema.designFiles)
|
|
377
|
+
.innerJoin(
|
|
378
|
+
schema.designs,
|
|
379
|
+
eq(schema.designFiles.designId, schema.designs.id),
|
|
380
|
+
)
|
|
381
|
+
.where(and(...conditions))
|
|
382
|
+
.limit(1);
|
|
383
|
+
|
|
384
|
+
if (!file) {
|
|
385
|
+
const err = new Error("Design file not found") as Error & {
|
|
386
|
+
statusCode: number;
|
|
387
|
+
};
|
|
388
|
+
err.statusCode = 404;
|
|
389
|
+
throw err;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const html = await liveContent(file.id, file.content ?? "");
|
|
393
|
+
|
|
394
|
+
// Run all audit checks over the static HTML.
|
|
395
|
+
const findings: A11yFinding[] = [
|
|
396
|
+
...checkMissingAlt(html),
|
|
397
|
+
...checkMissingLabels(html),
|
|
398
|
+
...checkTapTargets(html),
|
|
399
|
+
...checkReducedMotion(html),
|
|
400
|
+
...checkFocusVisibility(html),
|
|
401
|
+
...checkContrastHint(html),
|
|
402
|
+
];
|
|
403
|
+
|
|
404
|
+
// Summarise by severity for the agent context.
|
|
405
|
+
const summary = {
|
|
406
|
+
errors: findings.filter((f) => f.severity === "error").length,
|
|
407
|
+
warnings: findings.filter((f) => f.severity === "warning").length,
|
|
408
|
+
info: findings.filter((f) => f.severity === "info").length,
|
|
409
|
+
total: findings.length,
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
return {
|
|
413
|
+
designId,
|
|
414
|
+
fileId: file.id,
|
|
415
|
+
filename: file.filename,
|
|
416
|
+
auditedAt: new Date().toISOString(),
|
|
417
|
+
findings,
|
|
418
|
+
summary,
|
|
419
|
+
};
|
|
420
|
+
},
|
|
421
|
+
});
|