@agent-native/core 0.75.2 → 0.75.5

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 (44) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +86 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/durable-background.ts +57 -25
  5. package/corpus/core/src/agent/production-agent.ts +16 -13
  6. package/corpus/core/src/cli/skills.ts +22 -4
  7. package/corpus/core/src/cli/sync-builder-starter-manifest.ts +36 -12
  8. package/corpus/core/src/deploy/build.ts +79 -57
  9. package/corpus/core/src/deploy/workspace-deploy.ts +62 -30
  10. package/corpus/core/src/server/self-dispatch.ts +29 -2
  11. package/corpus/templates/clips/changelog/2026-06-24-chrome-extension-setup-now-shows-a-clear-all-done-confirmati.md +6 -0
  12. package/corpus/templates/clips/chrome-extension/src/permission.html +60 -0
  13. package/corpus/templates/clips/chrome-extension/src/permission.ts +28 -15
  14. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +6 -20
  15. package/corpus/templates/plan/.agents/skills/visual-plan/references/wireframe.md +22 -4
  16. package/corpus/templates/plan/.agents/skills/visual-recap/references/wireframe.md +22 -4
  17. package/corpus/templates/plan/app/components/plan/DocumentArea.tsx +45 -33
  18. package/corpus/templates/plan/changelog/2026-06-24-invalid-generated-recap-blocks-now-show-a-clear-warning-with.md +6 -0
  19. package/corpus/templates/plan/server/plan-content.ts +53 -3
  20. package/dist/agent/durable-background.d.ts +33 -24
  21. package/dist/agent/durable-background.d.ts.map +1 -1
  22. package/dist/agent/durable-background.js +56 -25
  23. package/dist/agent/durable-background.js.map +1 -1
  24. package/dist/agent/production-agent.d.ts.map +1 -1
  25. package/dist/agent/production-agent.js +16 -13
  26. package/dist/agent/production-agent.js.map +1 -1
  27. package/dist/cli/skills.d.ts +1 -1
  28. package/dist/cli/skills.d.ts.map +1 -1
  29. package/dist/cli/skills.js +22 -4
  30. package/dist/cli/skills.js.map +1 -1
  31. package/dist/cli/sync-builder-starter-manifest.d.ts.map +1 -1
  32. package/dist/cli/sync-builder-starter-manifest.js +21 -10
  33. package/dist/cli/sync-builder-starter-manifest.js.map +1 -1
  34. package/dist/deploy/build.d.ts +53 -33
  35. package/dist/deploy/build.d.ts.map +1 -1
  36. package/dist/deploy/build.js +79 -57
  37. package/dist/deploy/build.js.map +1 -1
  38. package/dist/deploy/workspace-deploy.js +62 -30
  39. package/dist/deploy/workspace-deploy.js.map +1 -1
  40. package/dist/server/self-dispatch.d.ts +0 -10
  41. package/dist/server/self-dispatch.d.ts.map +1 -1
  42. package/dist/server/self-dispatch.js +27 -2
  43. package/dist/server/self-dispatch.js.map +1 -1
  44. package/package.json +1 -1
@@ -696,24 +696,30 @@ function isDurableBackgroundDeployEnabled(): boolean {
696
696
  /**
697
697
  * Emit a SECOND Netlify function for `app` whose name ends in `-background`,
698
698
  * re-exporting the SAME `main.mjs` handler bundle. Netlify invokes any function
699
- * whose deployed name ends in `-background` asynchronously (202 immediately, up
700
- * to 15-min budget), which is exactly what the durable-background chat
701
- * dispatch (`fireInternalDispatch` → AGENT_CHAT_PROCESS_RUN_PATH) needs.
699
+ * with `config.background: true` asynchronously (202 immediately, up to 15-min
700
+ * budget), which is exactly what the durable-background chat dispatch
701
+ * (`fireInternalDispatch` → the function's default url) needs.
702
702
  *
703
- * The function is given a `config.path` of the chat process-run route so
704
- * Netlify routes that POST to this async function instead of the synchronous
705
- * `<app>-server` function. It shares the same bundle (`includedFiles: ["**"]`)
706
- * so `A2A_SECRET`, the DB URL, and the rest of the env/bundle are present.
703
+ * DOC-CORRECT DEFAULT-URL APPROACH (mirrors the single-template emit in
704
+ * deploy/build.ts): the function declares NO custom `config.path`, so it keeps
705
+ * its DEFAULT url `/.netlify/functions/<app>-agent-background`. The `<app>-server`
706
+ * function's catch-all already excludes `/.netlify/*`, so that default-url
707
+ * namespace is NEVER shadowed by the synchronous function — no overlapping
708
+ * `config.path` and no catch-all patch are needed. The foreground dispatches to
709
+ * that default url (`resolveAgentChatProcessRunDispatchPath` resolves the per-app
710
+ * name from `AGENT_NATIVE_WORKSPACE_APP_ID`); `fireInternalDispatch` strips the
711
+ * app base path for `/.netlify/*` targets so the request reaches the host-root
712
+ * function url. The entry then REWRITES the incoming pathname to the
713
+ * base-path-prefixed `_process-run` route before delegating to the Nitro router.
707
714
  *
708
- * ⚠️ REAL-DEPLOY VERIFICATION REQUIRED. The exact Netlify routing/precedence
709
- * between this `-background` function's `config.path` and the synchronous
710
- * `<app>-server` function's broader `config.path` (which also matches
711
- * `/_agent-native/*` for the dispatch app, or `/<app>/*` otherwise) cannot be
712
- * verified in this environment. It is emitted additively and only under the
713
- * flag; if Netlify resolves the overlap to the wrong function, the dispatch
714
- * would land on the synchronous function (no 15-min budget) the run would
715
- * still work via the existing 40s soft-timeout path, just without the durable
716
- * win. See packages/core/docs/design/durable-agent-runs.md.
715
+ * It shares the same bundle (`includedFiles: ["**"]`) so `A2A_SECRET`, the DB
716
+ * URL, and the rest of the env/bundle are present. A prior attempt gave the
717
+ * function a custom `config.path` (the framework route) that overlapped the
718
+ * synchronous `<app>-server` catch-all; that path was not honored as a route in
719
+ * prod (probe 404). The default url is the doc-correct fix.
720
+ *
721
+ * Safety net: if the dispatch fast-fails the foreground degrades to an inline
722
+ * 40s synchronous run (see production-agent.ts).
717
723
  */
718
724
  function emitNetlifyBackgroundFunction(
719
725
  workspaceRoot: string,
@@ -721,7 +727,9 @@ function emitNetlifyBackgroundFunction(
721
727
  srcServerDir: string,
722
728
  workspaceApps: WorkspaceAppManifestEntry[],
723
729
  ): void {
724
- // Name MUST end in `-background` for Netlify async invocation.
730
+ // Name MUST end in `-background` (Netlify async convention + the runtime guard
731
+ // reads the -background Lambda-name suffix as a fallback). It is reached at its
732
+ // DEFAULT url /.netlify/functions/<app>-agent-background.
725
733
  const backgroundName = `${app}-agent-background`;
726
734
  const dest = path.join(netlifyFunctionsDir(workspaceRoot), backgroundName);
727
735
  fs.rmSync(dest, { recursive: true, force: true });
@@ -733,14 +741,20 @@ function emitNetlifyBackgroundFunction(
733
741
  workspaceApps,
734
742
  app,
735
743
  );
736
- // Only the chat process-run route needs the async function. Keeping the path
737
- // narrow avoids stealing any other traffic from the synchronous function.
738
- // The dispatch URL is app-base-path-prefixed (same as the agent-teams
739
- // processor dispatch), so the route Netlify must map to this async function
740
- // is `/<app>/_agent-native/agent-chat/_process-run`.
744
+ // The Nitro router for this app expects the base-path-prefixed framework route.
745
+ // The function is reached at its default url, so the entry rewrites the
746
+ // incoming pathname to `/<app>/_agent-native/agent-chat/_process-run`.
741
747
  const processRunPath = `${basePath}${AGENT_CHAT_PROCESS_RUN_PATH}`;
742
- const pathConfig = [processRunPath];
743
- const server = `const basePath = ${JSON.stringify(basePath)};
748
+ const server = `// Mark this isolate as the durable background runtime BEFORE the handler bundle
749
+ // is imported, so isInBackgroundFunctionRuntime() reliably returns true in this
750
+ // function (the deployed Lambda name is not guaranteed to end in -background). A
751
+ // globalThis flag (NOT process.env) avoids the no-env-mutation guard and carries
752
+ // no cross-request state.
753
+ globalThis.__AGENT_NATIVE_BACKGROUND_RUNTIME__ = true;
754
+
755
+ const basePath = ${JSON.stringify(basePath)};
756
+ // The base-path-prefixed framework route the Nitro router dispatches to.
757
+ const PROCESS_RUN_PATH = ${JSON.stringify(processRunPath)};
744
758
 
745
759
  function setBasePathEnv() {
746
760
  const processRef = globalThis.process ??= { env: {} };
@@ -767,16 +781,33 @@ setBasePathEnv();
767
781
 
768
782
  let cachedHandler;
769
783
 
770
- export default async function handler(...args) {
784
+ // Reached at the DEFAULT url /.netlify/functions/${backgroundName}; REWRITE the
785
+ // incoming pathname to the base-path-prefixed _process-run route so the Nitro
786
+ // router runs the plugin. Method, ALL headers (the HMAC Authorization: Bearer
787
+ // MUST survive) and the body are preserved.
788
+ export default async function handler(request) {
771
789
  setBasePathEnv();
772
790
  cachedHandler ??= (await import("./main.mjs")).default;
773
- return cachedHandler(...args);
791
+ const url = new URL(request.url);
792
+ url.pathname = PROCESS_RUN_PATH;
793
+ const method = request.method || "POST";
794
+ const hasBody = method !== "GET" && method !== "HEAD";
795
+ const body = hasBody ? await request.text() : undefined;
796
+ const rewritten = new Request(url.toString(), {
797
+ method,
798
+ headers: request.headers,
799
+ body,
800
+ });
801
+ return cachedHandler(rewritten);
774
802
  }
775
803
 
776
804
  export const config = {
777
805
  name: ${JSON.stringify(`${app} agent background handler`)},
778
806
  generator: "agent-native workspace deploy",
779
- path: ${JSON.stringify(pathConfig)},
807
+ // background: true → async invoke (202, 15-min budget). NO custom path: the
808
+ // function keeps its default url /.netlify/functions/${backgroundName}, which
809
+ // the <app>-server catch-all never shadows (it excludes /.netlify/*).
810
+ background: true,
780
811
  nodeBundler: "none",
781
812
  includedFiles: ["**"],
782
813
  preferStatic: false,
@@ -788,9 +819,10 @@ export const config = {
788
819
  fs.writeFileSync(path.join(dest, `${backgroundName}.mjs`), server);
789
820
  console.log(
790
821
  `[workspace-deploy] Emitted durable-background function "${backgroundName}" ` +
791
- `for app "${app}" (path ${processRunPath}). ` +
792
- `REQUIRES real-deploy verification of Netlify async routing — see ` +
793
- `docs/design/durable-agent-runs.md.`,
822
+ `for app "${app}" with config { background:true } and NO custom path — ` +
823
+ `reachable at its default url /.netlify/functions/${backgroundName} ` +
824
+ `(rewrites to ${processRunPath}). REQUIRES real-deploy verification of ` +
825
+ `Netlify async (202) invocation — see docs/design/durable-agent-runs.md.`,
794
826
  );
795
827
  }
796
828
 
@@ -20,7 +20,10 @@
20
20
  * A2A, integration webhooks, and Agent Teams sub-agents share one tested
21
21
  * implementation.
22
22
  */
23
- import { withConfiguredAppBasePath } from "./app-base-path.js";
23
+ import {
24
+ getConfiguredAppBasePath,
25
+ withConfiguredAppBasePath,
26
+ } from "./app-base-path.js";
24
27
  import { isLocalDatabase } from "../db/client.js";
25
28
  import { signInternalToken } from "../integrations/internal-token.js";
26
29
 
@@ -119,11 +122,35 @@ async function dispatchResponseError(
119
122
  * processor accepts unsigned dispatches in dev and relies on the SQL atomic
120
123
  * claim for double-processing protection, mirroring the A2A/webhook flow.
121
124
  */
125
+ /**
126
+ * For host-root dispatch targets (`/.netlify/functions/*`), strip the configured
127
+ * app base path suffix from the resolved base url so the request reaches the
128
+ * function at the host root rather than under the workspace app base path. For
129
+ * every other (framework-route) path the base-path-prefixed base url is returned
130
+ * unchanged, preserving the existing self-dispatch behavior.
131
+ */
132
+ function rootBaseUrlForPath(baseUrl: string, path: string): string {
133
+ if (!path.startsWith("/.netlify/")) return baseUrl;
134
+ const basePath = getConfiguredAppBasePath();
135
+ if (!basePath) return baseUrl;
136
+ const trimmed = baseUrl.replace(/\/$/, "");
137
+ if (trimmed.endsWith(basePath)) {
138
+ return trimmed.slice(0, trimmed.length - basePath.length);
139
+ }
140
+ return trimmed;
141
+ }
142
+
122
143
  export async function fireInternalDispatch(
123
144
  options: FireInternalDispatchOptions,
124
145
  ): Promise<void> {
125
146
  const baseUrl = options.baseUrl ?? resolveSelfDispatchBaseUrl(options.event);
126
- const url = `${baseUrl}${options.path}`;
147
+ // Netlify function default urls (`/.netlify/functions/<name>`) live at the
148
+ // HOST ROOT, not under the workspace app base path. `resolveSelfDispatchBaseUrl`
149
+ // appends the configured base path (e.g. `https://host/starter`) so framework
150
+ // routes land on the right app; for a host-root function url we must dispatch
151
+ // to `https://host/.netlify/functions/<name>` instead. Strip the base path
152
+ // suffix from the resolved base url for `/.netlify/*` dispatch targets only.
153
+ const url = `${rootBaseUrlForPath(baseUrl, options.path)}${options.path}`;
127
154
  const headers: Record<string, string> = {
128
155
  "Content-Type": "application/json",
129
156
  };
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-06-24
4
+ ---
5
+
6
+ Chrome extension setup now shows a clear all-done confirmation when permissions are ready.
@@ -52,6 +52,9 @@
52
52
  * {
53
53
  box-sizing: border-box;
54
54
  }
55
+ [hidden] {
56
+ display: none !important;
57
+ }
55
58
  body {
56
59
  margin: 0;
57
60
  min-height: 100vh;
@@ -143,6 +146,45 @@
143
146
  color: var(--fg-subtle);
144
147
  font-size: 12px;
145
148
  }
149
+ .success-state {
150
+ display: flex;
151
+ flex-direction: column;
152
+ align-items: center;
153
+ gap: 10px;
154
+ margin-top: -4px;
155
+ padding: 18px 16px;
156
+ border: 1px solid var(--status-ok);
157
+ border-radius: var(--radius-sm);
158
+ background: rgba(34, 197, 94, 0.1);
159
+ text-align: center;
160
+ }
161
+ .success-icon {
162
+ display: inline-flex;
163
+ width: 58px;
164
+ height: 58px;
165
+ align-items: center;
166
+ justify-content: center;
167
+ border: 2px solid var(--status-ok);
168
+ border-radius: 50%;
169
+ background: var(--bg);
170
+ color: var(--status-ok);
171
+ }
172
+ .success-icon svg {
173
+ width: 32px;
174
+ height: 32px;
175
+ }
176
+ .success-title {
177
+ margin: 0;
178
+ color: var(--fg);
179
+ font-size: 18px;
180
+ font-weight: 700;
181
+ line-height: 1.2;
182
+ }
183
+ .success-copy {
184
+ margin: 0;
185
+ color: var(--fg-muted);
186
+ font-size: 13.5px;
187
+ }
146
188
  button {
147
189
  font: inherit;
148
190
  appearance: none;
@@ -219,6 +261,24 @@
219
261
  >
220
262
  </div>
221
263
  </div>
264
+ <div class="success-state" id="success-state" role="status" hidden>
265
+ <div class="success-icon" aria-hidden="true">
266
+ <svg
267
+ viewBox="0 0 24 24"
268
+ fill="none"
269
+ stroke="currentColor"
270
+ stroke-width="2.4"
271
+ stroke-linecap="round"
272
+ stroke-linejoin="round"
273
+ >
274
+ <path d="M20 6 9 17l-5-5" />
275
+ </svg>
276
+ </div>
277
+ <div class="success-title" id="success-title">You're all done</div>
278
+ <p class="success-copy" id="success-copy">
279
+ You can close this tab and start recording from the Clips icon.
280
+ </p>
281
+ </div>
222
282
  <button id="enable">Enable camera &amp; microphone</button>
223
283
  <div class="status" id="status"></div>
224
284
  </div>
@@ -7,6 +7,11 @@
7
7
 
8
8
  const enableBtn = document.getElementById("enable") as HTMLButtonElement;
9
9
  const statusEl = document.getElementById("status") as HTMLDivElement;
10
+ const successEl = document.getElementById("success-state") as HTMLDivElement;
11
+ const successTitle = document.getElementById("success-title") as HTMLDivElement;
12
+ const successCopy = document.getElementById(
13
+ "success-copy",
14
+ ) as HTMLParagraphElement;
10
15
  const rowMic = document.getElementById("row-mic") as HTMLDivElement;
11
16
  const rowCam = document.getElementById("row-cam") as HTMLDivElement;
12
17
  const checkMic = document.getElementById("check-mic") as HTMLSpanElement;
@@ -20,6 +25,25 @@ function setStatus(text: string, isError = false): void {
20
25
  statusEl.classList.toggle("error", isError);
21
26
  }
22
27
 
28
+ function showEnableButton(text: string, disabled: boolean): void {
29
+ enableBtn.hidden = false;
30
+ enableBtn.textContent = text;
31
+ enableBtn.disabled = disabled;
32
+ successEl.hidden = true;
33
+ }
34
+
35
+ function showSuccess(title: string): void {
36
+ enableBtn.hidden = true;
37
+ enableBtn.disabled = true;
38
+ successTitle.textContent = title;
39
+ successCopy.textContent =
40
+ "You can close this tab and start recording from the Clips icon.";
41
+ successEl.hidden = false;
42
+ setStatus(
43
+ "Chrome still asks you what to share before each recording starts.",
44
+ );
45
+ }
46
+
23
47
  function markRow(kind: "mic" | "cam", granted: boolean): void {
24
48
  const row = kind === "mic" ? rowMic : rowCam;
25
49
  const check = kind === "mic" ? checkMic : checkCam;
@@ -59,16 +83,9 @@ function finish(camOk: boolean, micOk: boolean): void {
59
83
  markRow("mic", micOk);
60
84
  markRow("cam", camOk);
61
85
  if (camOk || micOk) {
62
- enableBtn.textContent = "All set you can close this tab";
63
- enableBtn.disabled = true;
64
- setStatus(
65
- camOk && micOk
66
- ? "Camera and microphone are ready. Click the Clips icon to record."
67
- : "Saved. Click the Clips icon to record.",
68
- );
86
+ showSuccess(camOk && micOk ? "You're all done" : "Saved");
69
87
  } else {
70
- enableBtn.disabled = false;
71
- enableBtn.textContent = "Try again";
88
+ showEnableButton("Try again", false);
72
89
  setStatus(
73
90
  "Access was blocked. Click the camera icon in Chrome's address bar to allow it, then try again.",
74
91
  true,
@@ -77,7 +94,7 @@ function finish(camOk: boolean, micOk: boolean): void {
77
94
  }
78
95
 
79
96
  async function enable(): Promise<void> {
80
- enableBtn.disabled = true;
97
+ showEnableButton(enableBtn.textContent ?? "Enable camera & microphone", true);
81
98
  setStatus("Waiting for Chrome's permission prompt…");
82
99
  // Request separately so a camera denial doesn't also block the microphone.
83
100
  const micOk = await requestOne("mic");
@@ -96,10 +113,6 @@ void (async () => {
96
113
  markRow("mic", mic === "granted");
97
114
  markRow("cam", cam === "granted");
98
115
  if (cam === "granted" && mic === "granted") {
99
- enableBtn.textContent = "Already enabled — you can close this tab";
100
- enableBtn.disabled = true;
101
- setStatus(
102
- "Camera and microphone are ready. Click the Clips icon to record.",
103
- );
116
+ showSuccess("You're all done");
104
117
  }
105
118
  })();
@@ -2349,7 +2349,6 @@ export function AddProperty({
2349
2349
  },
2350
2350
  });
2351
2351
  const [open, setOpen] = useState(false);
2352
- const [name, setName] = useState("");
2353
2352
  const [typeQuery, setTypeQuery] = useState("");
2354
2353
  const filteredPropertyTypes = filterDocumentPropertyTypes(typeQuery);
2355
2354
  const firstFilteredPropertyType = filteredPropertyTypes[0] ?? null;
@@ -2377,19 +2376,18 @@ export function AddProperty({
2377
2376
  }),
2378
2377
  }))
2379
2378
  .filter((group) => group.fields.length > 0);
2380
- const addPropertyNameInputRef = useRef<HTMLInputElement>(null);
2379
+ const addPropertySearchInputRef = useRef<HTMLInputElement>(null);
2381
2380
 
2382
2381
  useEffect(() => {
2383
2382
  if (!open) return;
2384
2383
  const frame = requestAnimationFrame(() => {
2385
- addPropertyNameInputRef.current?.focus();
2386
- addPropertyNameInputRef.current?.select();
2384
+ addPropertySearchInputRef.current?.focus();
2385
+ addPropertySearchInputRef.current?.select();
2387
2386
  });
2388
2387
  return () => cancelAnimationFrame(frame);
2389
2388
  }, [open]);
2390
2389
 
2391
2390
  function closeAddPropertyPicker() {
2392
- setName("");
2393
2391
  setTypeQuery("");
2394
2392
  setOpen(false);
2395
2393
  }
@@ -2398,7 +2396,7 @@ export function AddProperty({
2398
2396
  const label = DOCUMENT_PROPERTY_TYPE_LABELS[type];
2399
2397
  await configure.mutateAsync({
2400
2398
  documentId,
2401
- name: name.trim() || label,
2399
+ name: label,
2402
2400
  type,
2403
2401
  options: defaultPropertyOptions(type),
2404
2402
  });
@@ -2445,23 +2443,11 @@ export function AddProperty({
2445
2443
  className="w-80 p-2"
2446
2444
  >
2447
2445
  <div className="grid gap-2">
2448
- <Input
2449
- ref={addPropertyNameInputRef}
2450
- aria-label="New property name"
2451
- autoFocus
2452
- value={name}
2453
- placeholder="Property name"
2454
- onChange={(event) => setName(event.target.value)}
2455
- onKeyDown={(event) => {
2456
- if (event.key === "Escape") {
2457
- event.preventDefault();
2458
- closeAddPropertyPicker();
2459
- }
2460
- }}
2461
- />
2462
2446
  <div className="flex h-8 items-center gap-1 rounded border border-border bg-background px-2">
2463
2447
  <IconSearch className="size-3.5 shrink-0 text-muted-foreground" />
2464
2448
  <Input
2449
+ ref={addPropertySearchInputRef}
2450
+ autoFocus
2465
2451
  value={typeQuery}
2466
2452
  placeholder="Search property types"
2467
2453
  aria-label="Search property types"
@@ -64,6 +64,13 @@ themes. For any inline border, background, or text color, reference a token:
64
64
  and `--wf-radius`. Never hard-code a hex color and never set `font-family` — the
65
65
  renderer owns the sketch/clean font.
66
66
 
67
+ **Use literal CSS lengths for spacing.** The `--wf-*` tokens are for colors and
68
+ renderer-owned visual styling, not layout spacing. Do not use guessed spacing
69
+ tokens such as `var(--wf-space-4)`, Tailwind spacing classes, or theme spacing
70
+ variables inside wireframe HTML; if a token is unavailable in the Plan renderer,
71
+ padding collapses and content hugs the border. Use explicit CSS lengths for
72
+ layout: `padding:16px`, `gap:12px`, `margin-top:18px`, `minmax(0,1fr)`.
73
+
67
74
  **Lay out with inline `style` flex/grid.** You write the real layout —
68
75
  `display:flex; flex-direction:column; gap:10px; padding:16px` and so on — and the
69
76
  renderer never repositions anything. Compose the actual product: reproduce the
@@ -148,10 +155,21 @@ check/serve or verify command for `<plan-dir>`.
148
155
  **Treat the wireframe border as part of the visible design.** Always wrap HTML
149
156
  wireframe content in a root container with real inner padding before drawing
150
157
  cards, fields, pills, labels, or controls. Use at least 14-16px of padding,
151
- `box-sizing: border-box`, `height: 100%`, and `gap` between child rows so the
152
- first row never sits flush against the screen border. Keep text away from
153
- borders: every container, field, button, menu item, and annotation needs enough
154
- padding and line-height to read cleanly in the rendered Plan view.
158
+ `box-sizing: border-box`, `height: 100%`, and `gap` between child rows on the
159
+ root node itself so the first row never sits flush against the screen border. Do
160
+ not rely on padding on a nested page section as the first visible inset; the
161
+ outermost element must create the breathing room. Keep text away from borders:
162
+ every container, field, button, menu item, and annotation needs enough padding
163
+ and line-height to read cleanly in the rendered Plan view.
164
+
165
+ **For feature-cloud or abundance visuals, optimize the composition over line-by-line
166
+ reading.** Some marketing/product sections need to feel like a large surface area
167
+ of capability rather than a precise app workflow. In those cases, use one padded
168
+ root with a short headline and a dense, aesthetic cloud of short feature labels,
169
+ chips, rings, or columns. Vary scale and opacity with tokens, cluster by meaning,
170
+ and let many labels be glanceable rather than individually essential. Do not
171
+ force dozens of features into equal cards with long wrapped sentences; that
172
+ usually creates a messy unreadable mockup.
155
173
 
156
174
  **Lay out children safely so they never collide.** Use HTML flex/grid with
157
175
  `gap`, `min-width: 0`, and sensible overflow. Avoid negative margins, absolute
@@ -64,6 +64,13 @@ themes. For any inline border, background, or text color, reference a token:
64
64
  and `--wf-radius`. Never hard-code a hex color and never set `font-family` — the
65
65
  renderer owns the sketch/clean font.
66
66
 
67
+ **Use literal CSS lengths for spacing.** The `--wf-*` tokens are for colors and
68
+ renderer-owned visual styling, not layout spacing. Do not use guessed spacing
69
+ tokens such as `var(--wf-space-4)`, Tailwind spacing classes, or theme spacing
70
+ variables inside wireframe HTML; if a token is unavailable in the Plan renderer,
71
+ padding collapses and content hugs the border. Use explicit CSS lengths for
72
+ layout: `padding:16px`, `gap:12px`, `margin-top:18px`, `minmax(0,1fr)`.
73
+
67
74
  **Lay out with inline `style` flex/grid.** You write the real layout —
68
75
  `display:flex; flex-direction:column; gap:10px; padding:16px` and so on — and the
69
76
  renderer never repositions anything. Compose the actual product: reproduce the
@@ -148,10 +155,21 @@ check/serve or verify command for `<plan-dir>`.
148
155
  **Treat the wireframe border as part of the visible design.** Always wrap HTML
149
156
  wireframe content in a root container with real inner padding before drawing
150
157
  cards, fields, pills, labels, or controls. Use at least 14-16px of padding,
151
- `box-sizing: border-box`, `height: 100%`, and `gap` between child rows so the
152
- first row never sits flush against the screen border. Keep text away from
153
- borders: every container, field, button, menu item, and annotation needs enough
154
- padding and line-height to read cleanly in the rendered Plan view.
158
+ `box-sizing: border-box`, `height: 100%`, and `gap` between child rows on the
159
+ root node itself so the first row never sits flush against the screen border. Do
160
+ not rely on padding on a nested page section as the first visible inset; the
161
+ outermost element must create the breathing room. Keep text away from borders:
162
+ every container, field, button, menu item, and annotation needs enough padding
163
+ and line-height to read cleanly in the rendered Plan view.
164
+
165
+ **For feature-cloud or abundance visuals, optimize the composition over line-by-line
166
+ reading.** Some marketing/product sections need to feel like a large surface area
167
+ of capability rather than a precise app workflow. In those cases, use one padded
168
+ root with a short headline and a dense, aesthetic cloud of short feature labels,
169
+ chips, rings, or columns. Vary scale and opacity with tokens, cluster by meaning,
170
+ and let many labels be glanceable rather than individually essential. Do not
171
+ force dozens of features into equal cards with long wrapped sentences; that
172
+ usually creates a messy unreadable mockup.
155
173
 
156
174
  **Lay out children safely so they never collide.** Use HTML flex/grid with
157
175
  `gap`, `min-width: 0`, and sensible overflow. Avoid negative margins, absolute
@@ -43,8 +43,8 @@ const LazyPlanMarkdownEditor = lazy(() =>
43
43
  /**
44
44
  * Marker prefix embedded in salvaged "unknown-block" callout bodies by the
45
45
  * server-side per-block salvage path in parsePlanContent. The renderer detects
46
- * this prefix and shows a "Unsupported block" placeholder card rather than a
47
- * generic callout.
46
+ * this prefix and shows an invalid-block warning card rather than a generic
47
+ * callout.
48
48
  *
49
49
  * Format: `__unknown_block__:<originalType>\n<errorSummary>`
50
50
  */
@@ -87,7 +87,7 @@ class BlockErrorBoundary extends Component<
87
87
  }
88
88
  }
89
89
 
90
- /** Muted "Unsupported block" card for unknown/salvaged/errored blocks. */
90
+ /** Muted warning card for unknown/salvaged/errored blocks. */
91
91
  function UnknownBlockPlaceholder({
92
92
  blockId,
93
93
  originalType,
@@ -103,26 +103,43 @@ function UnknownBlockPlaceholder({
103
103
  data-block-id={blockId}
104
104
  data-unknown-block-type={originalType}
105
105
  >
106
- <div className="flex items-start gap-2 rounded-lg border border-plan-line bg-plan-block/40 px-3 py-2.5 text-plan-muted">
106
+ <div className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 px-3.5 py-3 text-plan-muted">
107
107
  <IconAlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-500" />
108
- <span className="min-w-0 text-sm">
109
- <span className="font-medium text-plan-text">
110
- Unsupported block: {originalType}
111
- </span>
112
- <details className="mt-1">
113
- <summary className="cursor-pointer text-xs opacity-60 hover:opacity-80">
114
- Show details
115
- </summary>
116
- <pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-all font-mono text-xs opacity-70">
117
- {errorSummary}
118
- </pre>
119
- </details>
120
- </span>
108
+ <div className="min-w-0 text-sm">
109
+ <div className="font-medium text-plan-text">
110
+ Invalid {originalType} block
111
+ </div>
112
+ <p className="mt-1 leading-5">
113
+ This generated block did not match the Plan schema, so it was left
114
+ out while the rest of the recap stayed visible.
115
+ </p>
116
+ {errorSummary && (
117
+ <details className="mt-2">
118
+ <summary className="cursor-pointer text-xs opacity-70 hover:opacity-90">
119
+ Validation details
120
+ </summary>
121
+ <pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded border border-plan-line bg-plan-bg/60 p-2 font-mono text-xs opacity-80">
122
+ {errorSummary}
123
+ </pre>
124
+ </details>
125
+ )}
126
+ </div>
121
127
  </div>
122
128
  </section>
123
129
  );
124
130
  }
125
131
 
132
+ function parseUnknownBlockMarker(block: PlanBlock) {
133
+ if (block.type !== "callout") return null;
134
+ if (!block.data.body.startsWith(UNKNOWN_BLOCK_MARKER)) return null;
135
+ const rest = block.data.body.slice(UNKNOWN_BLOCK_MARKER.length);
136
+ const newline = rest.indexOf("\n");
137
+ return {
138
+ originalType: newline >= 0 ? rest.slice(0, newline) : rest,
139
+ errorSummary: newline >= 0 ? rest.slice(newline + 1) : "",
140
+ };
141
+ }
142
+
126
143
  type PlanBlockViewProps = {
127
144
  block: PlanBlock;
128
145
  onChange?: (block: PlanBlock) => Promise<void> | void;
@@ -163,6 +180,17 @@ function PlanBlockViewInner({
163
180
  planId,
164
181
  collabUser,
165
182
  }: PlanBlockViewProps) {
183
+ const unknownBlock = parseUnknownBlockMarker(block);
184
+ if (unknownBlock) {
185
+ return (
186
+ <UnknownBlockPlaceholder
187
+ blockId={block.id}
188
+ originalType={unknownBlock.originalType}
189
+ errorSummary={unknownBlock.errorSummary}
190
+ />
191
+ );
192
+ }
193
+
166
194
  // Registry-first dispatch. If the block type is registered, render through the
167
195
  // block registry (`BlockView` → spec `Read`, or in edit mode the spec `Edit`
168
196
  // or the schema-driven auto-editor). Unregistered types fall through to the
@@ -230,22 +258,6 @@ function PlanBlockViewInner({
230
258
  );
231
259
  }
232
260
  if (block.type === "callout") {
233
- // Detect the server-side per-block salvage marker. The marker starts with a
234
- // zero-width-space followed by `__unknown_block__:<type>` to avoid collision
235
- // with real callout content (real callouts never start with a ZWSP).
236
- if (block.data.body.startsWith(UNKNOWN_BLOCK_MARKER)) {
237
- const rest = block.data.body.slice(UNKNOWN_BLOCK_MARKER.length);
238
- const newline = rest.indexOf("\n");
239
- const originalType = newline >= 0 ? rest.slice(0, newline) : rest;
240
- const errorSummary = newline >= 0 ? rest.slice(newline + 1) : "";
241
- return (
242
- <UnknownBlockPlaceholder
243
- blockId={block.id}
244
- originalType={originalType}
245
- errorSummary={errorSummary}
246
- />
247
- );
248
- }
249
261
  return (
250
262
  <section className="plan-block plan-callout" data-block-id={block.id}>
251
263
  {block.title && <div className="plan-block-label">{block.title}</div>}
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-24
4
+ ---
5
+
6
+ Invalid generated recap blocks now show a clear warning with validation details instead of raw block markers.