@agent-native/core 0.126.0 → 0.127.3

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 (181) hide show
  1. package/corpus/README.md +2 -2
  2. package/corpus/core/CHANGELOG.md +83 -0
  3. package/corpus/core/docs/content/external-agents-catalog.mdx +14 -11
  4. package/corpus/core/docs/content/mcp-protocol.mdx +9 -4
  5. package/corpus/core/package.json +1 -1
  6. package/corpus/core/src/a2a/index.ts +2 -0
  7. package/corpus/core/src/agent/production-agent.ts +6 -1
  8. package/corpus/core/src/agent/run-manager.ts +37 -0
  9. package/corpus/core/src/agent/run-store.ts +18 -0
  10. package/corpus/core/src/cli/template-sync.ts +96 -0
  11. package/corpus/core/src/client/MultiTabAssistantChat.tsx +75 -43
  12. package/corpus/core/src/client/agent-chat.ts +19 -1
  13. package/corpus/core/src/client/chat/tool-call-display.tsx +25 -0
  14. package/corpus/core/src/client/resources/mcp-integration-catalog.ts +23 -0
  15. package/corpus/core/src/client/settings/AgentsSection.tsx +565 -71
  16. package/corpus/core/src/client/settings/SecretsSection.tsx +71 -36
  17. package/corpus/core/src/client/use-chat-models.ts +54 -37
  18. package/corpus/core/src/localization/default-messages.ts +11 -0
  19. package/corpus/core/src/mcp/ask-app-task-handle.ts +183 -0
  20. package/corpus/core/src/mcp/builtin-tools.ts +146 -34
  21. package/corpus/core/src/server/agent-capabilities.ts +8 -1
  22. package/corpus/core/src/server/agent-chat-plugin.ts +17 -0
  23. package/corpus/core/src/server/agent-discovery.ts +1 -1
  24. package/corpus/core/src/server/agent-peer-probe.ts +133 -0
  25. package/corpus/core/src/server/builder-design-systems.ts +1 -1
  26. package/corpus/core/src/server/core-routes-plugin.ts +67 -0
  27. package/corpus/core/src/shared/mcp-embed-headers.ts +4 -0
  28. package/corpus/core/src/templates/chat/_gitignore +2 -0
  29. package/corpus/core/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +159 -119
  30. package/corpus/templates/chat/_gitignore +2 -0
  31. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +23 -0
  32. package/corpus/templates/design/actions/create-file.ts +15 -0
  33. package/corpus/templates/design/actions/generate-design.ts +40 -0
  34. package/corpus/templates/design/actions/present-design-variants.ts +16 -0
  35. package/corpus/templates/design/app/hooks/use-question-flow.ts +21 -1
  36. package/corpus/templates/design/app/pages/design-editor/save-failure.ts +7 -1
  37. package/corpus/templates/design/changelog/2026-07-28-generated-designs-with-malformed-html-are-now-rejected-at-sa.md +6 -0
  38. package/corpus/templates/design/changelog/2026-07-28-the-model-you-pick-when-starting-a-new-design-is-now-used-fo.md +6 -0
  39. package/corpus/templates/design/server/source-workspace.ts +1 -0
  40. package/corpus/templates/design/shared/html-integrity.ts +608 -18
  41. package/corpus/templates/dispatch/changelog/2026-07-28-pending-apps-are-hidden-by-default-and-app-ownership-details.md +6 -0
  42. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +2 -6
  43. package/corpus/templates/slides/app/components/editor/EditorSidebar.tsx +5 -1
  44. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +5 -0
  45. package/corpus/templates/slides/app/components/editor/ExportMenu.tsx +31 -8
  46. package/corpus/templates/slides/app/components/editor/PromptDialog.tsx +15 -0
  47. package/corpus/templates/slides/app/components/presentation/PresentationView.tsx +78 -8
  48. package/corpus/templates/slides/app/components/presentation/PresenterView.tsx +179 -0
  49. package/corpus/templates/slides/app/components/presentation/present-channel.ts +15 -0
  50. package/corpus/templates/slides/app/i18n/en-US.ts +10 -1
  51. package/corpus/templates/slides/app/lib/export-google-slides-client.ts +67 -0
  52. package/corpus/templates/slides/app/lib/export-pptx-client.ts +16 -3
  53. package/corpus/templates/slides/app/pages/DeckEditor.tsx +11 -0
  54. package/corpus/templates/slides/app/pages/Presentation.tsx +11 -3
  55. package/corpus/templates/slides/changelog/2026-07-28-export-to-google-slides-now-creates-the-deck-directly-in-you.md +6 -0
  56. package/corpus/templates/slides/changelog/2026-07-28-opening-a-presentation-link-directly-no-longer-bounces-back-.md +6 -0
  57. package/corpus/templates/slides/changelog/2026-07-28-presenter-view-open-a-second-window-while-presenting-to-see-.md +6 -0
  58. package/corpus/templates/slides/server/routes/api/exports/google-slides.post.ts +90 -0
  59. package/corpus/toolkit/CHANGELOG.md +6 -0
  60. package/corpus/toolkit/package.json +1 -1
  61. package/corpus/toolkit/src/composer/TiptapComposer.tsx +143 -111
  62. package/corpus/toolkit/src/composer/runtime-adapters.tsx +2 -0
  63. package/dist/a2a/index.d.ts +2 -0
  64. package/dist/a2a/index.d.ts.map +1 -1
  65. package/dist/a2a/index.js +1 -0
  66. package/dist/a2a/index.js.map +1 -1
  67. package/dist/agent/production-agent.d.ts +2 -2
  68. package/dist/agent/production-agent.d.ts.map +1 -1
  69. package/dist/agent/production-agent.js +6 -3
  70. package/dist/agent/production-agent.js.map +1 -1
  71. package/dist/agent/run-manager.d.ts +11 -0
  72. package/dist/agent/run-manager.d.ts.map +1 -1
  73. package/dist/agent/run-manager.js +34 -1
  74. package/dist/agent/run-manager.js.map +1 -1
  75. package/dist/agent/run-store.d.ts +8 -0
  76. package/dist/agent/run-store.d.ts.map +1 -1
  77. package/dist/agent/run-store.js +16 -0
  78. package/dist/agent/run-store.js.map +1 -1
  79. package/dist/cli/template-sync.d.ts.map +1 -1
  80. package/dist/cli/template-sync.js +88 -0
  81. package/dist/cli/template-sync.js.map +1 -1
  82. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  83. package/dist/client/MultiTabAssistantChat.js +60 -31
  84. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  85. package/dist/client/agent-chat.d.ts +6 -0
  86. package/dist/client/agent-chat.d.ts.map +1 -1
  87. package/dist/client/agent-chat.js +13 -1
  88. package/dist/client/agent-chat.js.map +1 -1
  89. package/dist/client/chat/tool-call-display.d.ts.map +1 -1
  90. package/dist/client/chat/tool-call-display.js +14 -0
  91. package/dist/client/chat/tool-call-display.js.map +1 -1
  92. package/dist/client/resources/mcp-integration-catalog.d.ts +6 -0
  93. package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
  94. package/dist/client/resources/mcp-integration-catalog.js +16 -0
  95. package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
  96. package/dist/client/settings/AgentsSection.d.ts.map +1 -1
  97. package/dist/client/settings/AgentsSection.js +250 -30
  98. package/dist/client/settings/AgentsSection.js.map +1 -1
  99. package/dist/client/settings/SecretsSection.d.ts.map +1 -1
  100. package/dist/client/settings/SecretsSection.js +16 -2
  101. package/dist/client/settings/SecretsSection.js.map +1 -1
  102. package/dist/client/use-chat-models.d.ts.map +1 -1
  103. package/dist/client/use-chat-models.js +46 -27
  104. package/dist/client/use-chat-models.js.map +1 -1
  105. package/dist/collab/routes.d.ts +2 -2
  106. package/dist/collab/struct-routes.d.ts +1 -1
  107. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  108. package/dist/localization/default-messages.d.ts +9 -0
  109. package/dist/localization/default-messages.d.ts.map +1 -1
  110. package/dist/localization/default-messages.js +9 -0
  111. package/dist/localization/default-messages.js.map +1 -1
  112. package/dist/mcp/ask-app-task-handle.d.ts +25 -0
  113. package/dist/mcp/ask-app-task-handle.d.ts.map +1 -0
  114. package/dist/mcp/ask-app-task-handle.js +136 -0
  115. package/dist/mcp/ask-app-task-handle.js.map +1 -0
  116. package/dist/mcp/builtin-tools.d.ts +3 -3
  117. package/dist/mcp/builtin-tools.d.ts.map +1 -1
  118. package/dist/mcp/builtin-tools.js +115 -38
  119. package/dist/mcp/builtin-tools.js.map +1 -1
  120. package/dist/observability/routes.d.ts +3 -3
  121. package/dist/progress/routes.d.ts +1 -1
  122. package/dist/provider-api/actions/custom-provider-registration.d.ts +4 -4
  123. package/dist/provider-api/actions/provider-api.d.ts +8 -8
  124. package/dist/resources/handlers.d.ts +1 -1
  125. package/dist/secrets/routes.d.ts +9 -9
  126. package/dist/server/agent-capabilities.d.ts +7 -1
  127. package/dist/server/agent-capabilities.d.ts.map +1 -1
  128. package/dist/server/agent-capabilities.js +1 -0
  129. package/dist/server/agent-capabilities.js.map +1 -1
  130. package/dist/server/agent-chat-plugin.d.ts +7 -0
  131. package/dist/server/agent-chat-plugin.d.ts.map +1 -1
  132. package/dist/server/agent-chat-plugin.js +16 -1
  133. package/dist/server/agent-chat-plugin.js.map +1 -1
  134. package/dist/server/agent-discovery.d.ts +1 -0
  135. package/dist/server/agent-discovery.d.ts.map +1 -1
  136. package/dist/server/agent-discovery.js +1 -1
  137. package/dist/server/agent-discovery.js.map +1 -1
  138. package/dist/server/agent-peer-probe.d.ts +37 -0
  139. package/dist/server/agent-peer-probe.d.ts.map +1 -0
  140. package/dist/server/agent-peer-probe.js +90 -0
  141. package/dist/server/agent-peer-probe.js.map +1 -0
  142. package/dist/server/builder-design-systems.js +1 -1
  143. package/dist/server/builder-design-systems.js.map +1 -1
  144. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  145. package/dist/server/core-routes-plugin.js +53 -0
  146. package/dist/server/core-routes-plugin.js.map +1 -1
  147. package/dist/server/realtime-token.d.ts +1 -1
  148. package/dist/server/transcribe-voice.d.ts +1 -1
  149. package/dist/shared/mcp-embed-headers.d.ts +1 -0
  150. package/dist/shared/mcp-embed-headers.d.ts.map +1 -1
  151. package/dist/shared/mcp-embed-headers.js +4 -0
  152. package/dist/shared/mcp-embed-headers.js.map +1 -1
  153. package/dist/templates/chat/_gitignore +2 -0
  154. package/dist/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +159 -119
  155. package/docs/content/external-agents-catalog.mdx +14 -11
  156. package/docs/content/mcp-protocol.mdx +9 -4
  157. package/package.json +2 -2
  158. package/src/a2a/index.ts +2 -0
  159. package/src/agent/production-agent.ts +6 -1
  160. package/src/agent/run-manager.ts +37 -0
  161. package/src/agent/run-store.ts +18 -0
  162. package/src/cli/template-sync.ts +96 -0
  163. package/src/client/MultiTabAssistantChat.tsx +75 -43
  164. package/src/client/agent-chat.ts +19 -1
  165. package/src/client/chat/tool-call-display.tsx +25 -0
  166. package/src/client/resources/mcp-integration-catalog.ts +23 -0
  167. package/src/client/settings/AgentsSection.tsx +565 -71
  168. package/src/client/settings/SecretsSection.tsx +71 -36
  169. package/src/client/use-chat-models.ts +54 -37
  170. package/src/localization/default-messages.ts +11 -0
  171. package/src/mcp/ask-app-task-handle.ts +183 -0
  172. package/src/mcp/builtin-tools.ts +146 -34
  173. package/src/server/agent-capabilities.ts +8 -1
  174. package/src/server/agent-chat-plugin.ts +17 -0
  175. package/src/server/agent-discovery.ts +1 -1
  176. package/src/server/agent-peer-probe.ts +133 -0
  177. package/src/server/builder-design-systems.ts +1 -1
  178. package/src/server/core-routes-plugin.ts +67 -0
  179. package/src/shared/mcp-embed-headers.ts +4 -0
  180. package/src/templates/chat/_gitignore +2 -0
  181. package/src/templates/workspace-core/.agents/skills/a2a-protocol/SKILL.md +159 -119
@@ -4,6 +4,14 @@
4
4
  */
5
5
  export const DESIGN_HTML_INTEGRITY_ERROR_CODE = "DESIGN_HTML_INTEGRITY";
6
6
 
7
+ /**
8
+ * Human-facing summary for the editor toast. `message` carries the located,
9
+ * agent-facing detail instead — a person dragging on the canvas did not author
10
+ * the markup, so a line and column are noise to them.
11
+ */
12
+ export const DESIGN_HTML_INTEGRITY_SUMMARY =
13
+ "The edit was not applied because it would make the design HTML invalid.";
14
+
7
15
  export type DesignHtmlIntegrityIssue =
8
16
  | "document-boundary"
9
17
  | "document-root"
@@ -11,27 +19,146 @@ export type DesignHtmlIntegrityIssue =
11
19
  | "document-head"
12
20
  | "raw-text-balance"
13
21
  | "managed-marker-orphaned"
14
- | "managed-marker-duplicated";
22
+ | "managed-marker-duplicated"
23
+ | "attribute-unterminated"
24
+ | "element-unclosed"
25
+ | "close-tag-orphaned"
26
+ | "content-truncated"
27
+ | "runtime-missing";
28
+
29
+ /**
30
+ * Reporting the symptom instead of the cause sends the fix to the wrong line:
31
+ * an unterminated quote in `<head>` swallows the root tags, which reads as a
32
+ * missing `<html>` unless the quote itself is named.
33
+ */
34
+ export interface DesignHtmlIntegrityIssueDetail {
35
+ issue: DesignHtmlIntegrityIssue;
36
+ /** 1-based. */
37
+ line: number;
38
+ /** 1-based. */
39
+ column: number;
40
+ /** The offending source line, bounded for readability. */
41
+ excerpt: string;
42
+ tag?: string;
43
+ attribute?: string;
44
+ /** The tag that arrived where this element's close belonged, if any. */
45
+ closedBy?: { tag: string; line: number };
46
+ }
15
47
 
16
48
  export interface DesignHtmlIntegrityResult {
17
49
  valid: boolean;
18
50
  issue?: DesignHtmlIntegrityIssue;
51
+ /** Present only when invalid; first entry corresponds to `issue`. */
52
+ detail?: DesignHtmlIntegrityIssueDetail[];
53
+ /** Present only when non-empty. Never blocks a write. */
54
+ advisory?: DesignHtmlIntegrityIssueDetail[];
55
+ }
56
+
57
+ /** Cap cascades: one unclosed tag can leave a dozen ancestors unbalanced. */
58
+ const MAX_REPORTED_ISSUES = 3;
59
+
60
+ const DOCUMENT_SHAPE_MESSAGES: Partial<
61
+ Record<DesignHtmlIntegrityIssue, string>
62
+ > = {
63
+ "document-root":
64
+ "the document must have exactly one <html> element with a matching </html>",
65
+ "document-body":
66
+ "the document must have exactly one <body> element with a matching </body>",
67
+ "document-head":
68
+ "the document must have at most one <head> element, with a matching </head> if present",
69
+ "document-boundary":
70
+ "the document's <html>/<body> tags are out of order, or content sits outside <html>",
71
+ "raw-text-balance":
72
+ "a <style>, <script>, <textarea>, or <title> element is missing its opening or closing tag",
73
+ "managed-marker-orphaned":
74
+ "an editor-managed <style>/<script> marker is no longer attached to its element — it was likely split by a partial edit",
75
+ "managed-marker-duplicated":
76
+ "an editor-managed <style>/<script> block appears more than once; there must be exactly one of each",
77
+ };
78
+
79
+ export function describeDesignHtmlIntegrityIssue(
80
+ detail: DesignHtmlIntegrityIssueDetail,
81
+ ): string {
82
+ const at = `line ${detail.line} col ${detail.column}`;
83
+ switch (detail.issue) {
84
+ case "attribute-unterminated":
85
+ return (
86
+ `the ${detail.attribute ? `\`${detail.attribute}\`` : "attribute"} value on ` +
87
+ `<${detail.tag ?? "element"}> at ${at} is never closed. The HTML parser absorbs ` +
88
+ `everything after it into that attribute — including any markup, <style>, or ` +
89
+ `<script> that follows — so the rest of the document silently stops applying. ` +
90
+ `Close the quote.`
91
+ );
92
+ case "element-unclosed":
93
+ return detail.closedBy
94
+ ? `<${detail.tag}> opened at ${at} is never closed; the next closing tag is ` +
95
+ `</${detail.closedBy.tag}> on line ${detail.closedBy.line}, which belongs to an ` +
96
+ `ancestor. Everything between them gets nested inside <${detail.tag}>. ` +
97
+ `Add the missing </${detail.tag}>.`
98
+ : `<${detail.tag}> opened at ${at} is never closed before the document ends. ` +
99
+ `Add the missing </${detail.tag}>.`;
100
+ case "close-tag-orphaned":
101
+ return (
102
+ `</${detail.tag}> at ${at} closes an element that was never opened. ` +
103
+ `Remove the stray closing tag, or add the matching <${detail.tag}>.`
104
+ );
105
+ case "content-truncated":
106
+ return (
107
+ `the content ends mid-markup at ${at} — the final tag or comment is never ` +
108
+ `terminated. This is the signature of a payload that was cut off in transit; ` +
109
+ `re-send this file complete.`
110
+ );
111
+ case "runtime-missing":
112
+ return (
113
+ `no Tailwind runtime is reachable from this document (expected a ` +
114
+ `<script src="…@tailwindcss/browser@4"> or a <style type="text/tailwindcss">). ` +
115
+ `Utility classes will not apply and the design renders unstyled.`
116
+ );
117
+ default:
118
+ return `the document structure is invalid (${detail.issue}) at ${at}.`;
119
+ }
19
120
  }
20
121
 
21
122
  export class DesignHtmlIntegrityError extends Error {
22
123
  readonly code = DESIGN_HTML_INTEGRITY_ERROR_CODE;
23
124
  readonly status = 422;
24
125
  readonly issue: DesignHtmlIntegrityIssue;
126
+ readonly detail?: DesignHtmlIntegrityIssueDetail[];
25
127
 
26
- constructor(issue: DesignHtmlIntegrityIssue) {
27
- super(
28
- `${DESIGN_HTML_INTEGRITY_ERROR_CODE}: The edit was not applied because it would make the design HTML invalid.`,
29
- );
128
+ constructor(
129
+ issue: DesignHtmlIntegrityIssue,
130
+ options: {
131
+ filename?: string;
132
+ detail?: DesignHtmlIntegrityIssueDetail[];
133
+ } = {},
134
+ ) {
135
+ const where = options.filename ? `${options.filename}: ` : "";
136
+ const explained = options.detail?.length
137
+ ? options.detail
138
+ .map(
139
+ (entry) =>
140
+ `${describeDesignHtmlIntegrityIssue(entry)}\n\n ${entry.line} | ${entry.excerpt}`,
141
+ )
142
+ .join("\n\n")
143
+ : // Whole-document properties have no single offending character, but must
144
+ // still name which property failed.
145
+ `${DOCUMENT_SHAPE_MESSAGES[issue] ?? "the design HTML is invalid"}. The write was not applied.`;
146
+ super(`${DESIGN_HTML_INTEGRITY_ERROR_CODE}: ${where}${explained}`);
30
147
  this.name = "DesignHtmlIntegrityError";
31
148
  this.issue = issue;
149
+ this.detail = options.detail;
32
150
  }
33
151
  }
34
152
 
153
+ /**
154
+ * Bodies are text, not markup — mis-tokenizing these turns a `'</div>'` string
155
+ * inside Alpine JavaScript into a phantom structural error. Both passes must
156
+ * agree on this set: when only one treated `<title>`/`<textarea>` as raw text,
157
+ * literal `<body>` text inside a title reached the root-tag count as real markup.
158
+ */
159
+ const RAW_TEXT_TAGS = new Set(["script", "style", "textarea", "title"]);
160
+ type RawTextTag = "script" | "style" | "textarea" | "title";
161
+
35
162
  const MANAGED_RAW_TEXT_MARKERS = [
36
163
  { marker: "data-agent-native-breakpoints", tag: "style" },
37
164
  { marker: "data-agent-native-state-breakpoints", tag: "style" },
@@ -197,7 +324,7 @@ function scanRawTextTags(value: string): RawTextScan {
197
324
  // seen, ignore every tag-like token except that element's own closer. This
198
325
  // mirrors browser tokenization closely enough to avoid rejecting code-heavy
199
326
  // Alpine documents while still detecting an orphaned closer/missing opener.
200
- let active: "style" | "script" | null = null;
327
+ let active: RawTextTag | null = null;
201
328
  let bodyStart = -1;
202
329
  let severity = 0;
203
330
  const bodyRanges: RawTextScan["bodyRanges"] = [];
@@ -206,7 +333,7 @@ function scanRawTextTags(value: string): RawTextScan {
206
333
  if (active) {
207
334
  // HTML raw-text elements terminate at the first matching end-tag token,
208
335
  // even when that text happens to look like a JavaScript/CSS string.
209
- const closer = new RegExp(`<\\s*\\/\\s*${active}\\b[^>]*>`, "gi");
336
+ const closer = new RegExp(`<\\s*\\/\\s*${active}(?=[\\s/>])[^>]*>`, "gi");
210
337
  closer.lastIndex = cursor;
211
338
  const match = closer.exec(value);
212
339
  if (!match) break;
@@ -243,10 +370,10 @@ function scanRawTextTags(value: string): RawTextScan {
243
370
  }
244
371
  }
245
372
  const token = value.slice(nextOpen, tokenEnd);
246
- const match = token.match(/^<\s*(\/?)\s*(style|script)\b/i);
247
- if (match) {
373
+ const match = token.match(/^<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9:-]*)\b/i);
374
+ if (match && RAW_TEXT_TAGS.has(match[2]!.toLowerCase())) {
248
375
  const closing = match[1] === "/";
249
- const tag = match[2]!.toLowerCase() as "style" | "script";
376
+ const tag = match[2]!.toLowerCase() as RawTextTag;
250
377
  if (closing) severity += 1;
251
378
  else {
252
379
  active = tag;
@@ -259,6 +386,391 @@ function scanRawTextTags(value: string): RawTextScan {
259
386
  return { severity: severity + (active ? 1 : 0), bodyRanges };
260
387
  }
261
388
 
389
+ // ---------------------------------------------------------------------------
390
+ // Structural pass. Counting tokens is blind to order, so an unclosed <div> or
391
+ // a stray </section> leaves every root count above intact — those need a stack
392
+ // walk, and they are the defects browsers recover from most invisibly.
393
+ // ---------------------------------------------------------------------------
394
+
395
+ /** Closing tag is forbidden, so these never reach the stack. */
396
+ const VOID_TAGS = new Set([
397
+ "area",
398
+ "base",
399
+ "br",
400
+ "col",
401
+ "embed",
402
+ "hr",
403
+ "img",
404
+ "input",
405
+ "link",
406
+ "meta",
407
+ "param",
408
+ "source",
409
+ "track",
410
+ "wbr",
411
+ ]);
412
+
413
+ /**
414
+ * Closing tag optional per HTML5, so omitting one is legal authoring.
415
+ * `html`/`head`/`body` belong here because the counting pass already owns them.
416
+ */
417
+ const OPTIONAL_CLOSE_TAGS = new Set([
418
+ "body",
419
+ "caption",
420
+ "colgroup",
421
+ "dd",
422
+ "dt",
423
+ "head",
424
+ "html",
425
+ "li",
426
+ "optgroup",
427
+ "option",
428
+ "p",
429
+ "rb",
430
+ "rp",
431
+ "rt",
432
+ "rtc",
433
+ "tbody",
434
+ "td",
435
+ "tfoot",
436
+ "th",
437
+ "thead",
438
+ "tr",
439
+ ]);
440
+
441
+ /** Opening one of these implicitly closes the listed open sibling. */
442
+ const IMPLICIT_SIBLING_CLOSE = new Map<string, Set<string>>([
443
+ ["li", new Set(["li"])],
444
+ ["p", new Set(["p"])],
445
+ ["td", new Set(["td", "th"])],
446
+ ["th", new Set(["td", "th"])],
447
+ ["tr", new Set(["tr", "td", "th"])],
448
+ ["dt", new Set(["dt", "dd"])],
449
+ ["dd", new Set(["dt", "dd"])],
450
+ ["option", new Set(["option"])],
451
+ ["optgroup", new Set(["optgroup", "option"])],
452
+ ["tbody", new Set(["thead", "tbody", "tr", "td", "th"])],
453
+ ["tfoot", new Set(["thead", "tbody", "tr", "td", "th"])],
454
+ ]);
455
+
456
+ const MAX_EXCERPT_CHARS = 120;
457
+
458
+ /**
459
+ * Deliberately conservative: a miss costs one unreported advisory, a false hit
460
+ * costs trust in every advisory after it.
461
+ */
462
+ const USES_TAILWIND_UTILITIES =
463
+ /\bclass\s*=\s*["'][^"']*(?:\b(?:flex|grid|hidden|absolute|relative|sticky)\b|\b(?:p|m|px|py|mx|my|pt|pb|pl|pr|gap|w|h|text|bg|border|rounded|shadow|items|justify|font|leading|tracking|space-x|space-y|min-h|max-w|opacity|ring|z)-[a-z0-9[\]./-]+)/i;
464
+
465
+ type Locator = (index: number) => {
466
+ line: number;
467
+ column: number;
468
+ excerpt: string;
469
+ };
470
+
471
+ /**
472
+ * Scanning to the offset per call made validation quadratic on VALID documents,
473
+ * not just malformed ones — a 117KB screen cost ~700ms on every save. Index the
474
+ * line starts once, lazily, and binary search.
475
+ */
476
+ function createLocator(value: string): Locator {
477
+ let starts: number[] | null = null;
478
+ return (index) => {
479
+ if (!starts) {
480
+ starts = [0];
481
+ for (let cursor = 0; cursor < value.length; cursor += 1) {
482
+ if (value[cursor] === "\n") starts.push(cursor + 1);
483
+ }
484
+ }
485
+ let low = 0;
486
+ let high = starts.length - 1;
487
+ while (low < high) {
488
+ const mid = (low + high + 1) >> 1;
489
+ if (starts[mid]! <= index) low = mid;
490
+ else high = mid - 1;
491
+ }
492
+ const lineStart = starts[low]!;
493
+ let lineEnd = value.indexOf("\n", lineStart);
494
+ if (lineEnd === -1) lineEnd = value.length;
495
+ const raw = value.slice(lineStart, lineEnd).trim();
496
+ return {
497
+ line: low + 1,
498
+ column: index - lineStart + 1,
499
+ excerpt:
500
+ raw.length > MAX_EXCERPT_CHARS
501
+ ? `${raw.slice(0, MAX_EXCERPT_CHARS)}…`
502
+ : raw,
503
+ };
504
+ };
505
+ }
506
+
507
+ interface TagScan {
508
+ end: number;
509
+ /** False when EOF arrived before the tag's `>`. */
510
+ terminated: boolean;
511
+ /** Whether EOF arrived inside a quoted value — the truncation cause. */
512
+ quoteOpen: boolean;
513
+ /** The attribute that quote belonged to, when one was named. */
514
+ unterminatedAttribute?: string;
515
+ }
516
+
517
+ /**
518
+ * A raw-text end tag only closes the element when the name is followed by
519
+ * whitespace, `/`, or `>`. Matching on a word boundary instead treats script
520
+ * text like `"</script=template>"` as the closer, which orphans the real one.
521
+ */
522
+ function rawTextCloser(tag: string): RegExp {
523
+ return new RegExp(`<\\s*/\\s*${tag}(?=[\\s/>])`, "gi");
524
+ }
525
+
526
+ /** Consume one markup token starting at `start` (the `<`), honoring quotes. */
527
+ function scanTag(value: string, start: number): TagScan {
528
+ let quote: '"' | "'" | null = null;
529
+ let pendingAttribute: string | undefined;
530
+ let quotedAttribute: string | undefined;
531
+ let word = "";
532
+ for (let cursor = start; cursor < value.length; cursor += 1) {
533
+ const character = value[cursor]!;
534
+ if (quote) {
535
+ if (character === quote) {
536
+ quote = null;
537
+ quotedAttribute = undefined;
538
+ }
539
+ continue;
540
+ }
541
+ if (character === '"' || character === "'") {
542
+ quote = character;
543
+ quotedAttribute = pendingAttribute;
544
+ continue;
545
+ }
546
+ if (character === ">") {
547
+ return { end: cursor + 1, terminated: true, quoteOpen: false };
548
+ }
549
+ if (character === "=") {
550
+ if (word) pendingAttribute = word;
551
+ word = "";
552
+ continue;
553
+ }
554
+ if (character === "<" || character === "/" || /\s/.test(character)) {
555
+ word = "";
556
+ continue;
557
+ }
558
+ word += character;
559
+ }
560
+ return {
561
+ end: value.length,
562
+ terminated: false,
563
+ quoteOpen: quote !== null,
564
+ unterminatedAttribute: quotedAttribute,
565
+ };
566
+ }
567
+
568
+ /**
569
+ * One parse for both facts. Deriving `isClose` separately let `< /div>` be read
570
+ * as a close tag by one check and an open tag by the other.
571
+ */
572
+ function tagAt(
573
+ value: string,
574
+ index: number,
575
+ ): { tag: string; isClose: boolean } | null {
576
+ const match = /^<(\s*\/)?\s*([a-zA-Z][a-zA-Z0-9:-]*)/.exec(
577
+ value.slice(index, index + 64),
578
+ );
579
+ return match
580
+ ? { tag: match[2]!.toLowerCase(), isClose: match[1] !== undefined }
581
+ : null;
582
+ }
583
+
584
+ /**
585
+ * Runs on fragments as well as documents — an unterminated quote is as
586
+ * destructive in a `<template>` snippet as in a full page.
587
+ */
588
+ function collectStructuralIssues(
589
+ value: string,
590
+ ): DesignHtmlIntegrityIssueDetail[] {
591
+ const issues: DesignHtmlIntegrityIssueDetail[] = [];
592
+ const stack: Array<{ tag: string; start: number }> = [];
593
+ const locate = createLocator(value);
594
+ let cursor = 0;
595
+
596
+ while (cursor < value.length) {
597
+ const open = value.indexOf("<", cursor);
598
+ if (open === -1) break;
599
+
600
+ if (value.startsWith("<!--", open)) {
601
+ const commentEnd = value.indexOf("-->", open + 4);
602
+ if (commentEnd === -1) {
603
+ issues.push({ issue: "content-truncated", ...locate(open) });
604
+ return issues;
605
+ }
606
+ cursor = commentEnd + 3;
607
+ continue;
608
+ }
609
+
610
+ if (value.startsWith("<!", open)) {
611
+ cursor = Math.max(scanTag(value, open).end, open + 1);
612
+ continue;
613
+ }
614
+
615
+ const parsed = tagAt(value, open);
616
+ if (!parsed) {
617
+ // A bare `<` in text content. Not markup, not a defect.
618
+ cursor = open + 1;
619
+ continue;
620
+ }
621
+ const { tag, isClose } = parsed;
622
+
623
+ const scan = scanTag(value, open);
624
+ if (!scan.terminated) {
625
+ // Anything after an unclosed tag would be invented structure. Stop here.
626
+ const located = locate(open);
627
+ issues.push(
628
+ scan.quoteOpen
629
+ ? {
630
+ issue: "attribute-unterminated",
631
+ ...located,
632
+ tag,
633
+ attribute: scan.unterminatedAttribute,
634
+ }
635
+ : { issue: "content-truncated", ...located, tag },
636
+ );
637
+ return issues;
638
+ }
639
+
640
+ if (isClose) {
641
+ let matched = -1;
642
+ for (let index = stack.length - 1; index >= 0; index -= 1) {
643
+ if (stack[index]!.tag === tag) {
644
+ matched = index;
645
+ break;
646
+ }
647
+ }
648
+ if (matched === -1) {
649
+ if (!OPTIONAL_CLOSE_TAGS.has(tag) && !VOID_TAGS.has(tag)) {
650
+ issues.push({ issue: "close-tag-orphaned", ...locate(open), tag });
651
+ }
652
+ } else {
653
+ // Located lazily: computing this for every balanced close tag is what
654
+ // made a valid document quadratic.
655
+ let closeLine: number | null = null;
656
+ for (let index = stack.length - 1; index > matched; index -= 1) {
657
+ const abandoned = stack[index]!;
658
+ if (OPTIONAL_CLOSE_TAGS.has(abandoned.tag)) continue;
659
+ closeLine ??= locate(open).line;
660
+ issues.push({
661
+ issue: "element-unclosed",
662
+ ...locate(abandoned.start),
663
+ tag: abandoned.tag,
664
+ closedBy: { tag, line: closeLine },
665
+ });
666
+ }
667
+ stack.length = matched;
668
+ }
669
+ cursor = scan.end;
670
+ continue;
671
+ }
672
+
673
+ const selfClosing = /\/\s*>$/.test(value.slice(open, scan.end));
674
+
675
+ if (RAW_TEXT_TAGS.has(tag) && !selfClosing) {
676
+ // Resume AT the closing tag so the close branch pops this element,
677
+ // rather than duplicating that logic here. An unterminated body is left
678
+ // to the raw-text-balance check, which names that cause correctly.
679
+ const closer = rawTextCloser(tag);
680
+ closer.lastIndex = scan.end;
681
+ const found = closer.exec(value);
682
+ if (!found) {
683
+ // Report here rather than deferring to the document-only raw-text
684
+ // balance check: fragments never reach that check, so an unclosed
685
+ // <script> would pass the well-formedness gate entirely.
686
+ issues.push({
687
+ issue: "raw-text-balance",
688
+ ...locate(open),
689
+ tag,
690
+ });
691
+ return issues;
692
+ }
693
+ stack.push({ tag, start: open });
694
+ cursor = found.index;
695
+ continue;
696
+ }
697
+
698
+ if (VOID_TAGS.has(tag) || selfClosing) {
699
+ cursor = scan.end;
700
+ continue;
701
+ }
702
+
703
+ // An implied close discards the target AND everything opened inside it —
704
+ // the browser closes those too, so popping only the stack top reports a
705
+ // still-open inline descendant (`<li><span>one<li>`) as unclosed.
706
+ const impliedClose = IMPLICIT_SIBLING_CLOSE.get(tag);
707
+ if (impliedClose) {
708
+ for (let index = stack.length - 1; index >= 0; index -= 1) {
709
+ if (!impliedClose.has(stack[index]!.tag)) continue;
710
+ stack.length = index;
711
+ break;
712
+ }
713
+ }
714
+ stack.push({ tag, start: open });
715
+ cursor = scan.end;
716
+ }
717
+
718
+ // Stack order is document order, so stopping at the cap keeps the earliest
719
+ // (outermost) unclosed elements without locating every one of them.
720
+ for (const abandoned of stack) {
721
+ if (issues.length >= MAX_REPORTED_ISSUES) break;
722
+ if (OPTIONAL_CLOSE_TAGS.has(abandoned.tag)) continue;
723
+ issues.push({
724
+ issue: "element-unclosed",
725
+ ...locate(abandoned.start),
726
+ tag: abandoned.tag,
727
+ });
728
+ }
729
+
730
+ return issues
731
+ .sort((left, right) =>
732
+ left.line === right.line
733
+ ? left.column - right.column
734
+ : left.line - right.line,
735
+ )
736
+ .slice(0, MAX_REPORTED_ISSUES);
737
+ }
738
+
739
+ /**
740
+ * Reported, never enforced: legitimate fragments and token-only screens carry no
741
+ * runtime of their own, so blocking here would reject valid work.
742
+ */
743
+ function collectAdvisoryIssues(
744
+ value: string,
745
+ rawTextBodyRanges: RawTextScan["bodyRanges"],
746
+ ): DesignHtmlIntegrityIssueDetail[] {
747
+ if (!isDocumentHtml(value, rawTextBodyRanges)) return [];
748
+ // Only documents that actually depend on utility classes can be broken by a
749
+ // missing runtime. A screen styled entirely through its own CSS needs no
750
+ // Tailwind, and flagging it would train authors to ignore this warning.
751
+ if (!USES_TAILWIND_UTILITIES.test(value)) return [];
752
+ // Comments are not markup: a commented-out runtime tag is not a runtime.
753
+ const value_ = value.replace(/<!--[\s\S]*?-->/g, "");
754
+ const hasTailwindRuntime =
755
+ /<script\b[^>]*\bsrc\s*=\s*(?:"[^"]*tailwind[^"]*"|'[^']*tailwind[^']*')/i.test(
756
+ value_,
757
+ ) ||
758
+ /<style\b[^>]*\btype\s*=\s*(?:"text\/tailwindcss"|'text\/tailwindcss')/i.test(
759
+ value_,
760
+ ) ||
761
+ /<link\b[^>]*\bhref\s*=\s*(?:"[^"]*tailwind[^"]*"|'[^']*tailwind[^']*')/i.test(
762
+ value_,
763
+ );
764
+ if (hasTailwindRuntime) return [];
765
+ const headIndex = value.search(/<head\b/i);
766
+ return [
767
+ {
768
+ issue: "runtime-missing",
769
+ ...createLocator(value)(headIndex === -1 ? 0 : headIndex),
770
+ },
771
+ ];
772
+ }
773
+
262
774
  function markerCounts(
263
775
  value: string,
264
776
  marker: string,
@@ -292,8 +804,28 @@ export function inspectDesignHtmlDocumentIntegrity(
292
804
  value: string,
293
805
  ): DesignHtmlIntegrityResult {
294
806
  const rawText = scanRawTextTags(value);
807
+
808
+ // Structure first, counting second. An unterminated quote swallows the root
809
+ // tags, so the counting pass would report `document-root` — sending the fix to
810
+ // a `<html>` tag that is present and correct instead of to the quote.
811
+ const structural = collectStructuralIssues(value);
812
+ if (structural.length > 0) {
813
+ return {
814
+ valid: false,
815
+ issue: structural[0]!.issue,
816
+ detail: structural,
817
+ };
818
+ }
819
+
295
820
  if (!isDocumentHtml(value, rawText.bodyRanges)) return { valid: true };
296
821
 
822
+ // Before the root counts: an unbalanced raw-text element swallows the tags
823
+ // those counts look for, so checking order decides whether the report names
824
+ // the cause or its effect.
825
+ if (rawText.severity > 0) {
826
+ return { valid: false, issue: "raw-text-balance" };
827
+ }
828
+
297
829
  const html = tagCount(value, "html", rawText.bodyRanges);
298
830
  if (html.open !== 1 || html.close !== 1) {
299
831
  return { valid: false, issue: "document-root" };
@@ -349,10 +881,6 @@ export function inspectDesignHtmlDocumentIntegrity(
349
881
  return { valid: false, issue: "document-boundary" };
350
882
  }
351
883
 
352
- if (rawText.severity > 0) {
353
- return { valid: false, issue: "raw-text-balance" };
354
- }
355
-
356
884
  for (const { marker, tag } of MANAGED_RAW_TEXT_MARKERS) {
357
885
  const counts = markerCounts(value, marker, tag, rawText.bodyRanges);
358
886
  if (counts.raw !== counts.attached) {
@@ -363,7 +891,8 @@ export function inspectDesignHtmlDocumentIntegrity(
363
891
  }
364
892
  }
365
893
 
366
- return { valid: true };
894
+ const advisory = collectAdvisoryIssues(value, rawText.bodyRanges);
895
+ return advisory.length > 0 ? { valid: true, advisory } : { valid: true };
367
896
  }
368
897
 
369
898
  /**
@@ -376,18 +905,79 @@ export function assertDesignHtmlEditIntegrity(args: {
376
905
  previousContent: string;
377
906
  nextContent: string;
378
907
  fileType: string;
908
+ filename?: string;
379
909
  }): void {
380
910
  if (args.fileType.toLowerCase() !== "html") return;
381
911
  const previousIsDocument = isDocumentHtml(args.previousContent);
382
912
  const nextIsDocument = isDocumentHtml(args.nextContent);
383
- if (!previousIsDocument && !nextIsDocument) return;
913
+ // Fragments still get the structural pass; only the document-shape checks
914
+ // below need a document to apply to.
915
+ if (!previousIsDocument && !nextIsDocument) {
916
+ const structural = collectStructuralIssues(args.nextContent);
917
+ if (structural.length > 0) {
918
+ throw new DesignHtmlIntegrityError(structural[0]!.issue, {
919
+ filename: args.filename,
920
+ detail: structural,
921
+ });
922
+ }
923
+ return;
924
+ }
384
925
  if (previousIsDocument && !nextIsDocument) {
385
- throw new DesignHtmlIntegrityError("document-root");
926
+ throw new DesignHtmlIntegrityError("document-root", {
927
+ filename: args.filename,
928
+ });
386
929
  }
387
930
  const result = inspectDesignHtmlDocumentIntegrity(args.nextContent);
388
931
  if (!result.valid) {
389
- throw new DesignHtmlIntegrityError(result.issue ?? "document-root");
932
+ throw new DesignHtmlIntegrityError(result.issue ?? "document-root", {
933
+ filename: args.filename,
934
+ detail: result.detail,
935
+ });
936
+ }
937
+ }
938
+
939
+ /**
940
+ * Creation counterpart to the edit transition above. Every creation path must
941
+ * run this, or a design's first save is the one write with no gate at all.
942
+ *
943
+ * Returns advisory issues for the caller to surface; throws on anything
944
+ * blocking.
945
+ */
946
+ /**
947
+ * Well-formedness only — no document-shape rules. For markup that is not
948
+ * required to be a complete screen, such as a variant sketch, where `<html>` and
949
+ * `<body>` are legitimately implied. Unbalanced tags are defects at any level of
950
+ * completeness; a missing skeleton is not.
951
+ */
952
+ export function assertDesignHtmlWellFormed(args: {
953
+ content: string;
954
+ filename?: string;
955
+ }): void {
956
+ if (!args.content.trim()) return;
957
+ const structural = collectStructuralIssues(args.content);
958
+ if (structural.length > 0) {
959
+ throw new DesignHtmlIntegrityError(structural[0]!.issue, {
960
+ filename: args.filename,
961
+ detail: structural,
962
+ });
963
+ }
964
+ }
965
+
966
+ export function assertDesignHtmlCreateIntegrity(args: {
967
+ content: string;
968
+ fileType: string;
969
+ filename?: string;
970
+ }): DesignHtmlIntegrityIssueDetail[] {
971
+ if ((args.fileType || "html").toLowerCase() !== "html") return [];
972
+ if (!args.content.trim()) return [];
973
+ const result = inspectDesignHtmlDocumentIntegrity(args.content);
974
+ if (!result.valid) {
975
+ throw new DesignHtmlIntegrityError(result.issue ?? "document-root", {
976
+ filename: args.filename,
977
+ detail: result.detail,
978
+ });
390
979
  }
980
+ return result.advisory ?? [];
391
981
  }
392
982
 
393
983
  export function isDesignHtmlIntegrityError(error: unknown): boolean {