@antelopejs/dms-ai 0.3.0 → 0.4.0

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 (65) hide show
  1. package/README.md +64 -205
  2. package/dist/index.js +3 -2
  3. package/dist/index.js.map +1 -1
  4. package/dist/lifecycle/skill-sources.d.ts +1 -1
  5. package/dist/lifecycle/skill-sources.js +29 -13
  6. package/dist/lifecycle/skill-sources.js.map +1 -1
  7. package/frontend-vue/app/chat/components/BlockPreviewCard.vue +172 -0
  8. package/frontend-vue/app/chat/components/BlockPreviewModal.vue +324 -0
  9. package/frontend-vue/app/chat/components/BlockPreviewThumb.vue +141 -0
  10. package/frontend-vue/app/chat/components/MessageList.vue +20 -0
  11. package/frontend-vue/app/chat/components/SketchFrame.vue +59 -0
  12. package/frontend-vue/app/chat/composables/conversation-events.ts +23 -1
  13. package/frontend-vue/app/chat/composables/useBlockPreviewComponent.ts +15 -0
  14. package/frontend-vue/app/chat/composables/useDashboardScheme.ts +42 -0
  15. package/frontend-vue/app/chat/constants/block-preview.ts +27 -0
  16. package/frontend-vue/app/chat/constants/conversation.ts +1 -0
  17. package/frontend-vue/app/chat/constants/protocol.ts +1 -0
  18. package/frontend-vue/app/chat/constants/sketch.ts +105 -0
  19. package/frontend-vue/app/chat/types/conversation.ts +11 -1
  20. package/frontend-vue/app/chat/types/protocol.ts +56 -0
  21. package/frontend-vue/app/chat/utils/block-preview.ts +145 -0
  22. package/frontend-vue/app/chat/utils/conversation-messages.ts +23 -1
  23. package/frontend-vue/app/chat/utils/render-items.ts +13 -2
  24. package/frontend-vue/app/chat/utils/sketch.ts +56 -0
  25. package/frontend-vue/app/chat/utils/tool-lexicon.ts +7 -0
  26. package/frontend-vue/i18n/locales/dms-ai-panel-en-GB.json +23 -1
  27. package/frontend-vue/i18n/locales/dms-ai-panel-fr-FR.json +23 -1
  28. package/frontend-vue/package.json +1 -1
  29. package/frontend-vue/tests/chat/block-preview.test.ts +448 -0
  30. package/package.json +20 -6
  31. package/sidecar/dist/constants/mcp.js +2 -0
  32. package/sidecar/dist/constants/mcp.js.map +1 -1
  33. package/sidecar/dist/constants/preview.d.ts +39 -0
  34. package/sidecar/dist/constants/preview.js +55 -0
  35. package/sidecar/dist/constants/preview.js.map +1 -0
  36. package/sidecar/dist/constants/tool-kinds.js +2 -0
  37. package/sidecar/dist/constants/tool-kinds.js.map +1 -1
  38. package/sidecar/dist/mcp/tool-definitions.js +5 -0
  39. package/sidecar/dist/mcp/tool-definitions.js.map +1 -1
  40. package/sidecar/dist/mcp/tools/preview-blocks.d.ts +65 -0
  41. package/sidecar/dist/mcp/tools/preview-blocks.js +176 -0
  42. package/sidecar/dist/mcp/tools/preview-blocks.js.map +1 -0
  43. package/sidecar/dist/mcp/types.d.ts +3 -0
  44. package/sidecar/dist/prompts/agent.d.ts +1 -1
  45. package/sidecar/dist/prompts/agent.js +3 -2
  46. package/sidecar/dist/prompts/agent.js.map +1 -1
  47. package/sidecar/dist/protocol/events.d.ts +313 -0
  48. package/sidecar/dist/protocol/events.js +9 -0
  49. package/sidecar/dist/protocol/events.js.map +1 -1
  50. package/sidecar/dist/protocol/preview.d.ts +130 -0
  51. package/sidecar/dist/protocol/preview.js +91 -0
  52. package/sidecar/dist/protocol/preview.js.map +1 -0
  53. package/sidecar/dist/server/chat-events.d.ts +3 -0
  54. package/sidecar/dist/server/chat-events.js +21 -0
  55. package/sidecar/dist/server/chat-events.js.map +1 -1
  56. package/sidecar/dist/server/ws.js +3 -1
  57. package/sidecar/dist/server/ws.js.map +1 -1
  58. package/sidecar/dist/state/types.d.ts +3 -1
  59. package/sidecar/dist/state/types.js +1 -0
  60. package/sidecar/dist/state/types.js.map +1 -1
  61. package/sidecar/package.json +1 -1
  62. package/skills/dms-ai/SKILL.md +30 -0
  63. package/skills/dms-ai-code-mode/SKILL.md +51 -0
  64. package/skills/dms-builder-safe/SKILL.md +9 -2
  65. package/skills/dms-builder-page/SKILL.md +0 -80
@@ -0,0 +1,56 @@
1
+ import {
2
+ SKETCH_BASE_CSS,
3
+ SKETCH_CSP,
4
+ SKETCH_TOKENS,
5
+ type SketchScheme,
6
+ type SketchToken,
7
+ UNSAFE_TOKEN_VALUE,
8
+ } from "../constants/sketch";
9
+ import type { PreviewSketch } from "../types/protocol";
10
+
11
+ /** Reads a dashboard variable; unset or unsafe values give the token's default. */
12
+ export type TokenReader = (variable: string) => string;
13
+
14
+ function tokenValue(
15
+ token: SketchToken,
16
+ scheme: SketchScheme,
17
+ read: TokenReader,
18
+ ): string {
19
+ const value = read(token.source).trim();
20
+ if (value === "" || UNSAFE_TOKEN_VALUE.test(value)) return token[scheme];
21
+ return value;
22
+ }
23
+
24
+ /** The `:root` rule giving a sandboxed sketch the dashboard's theme. */
25
+ export function sketchTokensCss(
26
+ scheme: SketchScheme,
27
+ read: TokenReader,
28
+ ): string {
29
+ const declarations = SKETCH_TOKENS.map(
30
+ (token) => `--sketch-${token.name}:${tokenValue(token, scheme, read)};`,
31
+ );
32
+ return `:root{--sketch-scheme:${scheme};${declarations.join("")}}`;
33
+ }
34
+
35
+ export function computedStyleReader(element: Element): TokenReader {
36
+ const style = getComputedStyle(element);
37
+ return (variable) => style.getPropertyValue(variable);
38
+ }
39
+
40
+ /**
41
+ * The standalone document a sketch is drawn in. The CSP comes first in the
42
+ * head, before anything the agent wrote, so no later markup can loosen it.
43
+ */
44
+ export function buildSketchDocument(
45
+ sketch: PreviewSketch,
46
+ tokensCss: string,
47
+ ): string {
48
+ return [
49
+ "<!doctype html><html><head>",
50
+ `<meta http-equiv="Content-Security-Policy" content="${SKETCH_CSP}">`,
51
+ '<meta charset="utf-8">',
52
+ `<style>${tokensCss}\n${SKETCH_BASE_CSS}</style>`,
53
+ `<style>${sketch.css ?? ""}</style>`,
54
+ `</head><body>${sketch.html}</body></html>`,
55
+ ].join("");
56
+ }
@@ -138,6 +138,7 @@ const ICONS = {
138
138
  LOGS: "i-ph-scroll",
139
139
  SKILL: "i-ph-books",
140
140
  PLAN: "i-ph-list-checks",
141
+ PREVIEW: "i-ph-frame-corners",
141
142
  REFRESH: "i-ph-arrows-clockwise",
142
143
  CATEGORY: "i-ph-folders",
143
144
  } as const;
@@ -285,6 +286,12 @@ const ASSISTANT_TOOLS: Record<string, ToolLexiconEntry> = {
285
286
  target: countOf("questions"),
286
287
  isReadOnly: true,
287
288
  },
289
+ PreviewBlocks: {
290
+ verb: "show_preview",
291
+ icon: ICONS.PREVIEW,
292
+ target: firstOf("title"),
293
+ isReadOnly: true,
294
+ },
288
295
  Typecheck: {
289
296
  verb: "typecheck",
290
297
  icon: ICONS.CHECK,
@@ -368,6 +368,27 @@
368
368
  "close": "Close",
369
369
  "not_running": "The assistant isn't running in this tab."
370
370
  }
371
+ },
372
+ "preview": {
373
+ "eyebrow": "Block preview",
374
+ "open": "Preview",
375
+ "open_aria": "Open the block preview: {title}",
376
+ "blocks": "{count} block | {count} blocks",
377
+ "variants": "{count} variant | {count} variants",
378
+ "with_mockup": "vs your mockup",
379
+ "proposed": "Proposed",
380
+ "variants_aria": "Variants",
381
+ "mockup": "Your mockup",
382
+ "dms_blocks": "DMS blocks",
383
+ "mockup_missing": "{name} was attached before the chat reloaded, so it can no longer be shown. Attach it again to compare side by side.",
384
+ "mockup_external": "External image, not loaded in the dashboard.",
385
+ "mockup_open": "Open the mockup",
386
+ "fallback_title": "This DMS version cannot draw block previews",
387
+ "fallback_body": "Update the DMS to see the real blocks. Here is the tree the assistant sent.",
388
+ "custom_sketch": "Custom sketch — not a DMS block",
389
+ "sketch_badge": "Custom sketch",
390
+ "with_sketch": "custom sketch",
391
+ "sketch_frame": "Static sketch of a custom component: {caption}"
371
392
  }
372
393
  },
373
394
  "tools": {
@@ -419,7 +440,8 @@
419
440
  "add_query": "Add query",
420
441
  "configure_query": "Configure query",
421
442
  "remove_query": "Remove query",
422
- "search_tools": "Load tools"
443
+ "search_tools": "Load tools",
444
+ "show_preview": "Show a preview"
423
445
  }
424
446
  }
425
447
  }
@@ -368,6 +368,27 @@
368
368
  "close": "Fermer",
369
369
  "not_running": "L'assistant ne tourne pas dans cet onglet."
370
370
  }
371
+ },
372
+ "preview": {
373
+ "eyebrow": "Aperçu de blocs",
374
+ "open": "Aperçu",
375
+ "open_aria": "Ouvrir l'aperçu des blocs : {title}",
376
+ "blocks": "{count} bloc | {count} blocs",
377
+ "variants": "{count} variante | {count} variantes",
378
+ "with_mockup": "face à votre maquette",
379
+ "proposed": "Proposé",
380
+ "variants_aria": "Variantes",
381
+ "mockup": "Votre maquette",
382
+ "dms_blocks": "Blocs DMS",
383
+ "mockup_missing": "{name} a été jointe avant le rechargement du chat et ne peut plus être affichée. Joignez-la à nouveau pour comparer côte à côte.",
384
+ "mockup_external": "Image externe, non chargée dans le tableau de bord.",
385
+ "mockup_open": "Ouvrir la maquette",
386
+ "fallback_title": "Cette version du DMS ne sait pas afficher d'aperçu de blocs",
387
+ "fallback_body": "Mettez à jour le DMS pour voir les vrais blocs. Voici l'arbre envoyé par l'assistant.",
388
+ "custom_sketch": "Esquisse sur mesure — pas un bloc DMS",
389
+ "sketch_badge": "Esquisse",
390
+ "with_sketch": "esquisse sur mesure",
391
+ "sketch_frame": "Esquisse statique d'un composant sur mesure : {caption}"
371
392
  }
372
393
  },
373
394
  "tools": {
@@ -419,7 +440,8 @@
419
440
  "add_query": "Ajouter une requête",
420
441
  "configure_query": "Configurer la requête",
421
442
  "remove_query": "Retirer la requête",
422
- "search_tools": "Charger des outils"
443
+ "search_tools": "Charger des outils",
444
+ "show_preview": "Montrer un aperçu"
423
445
  }
424
446
  }
425
447
  }
@@ -6,7 +6,7 @@
6
6
  "license": "Apache-2.0",
7
7
  "packageManager": "pnpm@10.6.5",
8
8
  "engines": {
9
- "@antelopejs/dms-frontend": ">=0.6.0 <0.7.0"
9
+ "@antelopejs/dms-frontend": ">=0.6.3 <0.7.0"
10
10
  },
11
11
  "scripts": {
12
12
  "build": "pnpm --dir \"${DMS_FRONTEND_WORKSPACE:?Set DMS_FRONTEND_WORKSPACE to the generated Inertia workspace}\" build",
@@ -0,0 +1,448 @@
1
+ // @vitest-environment jsdom
2
+ import { mount, type VueWrapper } from "@vue/test-utils";
3
+ import { afterEach, describe, expect, it } from "vitest";
4
+ import { defineComponent, h, nextTick, type PropType, ref } from "vue";
5
+ import BlockPreviewCard from "../../app/chat/components/BlockPreviewCard.vue";
6
+ import { useConversation } from "../../app/chat/composables/useConversation";
7
+ import { CHAT_I18N_KEY } from "../../app/chat/composables/useChatI18n";
8
+ import type { ConversationMessage } from "../../app/chat/types/conversation";
9
+ import type { BlockPreview } from "../../app/chat/types/protocol";
10
+ import {
11
+ countBlocks,
12
+ previewTabs,
13
+ referenceView,
14
+ type ReferenceView,
15
+ } from "../../app/chat/utils/block-preview";
16
+ import { buildUserMessage } from "../../app/chat/utils/conversation-messages";
17
+ import {
18
+ buildSketchDocument,
19
+ sketchTokensCss,
20
+ } from "../../app/chat/utils/sketch";
21
+ import { createTestI18n } from "../support/i18n";
22
+ import { PANEL_STUBS } from "../support/stubs";
23
+
24
+ const CONVERSATION_ID = "conv-1";
25
+ const MOCKUP_NAME = "mockup.png";
26
+ const MOCKUP_DATA_URL = "data:image/png;base64,AAAA";
27
+
28
+ const PREVIEW: BlockPreview = {
29
+ id: "preview-1",
30
+ title: "Revenue card",
31
+ description: "Same layout; the trend sits under the value.",
32
+ tree: {
33
+ type: "Section",
34
+ children: [
35
+ { type: "KpiCard", options: { label: "Revenue" } },
36
+ { type: "KpiCard", options: { label: "Orders" } },
37
+ ],
38
+ },
39
+ timestampMs: 1_000,
40
+ };
41
+
42
+ const WITH_VARIANTS: BlockPreview = {
43
+ ...PREVIEW,
44
+ variants: [
45
+ { label: "Compact", tree: [{ type: "KpiCard", options: { size: "sm" } }] },
46
+ ],
47
+ };
48
+
49
+ const SKETCH = {
50
+ html: '<article class="card"><h3>Revenue</h3><p>12 400 €</p></article>',
51
+ css: ".card { background: var(--sketch-surface); }",
52
+ caption: "Revenue card with a sparkline",
53
+ height: 240,
54
+ };
55
+
56
+ const DMS_VS_SKETCH: BlockPreview = {
57
+ id: "preview-2",
58
+ title: "Revenue card: DMS or custom",
59
+ label: "DMS KpiCard",
60
+ tree: { type: "KpiCard", options: { label: "Revenue" } },
61
+ variants: [{ label: "Custom sketch", sketch: SKETCH }],
62
+ timestampMs: 2_000,
63
+ };
64
+
65
+ const SKETCH_ONLY: BlockPreview = {
66
+ id: "preview-3",
67
+ title: "Custom revenue card",
68
+ sketch: SKETCH,
69
+ timestampMs: 3_000,
70
+ };
71
+
72
+ const UTabs = defineComponent({
73
+ name: "UTabs",
74
+ props: {
75
+ modelValue: { type: String, default: "" },
76
+ items: {
77
+ type: Array as PropType<{ value: string; label: string }[]>,
78
+ default: () => [],
79
+ },
80
+ },
81
+ emits: ["update:modelValue"],
82
+ setup:
83
+ (props, { emit, attrs }) =>
84
+ () =>
85
+ h(
86
+ "div",
87
+ { role: "tablist", "aria-label": attrs["aria-label"] },
88
+ props.items.map((item) =>
89
+ h(
90
+ "button",
91
+ {
92
+ role: "tab",
93
+ "aria-selected": item.value === props.modelValue,
94
+ onClick: () => emit("update:modelValue", item.value),
95
+ },
96
+ item.label,
97
+ ),
98
+ ),
99
+ ),
100
+ });
101
+
102
+ const DmsBlockPreview = defineComponent({
103
+ name: "DmsBlockPreview",
104
+ props: { tree: { type: [Object, Array], required: true } },
105
+ setup: (props) => () =>
106
+ h("div", { class: "dms-block-preview" }, JSON.stringify(props.tree)),
107
+ });
108
+
109
+ interface MountOptions {
110
+ preview?: BlockPreview;
111
+ reference?: ReferenceView | null;
112
+ hasPreviewComponent?: boolean;
113
+ }
114
+
115
+ function mountCard(options: MountOptions = {}): VueWrapper {
116
+ return mount(BlockPreviewCard, {
117
+ props: {
118
+ preview: options.preview ?? PREVIEW,
119
+ reference: options.reference ?? null,
120
+ },
121
+ global: {
122
+ stubs: { ...PANEL_STUBS, UTabs },
123
+ components:
124
+ options.hasPreviewComponent === false ? {} : { DmsBlockPreview },
125
+ provide: { [CHAT_I18N_KEY as symbol]: createTestI18n() },
126
+ },
127
+ });
128
+ }
129
+
130
+ async function openModal(wrapper: VueWrapper): Promise<void> {
131
+ await wrapper.find("button.bp-card").trigger("click");
132
+ }
133
+
134
+ describe("block preview helpers", () => {
135
+ it("counts nested blocks", () => {
136
+ expect(countBlocks(PREVIEW.tree)).toBe(3);
137
+ });
138
+
139
+ it("puts the proposed tree first, then one tab per variant", () => {
140
+ const tabs = previewTabs(WITH_VARIANTS);
141
+ expect(tabs.map((tab) => tab.label)).toEqual([null, "Compact"]);
142
+ expect(tabs[1].tree).toEqual(WITH_VARIANTS.variants?.[0].tree);
143
+ });
144
+
145
+ it("finds the mockup among the images attached in this session", () => {
146
+ const messages: ConversationMessage[] = [
147
+ buildUserMessage("like this", [
148
+ {
149
+ name: MOCKUP_NAME,
150
+ mimeType: "image/png",
151
+ size: 4,
152
+ dataUrl: MOCKUP_DATA_URL,
153
+ },
154
+ ]),
155
+ ];
156
+ expect(referenceView({ attachmentName: MOCKUP_NAME }, messages)).toEqual({
157
+ kind: "image",
158
+ src: MOCKUP_DATA_URL,
159
+ name: MOCKUP_NAME,
160
+ });
161
+ });
162
+
163
+ it("says the mockup is gone when only its name survived a reload", () => {
164
+ const messages = [
165
+ buildUserMessage("like this", [
166
+ { name: MOCKUP_NAME, mimeType: "image/png", size: 4 },
167
+ ]),
168
+ ];
169
+ expect(referenceView({ attachmentName: MOCKUP_NAME }, messages)).toEqual({
170
+ kind: "missing",
171
+ name: MOCKUP_NAME,
172
+ });
173
+ });
174
+
175
+ it("loads a same-origin path, links an external URL, ignores the rest", () => {
176
+ expect(referenceView({ url: "/media/a.png" }, [])?.kind).toBe("image");
177
+ expect(referenceView({ url: "https://x.example/a.png" }, [])?.kind).toBe(
178
+ "link",
179
+ );
180
+ expect(referenceView({ url: "//x.example/a.png" }, [])).toBeNull();
181
+ expect(referenceView({ url: "javascript:alert(1)" }, [])).toBeNull();
182
+ });
183
+ });
184
+
185
+ describe("block preview events", () => {
186
+ function conversation() {
187
+ const handlers: ((msg: unknown) => void)[] = [];
188
+ const result = useConversation({
189
+ activeId: ref(CONVERSATION_ID),
190
+ send: () => true,
191
+ onMessage: (handler) => {
192
+ handlers.push(handler);
193
+ return () => undefined;
194
+ },
195
+ });
196
+ const receive = (event: Record<string, unknown>) =>
197
+ handlers.forEach((handler) =>
198
+ handler({ conversationId: CONVERSATION_ID, ...event }),
199
+ );
200
+ return { ...result, receive };
201
+ }
202
+
203
+ it("appends a live preview once", () => {
204
+ const chat = conversation();
205
+ chat.receive({ type: "block_preview", preview: PREVIEW });
206
+ chat.receive({ type: "block_preview", preview: PREVIEW });
207
+ expect(chat.messages.value).toHaveLength(1);
208
+ expect(chat.messages.value[0]).toMatchObject({
209
+ role: "block_preview",
210
+ preview: PREVIEW,
211
+ });
212
+ });
213
+
214
+ it("restores a preview from the stored transcript", () => {
215
+ const chat = conversation();
216
+ chat.receive({
217
+ type: "conversation_snapshot",
218
+ messages: [
219
+ { role: "user", content: "show me", timestampMs: 1 },
220
+ {
221
+ role: "block_preview",
222
+ content: "",
223
+ preview: PREVIEW,
224
+ timestampMs: 2,
225
+ },
226
+ ],
227
+ });
228
+ expect(chat.messages.value.map((m) => m.role)).toEqual([
229
+ "user",
230
+ "block_preview",
231
+ ]);
232
+ });
233
+
234
+ it("keeps the preview when a running turn is replayed", () => {
235
+ const chat = conversation();
236
+ chat.receive({
237
+ type: "conversation_snapshot",
238
+ messages: [{ role: "user", content: "show me", timestampMs: 1 }],
239
+ });
240
+ chat.receive({ type: "block_preview", preview: PREVIEW });
241
+ chat.receive({ type: "run_resumed" });
242
+ expect(chat.messages.value.at(-1)?.role).toBe("block_preview");
243
+ });
244
+ });
245
+
246
+ describe("the block preview attachment", () => {
247
+ it("reads as a file: thumbnail, title, what it holds", () => {
248
+ const wrapper = mountCard({ preview: WITH_VARIANTS });
249
+ const card = wrapper.find("button.bp-card");
250
+ expect(card.attributes("aria-haspopup")).toBe("dialog");
251
+ expect(card.attributes("aria-label")).toBe(
252
+ "Open the block preview: Revenue card",
253
+ );
254
+ expect(wrapper.find(".bp-thumb .dms-block-preview").exists()).toBe(true);
255
+ expect(wrapper.find(".bp-card__meta").text()).toBe(
256
+ "Section · 3 blocks · 2 variants",
257
+ );
258
+ });
259
+
260
+ it("draws a schematic thumbnail when the DMS has no preview component", () => {
261
+ const wrapper = mountCard({ hasPreviewComponent: false });
262
+ expect(wrapper.findAll(".bp-thumb__cell")).toHaveLength(2);
263
+ });
264
+
265
+ it("opens a dialog rendering the real blocks", async () => {
266
+ const wrapper = mountCard();
267
+ expect(wrapper.find("[role=dialog]").exists()).toBe(false);
268
+ await openModal(wrapper);
269
+ const dialog = wrapper.find("[role=dialog]");
270
+ expect(dialog.text()).toContain("Revenue card");
271
+ expect(dialog.find(".bp-pane__stage .dms-block-preview").text()).toBe(
272
+ JSON.stringify(PREVIEW.tree),
273
+ );
274
+ expect(dialog.find(".bp-fallback").exists()).toBe(false);
275
+ });
276
+
277
+ it("shows the tree as JSON when the DMS cannot render it", async () => {
278
+ const wrapper = mountCard({ hasPreviewComponent: false });
279
+ await openModal(wrapper);
280
+ const fallback = wrapper.find(".bp-fallback");
281
+ expect(fallback.text()).toContain(
282
+ "This DMS version cannot draw block previews",
283
+ );
284
+ expect(JSON.parse(fallback.find("pre").text())).toEqual(PREVIEW.tree);
285
+ });
286
+
287
+ it("switches the rendered tree with the variant tabs", async () => {
288
+ const wrapper = mountCard({ preview: WITH_VARIANTS });
289
+ await openModal(wrapper);
290
+ const tabs = wrapper.findAll("[role=tab]");
291
+ expect(tabs.map((tab) => tab.text())).toEqual(["Proposed", "Compact"]);
292
+ await tabs[1].trigger("click");
293
+ expect(wrapper.find(".bp-pane__stage .dms-block-preview").text()).toBe(
294
+ JSON.stringify(WITH_VARIANTS.variants?.[0].tree),
295
+ );
296
+ });
297
+
298
+ it("puts the mockup beside the blocks", async () => {
299
+ const wrapper = mountCard({
300
+ preview: { ...PREVIEW, reference: { caption: "Your Figma frame" } },
301
+ reference: { kind: "image", src: MOCKUP_DATA_URL, name: MOCKUP_NAME },
302
+ });
303
+ await openModal(wrapper);
304
+ expect(wrapper.find(".bp-modal__panes").classes()).toContain("is-split");
305
+ const image = wrapper.find(".bp-pane__image");
306
+ expect(image.attributes("src")).toBe(MOCKUP_DATA_URL);
307
+ expect(image.attributes("alt")).toBe("Your Figma frame");
308
+ });
309
+
310
+ it("links an external mockup instead of loading it", async () => {
311
+ const href = "https://figma.example/frame.png";
312
+ const wrapper = mountCard({ reference: { kind: "link", href } });
313
+ await openModal(wrapper);
314
+ expect(wrapper.find(".bp-pane__image").exists()).toBe(false);
315
+ const link = wrapper.find(".bp-pane__link");
316
+ expect(link.attributes("href")).toBe(href);
317
+ expect(link.attributes("rel")).toBe("noopener noreferrer");
318
+ });
319
+ });
320
+
321
+ describe("sketch documents", () => {
322
+ const unset = () => "";
323
+
324
+ it("puts the CSP first, before anything the agent wrote", () => {
325
+ const doc = buildSketchDocument(
326
+ { html: "<p>x</p>", css: "p{}" },
327
+ sketchTokensCss("light", unset),
328
+ );
329
+ const head = doc.slice(doc.indexOf("<head>") + "<head>".length);
330
+ expect(head.startsWith('<meta http-equiv="Content-Security-Policy"')).toBe(
331
+ true,
332
+ );
333
+ expect(doc).toContain(
334
+ "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:",
335
+ );
336
+ expect(doc.indexOf("Content-Security-Policy")).toBeLessThan(
337
+ doc.indexOf("<p>x</p>"),
338
+ );
339
+ });
340
+
341
+ it("gives the sketch the dashboard's values, or its defaults", () => {
342
+ const read = (variable: string) =>
343
+ variable === "--ui-border" ? " #123456 " : "";
344
+ const css = sketchTokensCss("dark", read);
345
+ expect(css).toContain("--sketch-scheme:dark;");
346
+ expect(css).toContain("--sketch-border:#123456;");
347
+ expect(css).toContain("--sketch-surface:#10151a;");
348
+ });
349
+
350
+ it("never writes a value that could close the style element", () => {
351
+ const read = () => "red}</style><p>";
352
+ const css = sketchTokensCss("light", read);
353
+ expect(css).not.toContain("</style>");
354
+ expect(css).toContain("--sketch-border:#e2e7ea;");
355
+ });
356
+ });
357
+
358
+ describe("a custom sketch in the preview", () => {
359
+ afterEach(() => {
360
+ document.documentElement.classList.remove("dark");
361
+ });
362
+
363
+ async function openOnSketchTab(wrapper: VueWrapper): Promise<void> {
364
+ await openModal(wrapper);
365
+ await wrapper.findAll("[role=tab]")[1].trigger("click");
366
+ await nextTick();
367
+ }
368
+
369
+ it("tabs a DMS block against a custom sketch", async () => {
370
+ const wrapper = mountCard({ preview: DMS_VS_SKETCH });
371
+ await openModal(wrapper);
372
+ const tabs = wrapper.findAll("[role=tab]");
373
+ expect(tabs.map((tab) => tab.text())).toEqual([
374
+ "DMS KpiCard",
375
+ "Custom sketch",
376
+ ]);
377
+ expect(wrapper.find(".bp-pane__stage .dms-block-preview").exists()).toBe(
378
+ true,
379
+ );
380
+ expect(wrapper.find("iframe").exists()).toBe(false);
381
+ await openOnSketchTab(wrapper);
382
+ expect(wrapper.find(".bp-pane__stage").exists()).toBe(false);
383
+ expect(wrapper.find(".bp-pane--sketch").text()).toContain(
384
+ "Custom sketch — not a DMS block",
385
+ );
386
+ expect(wrapper.find(".bp-pane__caption").text()).toBe(SKETCH.caption);
387
+ });
388
+
389
+ it("draws the sketch in a sandboxed frame with no script", async () => {
390
+ const wrapper = mountCard({ preview: DMS_VS_SKETCH });
391
+ await openOnSketchTab(wrapper);
392
+ const frame = wrapper.find(".bp-pane--sketch iframe");
393
+ expect(frame.attributes("sandbox")).toBe("");
394
+ expect(frame.attributes("referrerpolicy")).toBe("no-referrer");
395
+ expect(frame.attributes("src")).toBeUndefined();
396
+ expect(frame.attributes("style")).toContain("height: 240px");
397
+ expect(frame.attributes("title")).toContain(SKETCH.caption);
398
+ const doc = frame.attributes("srcdoc") ?? "";
399
+ expect(doc).toContain('http-equiv="Content-Security-Policy"');
400
+ expect(doc).toContain(SKETCH.html);
401
+ expect(doc).toContain(SKETCH.css);
402
+ expect(doc).not.toMatch(/<script/i);
403
+ expect(doc).toContain("--sketch-scheme:light;");
404
+ });
405
+
406
+ it("follows the dashboard into dark mode", async () => {
407
+ document.documentElement.classList.add("dark");
408
+ const wrapper = mountCard({ preview: DMS_VS_SKETCH });
409
+ await openOnSketchTab(wrapper);
410
+ const doc = wrapper.find("iframe").attributes("srcdoc") ?? "";
411
+ expect(doc).toContain("--sketch-scheme:dark;");
412
+ });
413
+
414
+ it("labels the attachment as holding a custom sketch", () => {
415
+ const wrapper = mountCard({ preview: DMS_VS_SKETCH });
416
+ expect(wrapper.find(".bp-thumb__badge").text()).toBe("Custom sketch");
417
+ expect(wrapper.find(".bp-card__meta").text()).toBe(
418
+ "KpiCard · 1 block · 2 variants · custom sketch",
419
+ );
420
+ });
421
+
422
+ it("thumbnails a sketch-only preview with the sandboxed sketch", async () => {
423
+ const wrapper = mountCard({ preview: SKETCH_ONLY });
424
+ await nextTick();
425
+ const frame = wrapper.find(".bp-thumb iframe");
426
+ expect(frame.attributes("sandbox")).toBe("");
427
+ expect(wrapper.find(".bp-card__meta").text()).toBe("custom sketch");
428
+ });
429
+
430
+ it("still draws the sketch when the DMS cannot render blocks", async () => {
431
+ const wrapper = mountCard({
432
+ preview: { ...SKETCH_ONLY, tree: { type: "KpiCard" } },
433
+ hasPreviewComponent: false,
434
+ });
435
+ await openModal(wrapper);
436
+ await nextTick();
437
+ expect(wrapper.find(".bp-fallback").exists()).toBe(true);
438
+ expect(wrapper.find(".bp-pane--sketch iframe").exists()).toBe(true);
439
+ expect(wrapper.find(".bp-modal__panes").classes()).toContain("is-split");
440
+ });
441
+
442
+ it("keeps a sketch-only preview as a single tab", () => {
443
+ const restored = previewTabs(SKETCH_ONLY);
444
+ expect(restored).toHaveLength(1);
445
+ expect(restored[0].sketch).toEqual(SKETCH);
446
+ expect(restored[0].tree).toBeUndefined();
447
+ });
448
+ });
package/package.json CHANGED
@@ -1,6 +1,20 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-ai",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
+ "description": "AI development assistant for the AntelopeJS DMS: a chat panel next to every page and a coding agent that edits the project in reviewable, undoable change sets.",
5
+ "keywords": [
6
+ "ai",
7
+ "antelopejs",
8
+ "assistant",
9
+ "claude-code",
10
+ "codex",
11
+ "dms",
12
+ "dms-module"
13
+ ],
14
+ "homepage": "https://dms.antelopejs.com/docs/modules/ai",
15
+ "bugs": {
16
+ "url": "https://github.com/AntelopeJS/dms-ai/issues"
17
+ },
4
18
  "license": "Apache-2.0",
5
19
  "repository": {
6
20
  "type": "git",
@@ -52,9 +66,9 @@
52
66
  "knip:all": "knip"
53
67
  },
54
68
  "dependencies": {
55
- "@antelopejs/interface-api": ">=0.0.14 <1.0.0",
56
- "@antelopejs/interface-core": ">=0.1.1 <1.0.0",
57
- "@antelopejs/interface-dms": ">=0.7.0 <1.0.0",
69
+ "@antelopejs/interface-api": ">=0.0.15 <1.0.0",
70
+ "@antelopejs/interface-core": ">=0.2.4 <1.0.0",
71
+ "@antelopejs/interface-dms": ">=0.7.2 <1.0.0",
58
72
  "@anthropic-ai/claude-agent-sdk": "^0.2.131",
59
73
  "@anthropic-ai/claude-code": "^2.1.131",
60
74
  "@modelcontextprotocol/sdk": "1.29.0",
@@ -62,7 +76,7 @@
62
76
  "zod": "^4.0.0"
63
77
  },
64
78
  "devDependencies": {
65
- "@antelopejs/tooling-configs": ">=0.0.7 <1.0.0",
79
+ "@antelopejs/tooling-configs": ">=0.0.9 <1.0.0",
66
80
  "@types/node": "^24.0.0",
67
81
  "@types/ws": "^8.5.12",
68
82
  "knip": "^6.34.0",
@@ -83,7 +97,7 @@
83
97
  }
84
98
  },
85
99
  "optionalDependencies": {
86
- "@antelopejs/interface-dms-builder": ">=0.1.3 <1.0.0"
100
+ "@antelopejs/interface-dms-builder": ">=0.2.1 <1.0.0"
87
101
  },
88
102
  "packageManager": "pnpm@10.2.0",
89
103
  "antelopeJs": {
@@ -1,6 +1,7 @@
1
1
  import { BUILDER_TOOL_NAMES } from "./builder.js";
2
2
  import { QUERY_LOGS_TOOL_NAME } from "./logs.js";
3
3
  import { FIND_PAGES_TOOL_NAME, LIST_PAGES_TOOL_NAME } from "./pages.js";
4
+ import { PREVIEW_BLOCKS_TOOL_NAME } from "./preview.js";
4
5
  import { REQUEST_TIMEOUT_MINUTES } from "../state/settings-types.js";
5
6
  import { QUESTION_TIMEOUT_MS } from "./questions.js";
6
7
  import { MS_PER_MINUTE } from "./settings.js";
@@ -35,6 +36,7 @@ export const FIRST_PARTY_AUTO_ALLOW_TOOL_NAMES = new Set([
35
36
  FIND_PAGES_TOOL_NAME,
36
37
  LIST_PAGES_TOOL_NAME,
37
38
  ASK_USER_TOOL_NAME,
39
+ PREVIEW_BLOCKS_TOOL_NAME,
38
40
  TYPECHECK_TOOL_NAME,
39
41
  QUERY_LOGS_TOOL_NAME,
40
42
  ...BUILDER_TOOL_NAMES,