From 2344486f11b16425bd54a61aaded1501eeea2dcc Mon Sep 17 00:00:00 2001 From: Evan Date: Sun, 20 Sep 2026 23:54:15 +0900 Subject: [PATCH] fix: 34 findings from a six-lens audit, and the tests that would have caught them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six auditors read the codebase along different lines, every finding then had to survive an adversarial pass whose job was to refute it, and several were reproduced by running the code rather than reasoning about it. 34 survived. This fixes them. The worst one collapsed a page that had already rendered. `asSettled` in lib/content-parallel.ts is named for settling and raced raw promises instead, so when one of four copy chunks failed — and the README measures malformed JSON at roughly one run in two — the rejection escaped compose.ts, reached the route as a terminal error, and the client hid the exports and the edit box on a page whose nav, hero, testimonials, pricing, FAQ and footer had all arrived and painted. The fix is in the helper that owns the contract: it now yields `{status}` like `Promise.allSettled`, and it is a plain function returning a generator rather than an `async function*`, because a generator body does not run until its first `next()` and a rejection landing in that gap is unhandled. The second round's promise gets its handler where it is created, not at the await site — attaching it at the await produces an unhandled rejection in the window between, which is demonstrable. The retry underneath it could not fix the failure it exists for. `finish_reason` was never read and attempt two re-sent the identical `max_tokens`, so a truncated reply truncated again at the same place, burning the budget twice. Truncation is now detected and the second attempt gets a larger cap and a complaint the model can act on; other malformed-JSON causes keep the original budget. Two whole features were dead. `add` arrived well-formed from both edit layers and nothing consumed it, so the log printed `add@1.00 faq` and the page did not change — and that made `remove` one-way. The token readout summed a field that was never written, so `· N tok · N evals` was structurally unreachable. The stream consumer had no error path at all: no `try`, no `finally`, no `response.ok`. A rejected fetch left Generate disabled until a reload, and a non-200 was fed to the NDJSON parser as if it were events. On the MCP server, a tool argument of `toString` or `constructor` reached a plain object lookup and threw out of the handler, killing the process for the agent on the other end — reproduced over real stdio, where ids 2 and 3 went unanswered. Every client-keyed lookup now goes through `Object.hasOwn`, the module comment states that as the rule, and batch requests are handled rather than advertised and refused. Nothing capped a prompt, at any layer. 2000 characters now, at both routes, chosen against the longest example any locale ships (91). Both routes validate what they destructure instead of trusting it. Smaller, all real: `:first-child` was being rewritten to `:first` by an alternation matching a prefix, so those rules were dropped from every HTML export; the JSX exporter trimmed each text node independently and ate the space before a byline; the HTML title deleted `&`, `<` and `>` rather than escaping them; AGENTS.md told the receiving agent the theme tokens sit on the outermost element when they sit on its first child; the theme swatches carried their selected state in colour alone. Four files had no tests: lib/compose.ts, lib/edit.ts, lib/export.ts, and the erasable-syntax constraint that lets scripts/mcp.mjs import lib/*.ts under Node's type stripping — invisible to the suite until `npm run mcp` breaks. All four now have them, and CI drives the MCP server over a real pipe, which is the only thing that exercises the transport rather than the handler. The contrast test constrained six token pairs while the registry paints text on three more. Extended; no theme fails. The README claimed in all eight languages that app/registry.tsx contains no hex at all — it contains three, the macOS traffic lights in the mocked screenshot. The claim was what was wrong, and it now names its exception. Two findings turned out to be arguments rather than bugs and were deliberately not "fixed". Demo mode was not made to answer 「不要定价了」 by adding a pricing block, because the fixture has no pricing and the live system would return `unclear`; demo mode exists to replay what really happens, so it now says so through `blockedBy` instead. And the removal guard rejects the request rather than removing a block the archetype requires. One bug was found while wiring the parts together rather than by the audit: `applyEdit` sent `variants` without declaring it, so after any restyle the next edit described the page as it looked two edits ago and Jev answered about a page that no longer existed. `run` had the same shape with `locale`, harmless only because the dictionary changes identity alongside it. lib/page.test.ts already guarded this class for the dictionary; it now guards it for everything a callback puts in a request body, which is how both were caught. --- .github/workflows/ci.yml | 9 ++ CONTRIBUTING.md | 2 +- README.md | 6 +- app/api/edit/route.ts | 62 +++++-- app/api/generate/route.ts | 32 +++- app/page.tsx | 290 +++++++++++++++++++++++---------- docs/CONTRIBUTING.zh.md | 2 +- docs/README.de.md | 6 +- docs/README.es.md | 6 +- docs/README.fr.md | 6 +- docs/README.ja.md | 6 +- docs/README.ko.md | 6 +- docs/README.pt.md | 6 +- docs/README.zh.md | 6 +- lib/compose.test.ts | 306 +++++++++++++++++++++++++++++++++++ lib/compose.ts | 48 ++++-- lib/content-parallel.test.ts | 130 ++++++++++++++- lib/content-parallel.ts | 71 ++++++-- lib/content.ts | 6 +- lib/demo.test.ts | 65 +++++++- lib/demo.ts | 55 ++++++- lib/edit.test.ts | 211 ++++++++++++++++++++++++ lib/edit.ts | 31 +++- lib/erasable.test.ts | 38 +++++ lib/export-agents.test.ts | 47 +++++- lib/export-agents.ts | 26 ++- lib/export-tsx.test.ts | 28 ++++ lib/export-tsx.ts | 32 +++- lib/export.test.ts | 132 +++++++++++++++ lib/export.ts | 17 +- lib/llms.test.ts | 12 ++ lib/llms.ts | 12 +- lib/mcp.test.ts | 62 ++++++- lib/mcp.ts | 39 ++++- lib/page.test.ts | 114 +++++++++++++ lib/themes.test.ts | 58 ++++++- scripts/sources.mjs | 7 +- 37 files changed, 1772 insertions(+), 220 deletions(-) create mode 100644 lib/compose.test.ts create mode 100644 lib/edit.test.ts create mode 100644 lib/erasable.test.ts create mode 100644 lib/export.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f46dd40..d4ff06a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,15 @@ jobs: # registry, and every block recipe must match its props. tsc enforces both. - run: npx tsc --noEmit - run: npm test + # The only part of loom nothing else exercises: scripts/mcp.mjs's own resolve hook and stdio framing. lib/mcp.test.ts calls handle() directly, so it would stay green through a transport that never starts — an agent registering `npm run --silent mcp` would be the one to find out. Two messages in one pipe is also the chunk boundary case the script's comment warns about. `npm run sources` is deliberately absent: it probes five third-party registries and belongs nowhere near a CI run. + - name: MCP server answers a handshake over stdio + run: | + printf '%s\n%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | npm run --silent mcp > mcp-handshake.json + grep -q '"protocolVersion"' mcp-handshake.json + for tool in list_blocks list_themes theme_tokens list_component_sources sources_for_block; do + grep -q "\"$tool\"" mcp-handshake.json || { echo "MCP server did not advertise $tool"; exit 1; } + done + rm mcp-handshake.json - run: npm run build env: JEV_TOKEN: build-time-placeholder diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88c008e..0f98e42 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Get it running first, so you know the environment is sound: ```bash npm install -npm test # 250 of them, ~1.5s, no network +npm test # 347 of them, ~1.5s, no network npm run dev # demo mode; no keys needed ``` diff --git a/README.md b/README.md index ce55327..ea9a391 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,7 @@ The origin comes from the request rather than from a build-time constant, since The same generated copy under all six themes. Switching is a single client-side prop change — no model call, no regeneration. -Six themes, each a complete set of design tokens. `app/registry.tsx` contains **no hex values at all** — everything reads from CSS variables: +Six themes, each a complete set of design tokens. Every colour in `app/registry.tsx` reads from a CSS variable, with **exactly one exception**, which a test holds at one: the three macOS traffic-light dots in the mocked screenshot inside the split hero, which draw another operating system's window chrome rather than loom's palette: | Theme | Character | Suits | |---|---|---| @@ -336,7 +336,7 @@ The audit needs a running server and `npx playwright install chromium`, so it st ```bash -npm test # 250 of them, ~1.5s, no network +npm test # 347 of them, ~1.5s, no network ``` They cover **the three rules taken back from the model** — selling-point count decides grid vs list, tier count decides single vs comparison, whether there is a UI screenshot decides the hero layout. If any of these drift, the decision quietly goes back to a model that cannot make it, so they are the ones that must not move. @@ -382,7 +382,7 @@ locales/ en|zh|ja|ko|es|fr|de|pt.json UI translations, zh is the reference app/ page.tsx the editor, stream consumption, client-side retheme/restyle - registry.tsx what blocks look like, all via CSS variables + registry.tsx what blocks look like, via CSS variables llms.txt/ the /llms.txt route api/generate generation api/edit edits diff --git a/app/api/edit/route.ts b/app/api/edit/route.ts index c4a2dc8..ec021cb 100644 --- a/app/api/edit/route.ts +++ b/app/api/edit/route.ts @@ -1,24 +1,59 @@ -import { editPlan } from "@/lib/edit"; +import { MAX_REQUEST_CHARS, editPlan } from "@/lib/edit"; import { isDemoMode, replayEdit } from "@/lib/demo"; -import type { SlotKey } from "@/lib/plan"; +import { ARCHETYPES, SLOTS, type ArchetypeKey, type SlotKey } from "@/lib/plan"; +import { THEMES } from "@/lib/themes"; export const maxDuration = 60; +const DEFAULT_THEME = "forest"; + +/** + * Everything below the boundary trusts its arguments, so the boundary has to earn that: the `as` cast this handler used to open with was erased at runtime, and a body of `{"prompt":123}` reached `prompt.trim()` — a TypeError thrown out of the handler, a 500 where the very next line meant to answer 400. `request.json()` sat outside the try as well, so a body that was not JSON did the same. The endpoint is advertised to agents in lib/llms.ts, which makes hand-written bodies an expected input rather than a hypothetical one. + */ export async function POST(request: Request) { - const { prompt, present, theme, variants } = (await request.json()) as { - prompt?: string; - present?: SlotKey[]; - theme?: string; - variants?: Partial>; - }; - if (!prompt?.trim()) { + let raw: unknown; + try { + raw = await request.json(); + } catch { + return Response.json({ error: "body must be JSON" }, { status: 400 }); + } + const body = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { return Response.json({ error: "prompt is required" }, { status: 400 }); } + if (prompt.length > MAX_REQUEST_CHARS) { + return Response.json( + { error: `prompt is longer than ${MAX_REQUEST_CHARS} characters` }, + { status: 400 }, + ); + } + + // Unknown slot keys, theme keys and variant ids are dropped rather than rejected: they are interpolated into Jev's instructions, and the catalogues are the only authority on what is real. + const present = Array.isArray(body.present) + ? body.present.filter((slot): slot is SlotKey => typeof slot === "string" && slot in SLOTS) + : []; + const theme = + typeof body.theme === "string" && body.theme in THEMES ? body.theme : DEFAULT_THEME; + const archetype = + typeof body.archetype === "string" && body.archetype in ARCHETYPES + ? (body.archetype as ArchetypeKey) + : undefined; + const variants: Partial> = {}; + if (typeof body.variants === "object" && body.variants !== null && !Array.isArray(body.variants)) { + for (const [slot, variant] of Object.entries(body.variants as Record)) { + if (!(slot in SLOTS)) continue; + if (typeof variant !== "string") continue; + if (!(variant in SLOTS[slot as SlotKey].variants)) continue; + variants[slot as SlotKey] = variant; + } + } const started = Date.now(); if (isDemoMode()) { return Response.json({ - ...replayEdit(prompt, present ?? [], theme ?? "forest"), + ...replayEdit(prompt, present, theme), demo: true, inputTokens: 0, speculative: {}, @@ -31,10 +66,11 @@ export async function POST(request: Request) { const plan = await editPlan( apiKey, prompt, - present ?? [], - theme ?? "forest", + present, + theme, AbortSignal.timeout(30_000), - variants ?? {}, + variants, + archetype, ); return Response.json({ ...plan, elapsedMs: Date.now() - started }); } catch (error) { diff --git a/app/api/generate/route.ts b/app/api/generate/route.ts index 7e82837..d12a04b 100644 --- a/app/api/generate/route.ts +++ b/app/api/generate/route.ts @@ -1,16 +1,38 @@ import { composePlanned } from "@/lib/compose"; import { isDemoMode, replay } from "@/lib/demo"; +import { MAX_REQUEST_CHARS } from "@/lib/edit"; export const maxDuration = 120; +/** A locale is a BCP-47 tag here, not free text: it is compared against the fixture names and against LANGUAGES, and anything else is a typo at best. */ +const LOCALE_TAG = /^[a-z]{2,8}(-[a-z0-9]{2,8})*$/i; + +/** + * The prompt is checked before anything touches it because of what happens next: lib/compose.ts copies it into seventeen questions of the planning round, so the upstream body is roughly seventeen times whatever arrives. Measured with a 1 MB prompt, the first outbound request was 16.22 MiB; a 33 MB prompt drove server RSS to 2 GB. One unauthenticated POST should not be able to do that, and the request is buffered by `request.json()` in any case, so the cap belongs here rather than deeper in. + * + * The `as` cast this opened with was erased at runtime: `{"prompt":123}` reached `prompt?.trim()` and threw out of the handler, and `request.json()` itself was outside every try, so a non-JSON body did the same. Both answered 500 where 400 was meant. + */ export async function POST(request: Request) { - const { prompt, locale } = (await request.json()) as { - prompt?: string; - locale?: string; - }; - if (!prompt?.trim()) { + let raw: unknown; + try { + raw = await request.json(); + } catch { + return Response.json({ error: "body must be JSON" }, { status: 400 }); + } + const body = (typeof raw === "object" && raw !== null ? raw : {}) as Record; + + const prompt = typeof body.prompt === "string" ? body.prompt.trim() : ""; + if (!prompt) { return Response.json({ error: "prompt is required" }, { status: 400 }); } + if (prompt.length > MAX_REQUEST_CHARS) { + return Response.json( + { error: `prompt is longer than ${MAX_REQUEST_CHARS} characters` }, + { status: 400 }, + ); + } + const locale = + typeof body.locale === "string" && LOCALE_TAG.test(body.locale) ? body.locale : undefined; const jevKey = process.env.JEV_TOKEN?.trim(); const llmKey = process.env.LLM_TOKEN?.trim(); diff --git a/app/page.tsx b/app/page.tsx index 57742ff..b0c0dda 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -6,11 +6,14 @@ import type { Spec } from "@json-render/core"; import { registry } from "./registry"; import { THEMES } from "@/lib/themes"; import { elementsFor, type SiteContent } from "@/lib/content"; +import { isReady } from "@/lib/content-parallel"; +import { layoutRules, SLOTS, SLOT_ORDER, type SlotKey } from "@/lib/plan"; import { buildStandaloneHtml } from "@/lib/export"; import { buildReactSource } from "@/lib/export-tsx"; import { buildRegistryItem } from "@/lib/export-registry"; import { buildAgentsMd } from "@/lib/export-agents"; import { buildBundle } from "@/lib/export-bundle"; +import { MAX_REQUEST_CHARS } from "@/lib/edit"; import { detectLocale, dict, @@ -22,7 +25,6 @@ import { type StepInfo = { choice: string; elapsedMs: number; - inputTokens?: number | null; answers?: Record; }; @@ -52,12 +54,17 @@ export default function Page() { const [plan, setPlan] = useState(""); const [demo, setDemo] = useState(false); const [failure, setFailure] = useState(null); + // Both jev rounds report a running total, not a per-round delta: compose.ts keeps one `inputTokens` accumulator and yields its current value on `plan`, again on `select` and again on `complete`. Summing them would advertise 7038 for a run that cost 3908, so this is assigned, never accumulated. The old code summed a field it never stored, which hid the mistake by always reading 0. + const [tokens, setTokens] = useState(0); + const [rounds, setRounds] = useState(0); const [theme, setTheme] = useState("forest"); const [siteLanguage, setSiteLanguage] = useState("en"); const [pickedTheme, setPickedTheme] = useState(""); const [editPrompt, setEditPrompt] = useState(""); const [editLog, setEditLog] = useState([]); const [slots, setSlots] = useState([]); + // The archetype decides which blocks are required, and /api/edit refuses to remove one of those. It can only do that if the client tells it which archetype this page is, so the value is kept rather than only logged. + const [archetype, setArchetype] = useState(""); const previewRef = useRef(null); // Kept client-side so a restyle can rebuild any block locally, with no // regeneration and no server round trip beyond the single intent call. @@ -65,6 +72,11 @@ export default function Page() { const [variants, setVariants] = useState>({}); const renderKey = useRef(0); + /** + * Consume the NDJSON stream from /api/generate. + * + * Everything here that is not the happy path was added after the fact. The original version checked only `response.body`, called a bare `JSON.parse` on every line, and reached `setRunning(false)` only by falling off the end of the read loop, so it had no error path at all and failed in two distinct ways. Press Generate with an empty box and the route answers 400 with `{"error":"prompt is required"}`, which is one perfectly valid JSON line that matches no `event.type` branch: the loop drained, nothing was shown, and the status sat on "Sending…". Restart the dev server mid-run, go offline, or let a proxy return an HTML 502 and `fetch`, `reader.read()` or `JSON.parse` rejects instead; the rejection escaped the `void run(...)` call site unhandled with `running` still true, which left the Generate button and every example chip disabled until the page was reloaded. The status check, the per-line try and the finally are three guards against that one missing path. + */ const run = useCallback(async (request: string) => { renderKey.current += 1; setRunning(true); @@ -72,94 +84,130 @@ export default function Page() { setSpec(null); setPlan(""); setFailure(null); + setTokens(0); + setRounds(0); setStatus(t.sending); - const response = await fetch("/api/generate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prompt: request, locale }), - }); - if (!response.body) { - setStatus(t.noStream); - setRunning(false); - return; - } + try { + const response = await fetch("/api/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: request, locale }), + }); + // The route reports its own trouble as an in-stream `error` event, but only once the stream exists. Anything that fails earlier arrives as an ordinary body that simply is not NDJSON, so the status has to be read before the body is treated as a stream. + if (!response.ok) { + const detail = (await response.text()).slice(0, 400); + setFailure(`HTTP ${response.status} · ${detail}`); + setStatus(`${t.error}: HTTP ${response.status}`); + return; + } + if (!response.body) { + setStatus(t.noStream); + return; + } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - if (!line.trim()) continue; - const event = JSON.parse(line); - if (event.demo) setDemo(true); - const at = `${((event.elapsedMs ?? 0) / 1000).toFixed(1)}s`; - - if (event.type === "plan") { - setSiteLanguage((event.language as string) ?? "en"); - const optional = Object.entries(event.optional as Record) - .map(([slot, noul]) => `${slot} ${(noul as number).toFixed(2)}`) - .join(" "); - setPlan( - `${at} ${t.plan} → ${t.archetypeLabels[event.archetype as keyof typeof t.archetypeLabels] ?? event.archetype}@${(event.confidence ?? 0).toFixed(2)} · ${event.language}${event.languageSource ? `/${event.languageSource}` : ""} · ${t.theme} ${event.theme}@${(event.themeConfidence ?? 0).toFixed(2)} · ${t.required} ${event.required.join(" ")} · ${t.optional} ${optional || t.none}`, - ); - setTheme(event.theme); - setPickedTheme(event.theme); - setSlots(event.slots as string[]); - setVariants({}); - contentRef.current = null; - setEditLog([]); - setStatus(t.writingCopy); - } else if (event.type === "content") { - setPlan( - (previous) => - `${previous}\n${at} ${t.copy} → ${event.brand} · ${event.tagline}`, - ); - } else if (event.type === "picks") { - setVariants((current) => ({ ...current, ...event.picks })); - if (event.content) contentRef.current = event.content as SiteContent; - } else if (event.type === "select") { - setSteps((previous) => [ - ...previous, - { choice: "select", elapsedMs: event.elapsedMs, answers: event.picks }, - ]); - } else if (event.type === "partial") { - setPlan((previous) => `${previous}\n${at} ${t.render} → ${event.phase}`); - setStatus(`${event.phase} ${t.filled}`); - setSpec(event.spec); - } else if (event.type === "complete") { - setStatus(`${event.stopReason} · ${t.total} ${at}`); - // No key bump here: the final spec should reconcile into the blocks - // already on screen, not remount and re-animate the whole page. - if (event.spec) setSpec(event.spec); - } else if (event.type === "error") { - // A half-built page whose skeletons keep pulsing reads as "still - // working". Freeze it and say plainly that it stopped. - setFailure(event.message); - setStatus(`${t.error}: ${event.message}`); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (!line.trim()) continue; + let event; + try { + event = JSON.parse(line); + } catch { + // One unreadable line is not a reason to abandon the blocks that already arrived; throwing here used to strand the whole editor. + continue; + } + if (event.demo) setDemo(true); + const at = `${((event.elapsedMs ?? 0) / 1000).toFixed(1)}s`; + + if (event.type === "plan") { + setSiteLanguage((event.language as string) ?? "en"); + const optional = Object.entries(event.optional as Record) + .map(([slot, noul]) => `${slot} ${(noul as number).toFixed(2)}`) + .join(" "); + setPlan( + `${at} ${t.plan} → ${t.archetypeLabels[event.archetype as keyof typeof t.archetypeLabels] ?? event.archetype}@${(event.confidence ?? 0).toFixed(2)} · ${event.language}${event.languageSource ? `/${event.languageSource}` : ""} · ${t.theme} ${event.theme}@${(event.themeConfidence ?? 0).toFixed(2)} · ${t.required} ${event.required.join(" ")} · ${t.optional} ${optional || t.none}`, + ); + setTheme(event.theme); + setPickedTheme(event.theme); + setSlots(event.slots as string[]); + setArchetype(event.archetype as string); + setVariants({}); + contentRef.current = null; + setEditLog([]); + setStatus(t.writingCopy); + setTokens((current) => event.inputTokens ?? current); + setRounds((current) => current + 1); + } else if (event.type === "content") { + setPlan( + (previous) => + `${previous}\n${at} ${t.copy} → ${event.brand} · ${event.tagline}`, + ); + } else if (event.type === "picks") { + setVariants((current) => ({ ...current, ...event.picks })); + if (event.content) contentRef.current = event.content as SiteContent; + } else if (event.type === "select") { + setSteps((previous) => [ + ...previous, + { choice: "select", elapsedMs: event.elapsedMs, answers: event.picks }, + ]); + setTokens((current) => event.inputTokens ?? current); + setRounds((current) => current + 1); + } else if (event.type === "partial") { + setPlan((previous) => `${previous}\n${at} ${t.render} → ${event.phase}`); + setStatus(`${event.phase} ${t.filled}`); + setSpec(event.spec); + } else if (event.type === "complete") { + setStatus(`${event.stopReason} · ${t.total} ${at}`); + // No key bump here: the final spec should reconcile into the blocks + // already on screen, not remount and re-animate the whole page. + if (event.spec) setSpec(event.spec); + setTokens((current) => event.inputTokens ?? current); + } else if (event.type === "error") { + // A half-built page whose skeletons keep pulsing reads as "still + // working". Freeze it and say plainly that it stopped. + setFailure(event.message); + setStatus(`${t.error}: ${event.message}`); + } } } + } catch (error) { + // fetch rejecting, or reader.read() rejecting mid-stream: a dev server restart, a dropped tunnel, an offline browser. This used to escape as an unhandled rejection and the finally below is what stops it locking the editor. + const message = error instanceof Error ? error.message : String(error); + setFailure(message); + setStatus(`${t.error}: ${message}`); + } finally { + setRunning(false); } - setRunning(false); - }, [t]); + }, [locale, t]); const applyEdit = useCallback(async () => { const request = editPrompt.trim(); if (!request || !spec) return; setEditPrompt(""); - const response = await fetch("/api/edit", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prompt: request, present: slots, theme, variants }), - }); - const plan = await response.json(); + let plan; + try { + const response = await fetch("/api/edit", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: request, present: slots, theme, variants, archetype }), + }); + plan = await response.json(); + } catch (error) { + // The edit route answers its own failures as JSON, so getting here means the transport died or something upstream returned an HTML page. Letting that reject dropped the edit without even the `→ failed:` line, which is indistinguishable from having typed nothing. + const message = error instanceof Error ? error.message : String(error); + setEditLog((log) => [...log, `"${request}" → ${t.failed}: ${message}`]); + return; + } if (plan.error) { setEditLog((log) => [...log, `"${request}" → ${t.failed}: ${plan.error}`]); return; @@ -169,9 +217,11 @@ export default function Page() { const target = plan.variant ? `${plan.slot} → ${plan.variant}${plan.arbitrary ? t.arbitrary : ""}` : (plan.slot ?? plan.theme ?? "—"); + // `blockedBy` is the edit layer saying in words why it will not act — the page has no pricing block to drop, or every block left is required by this archetype. It travelled in the JSON from the start and nothing rendered it, so a refusal and a success both printed one log line and the user was left to guess which they got. setEditLog((log) => [ ...log, `"${request}" → ${plan.action}@${conf} ${target} · ${plan.elapsedMs}ms`, + ...(plan.blockedBy ? [` ${plan.blockedBy}`] : []), ]); if (plan.action === "theme" && plan.theme) { @@ -184,6 +234,11 @@ export default function Page() { setEditLog((log) => [...log, ` ${t.notReady}`]); return; } + // `if (!element) return` used to stand here on its own and could never fire: lib/content.ts builds the whole variant table unconditionally, so looking up features_list against a half-arrived SiteContent yields a truthy element whose `props.items` is undefined, and lib/edit.ts can only ever name ids that are in that table. Splicing such an element into the spec made registry.tsx map over undefined, which @json-render's ElementErrorBoundary swallows by rendering null — the block simply vanished, with no banner and no log line. isReady is the same field table compose.ts gates its own build with. + if (!isReady(plan.variant, content)) { + setEditLog((log) => [...log, ` ${t.notReady}`]); + return; + } const element = elementsFor(content)[plan.variant]; if (!element) return; setVariants((current) => ({ ...current, [plan.slot]: plan.variant })); @@ -197,6 +252,54 @@ export default function Page() { }); return; } + // `add` reached this function well-formed from both edit layers — lib/edit.ts builds an `add_target` question over the absent slots, lib/demo.ts replays `{action:"add", slot:"faq"}` — and nothing consumed it, so the log printed `add@1.00 faq · 3ms`, which reads as success, while the page did not change. It also made `remove` one-way. The copy is already here: compose.ts runs all four content chunks whatever the plan chose, so contentRef holds every field, including the ones for blocks the page never had. + if (plan.action === "add" && plan.slot) { + const content = contentRef.current; + const slot = plan.slot as SlotKey; + const definition = SLOTS[slot]; + if (!content || !definition) { + setEditLog((log) => [...log, ` ${t.notReady}`]); + return; + } + // Which variant a newly added block gets is decided exactly the way the first build decided it: layoutRules owns the slots marked `auto`, and for the rest the catalog's first key is a default rather than a judgement — jev's `add` answer names the slot only. + const ruled = layoutRules(content).find((rule) => rule.slot === slot); + const id = ruled?.id ?? Object.keys(definition.variants)[0]!; + if (!isReady(id, content)) { + setEditLog((log) => [...log, ` ${t.notReady}`]); + return; + } + const element = elementsFor(content)[id as keyof ReturnType]; + if (!element) { + setEditLog((log) => [...log, ` ${t.notReady}`]); + return; + } + setSlots((current) => (current.includes(slot) ? current : [...current, slot])); + setVariants((current) => ({ ...current, [slot]: id })); + setSpec((current) => { + if (!current) return current; + const elements = current.elements as Record; + const root = elements[current.root as string]!; + const key = `slot_${slot}`; + const rank = (child: string) => + SLOT_ORDER.indexOf(child.replace("slot_", "") as SlotKey); + // Position is SLOT_ORDER's business, not arrival order: appending would put a newly added FAQ below the footer. + const siblings = (root.children ?? []).filter((c) => c !== key); + const at = siblings.findIndex((c) => rank(c) > SLOT_ORDER.indexOf(slot)); + const children = + at < 0 + ? [...siblings, key] + : [...siblings.slice(0, at), key, ...siblings.slice(at)]; + return { + ...current, + elements: { + ...elements, + [key]: { ...element, children: [] }, + [current.root as string]: { ...root, children }, + }, + } as Spec; + }); + return; + } if (plan.action === "remove" && plan.slot) { setSlots((current) => current.filter((s) => s !== plan.slot)); setSpec((current) => { @@ -216,7 +319,7 @@ export default function Page() { } as Spec; }); } - }, [editPrompt, spec, slots, theme, t]); + }, [editPrompt, spec, slots, theme, variants, archetype, t]); /** * Export the rendered result as one self-contained file: the block markup @@ -226,7 +329,9 @@ export default function Page() { const exportHtml = useCallback(() => { const node = previewRef.current?.firstElementChild; if (!node) return; - const html = buildStandaloneHtml(node, pickedTheme ? prompt.slice(0, 40) : "loom", siteLanguage); + // The condition used to be the theme jev chose — a variable this line does not otherwise use, and one that is always set by the time the export button exists, so the "loom" fallback was dead code. Clearing the input after a run then saved a file with an empty , which shows up as "Untitled" in the tab and in bookmarks. Test the value actually being written. + const title = prompt.trim().slice(0, 40); + const html = buildStandaloneHtml(node, title || "loom", siteLanguage); const blob = new Blob([html], { type: "text/html;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -238,7 +343,7 @@ export default function Page() { ...log, `${t.exportHtml} · ${(html.length / 1024).toFixed(0)} KB`, ]); - }, [prompt, pickedTheme, theme, siteLanguage, t]); + }, [prompt, theme, siteLanguage, t]); const exportTsx = useCallback(() => { const node = previewRef.current?.firstElementChild; @@ -347,8 +452,6 @@ export default function Page() { ]); }, [prompt, theme, slots, spec, siteLanguage, t]); - const totalTokens = steps.reduce((sum, step) => sum + (step.inputTokens ?? 0), 0); - // Appearance lives entirely in one prop, so re-theming costs nothing: no // regeneration, no model call, no new copy. const themedSpec = (() => { @@ -380,11 +483,12 @@ export default function Page() { <button key={code} type="button" + aria-pressed={locale === code} onClick={() => { setLocale(code); setPrompt(dict(code).examples[0]!); }} - className={`px-2 py-1 ${locale === code ? "bg-neutral-900 text-white dark:bg-white dark:text-black" : "text-neutral-500"}`} + className={`px-2 py-1 ${locale === code ? "bg-neutral-900 font-semibold text-white dark:bg-white dark:text-black" : "text-neutral-500"}`} > {LOCALE_LABELS[code]} </button> @@ -404,13 +508,15 @@ export default function Page() { className="flex gap-2 pt-3" onSubmit={(event) => { event.preventDefault(); - if (!running) void run(prompt); + // An empty box used to be sent anyway; the route replies 400 and the reply happens to be one valid JSON line, so the old consumer parsed it as an event, matched nothing, and left the status on "Sending…". + if (!running && prompt.trim()) void run(prompt); }} > <input value={prompt} onChange={(event) => setPrompt(event.target.value)} placeholder={t.placeholder} + maxLength={MAX_REQUEST_CHARS} className="flex-1 rounded-lg border border-neutral-300 px-3 py-2 text-[14px] outline-none focus:border-neutral-600 dark:border-neutral-700 dark:bg-neutral-800" /> <button @@ -474,11 +580,13 @@ export default function Page() { key={key} type="button" data-theme-key={key} + aria-pressed={theme === key} onClick={() => setTheme(key)} title={swatch.description} + // Which theme is applied was carried by the border colour alone, so every pill announced identically and read identically to anyone who cannot see the border. aria-pressed states it, and the weight gives a cue that survives greyscale. className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[12px] transition-colors ${ theme === key - ? "border-neutral-900 dark:border-white" + ? "border-neutral-900 font-medium dark:border-white" : "border-neutral-300 text-neutral-500 dark:border-neutral-700" }`} > @@ -548,15 +656,19 @@ export default function Page() { void applyEdit(); }} > + {/* The form appears as soon as the frame partial sets `spec`, which is before any LLM call, and every later `partial` and the `complete` event replace the whole spec with the server's — which knows nothing about an edit. So an edit applied mid-run was silently reverted a second or two later, and a restyle against half-arrived copy could splice in a block whose props had not landed. Disabling until the stream ends closes both. */} <input value={editPrompt} onChange={(event) => setEditPrompt(event.target.value)} placeholder={t.editPlaceholder} - className="flex-1 rounded-lg border border-neutral-300 px-3 py-1.5 text-[13px] outline-none focus:border-neutral-600 dark:border-neutral-700 dark:bg-neutral-800" + disabled={running} + maxLength={MAX_REQUEST_CHARS} + className="flex-1 rounded-lg border border-neutral-300 px-3 py-1.5 text-[13px] outline-none focus:border-neutral-600 disabled:opacity-40 dark:border-neutral-700 dark:bg-neutral-800" /> <button type="submit" - className="rounded-lg border border-neutral-400 px-3 py-1.5 text-[13px] dark:border-neutral-600" + disabled={running} + className="rounded-lg border border-neutral-400 px-3 py-1.5 text-[13px] disabled:opacity-40 dark:border-neutral-600" > {t.edit} </button> @@ -572,9 +684,9 @@ export default function Page() { )} {status && ( - <div className="pt-3 font-mono text-[12px] text-neutral-500"> + <div className="pt-3 font-mono text-[12px] text-neutral-500" role="status"> {status} - {totalTokens > 0 && ` · ${totalTokens} tok · ${steps.length} evals`} + {tokens > 0 && ` · ${tokens} tok · ${rounds} evals`} </div> )} diff --git a/docs/CONTRIBUTING.zh.md b/docs/CONTRIBUTING.zh.md index 329bc2d..edf3bf4 100644 --- a/docs/CONTRIBUTING.zh.md +++ b/docs/CONTRIBUTING.zh.md @@ -6,7 +6,7 @@ ```bash npm install -npm test # 250 个,约 1.5 秒,不碰网络 +npm test # 347 个,约 1.5 秒,不碰网络 npm run dev # 演示模式,不需要 key ``` diff --git a/docs/README.de.md b/docs/README.de.md index 8e9d4c8..fe377cf 100644 --- a/docs/README.de.md +++ b/docs/README.de.md @@ -290,7 +290,7 @@ Die Origin stammt aus der Anfrage und nicht aus einer Konstante zur Build-Zeit, Derselbe generierte Text in allen sechs Themes. Umschalten ist eine einzige Prop-Änderung im Client: kein Modellaufruf, keine Neuerzeugung. -Sechs Themes, jedes ein vollständiger Satz Design-Tokens. `app/registry.tsx` enthält **keinen einzigen Hex-Wert** — alles kommt aus CSS-Variablen. +Sechs Themes, jedes ein vollständiger Satz Design-Tokens. Alle Farben in `app/registry.tsx` kommen aus CSS-Variablen, mit **genau einer Ausnahme**, die ein Test bei einer hält: die drei macOS-Ampelpunkte im nachgebauten Screenshot im geteilten Hero, die den Fensterrahmen eines anderen Betriebssystems zeichnen und nicht die Palette von loom. | Theme | Charakter | Passt zu | |---|---|---| @@ -333,7 +333,7 @@ Die Prüfung braucht einen laufenden Server und `npx playwright install chromium ```bash -npm test # 250 Stück, rund 1,5 s, ohne Netz +npm test # 347 Stück, rund 1,5 s, ohne Netz ``` Sie decken **die drei dem Modell abgenommenen Regeln** ab: Die Anzahl der Verkaufsargumente entscheidet Raster oder Liste, die Anzahl der Preisstufen entscheidet Einzelkarte oder Vergleich, und ob es einen Oberflächen-Screenshot gibt, entscheidet das Layout des Kopfbereichs. Driftet eine davon, geht die Entscheidung still an ein Modell zurück, das sie nicht treffen kann — sie dürfen sich also nicht bewegen. @@ -382,7 +382,7 @@ fixtures/ en|zh|ja|ko.jsonl echte aufgezeichnete Läufe für den Demomodus app/ page.tsx der Editor, Stream-Verarbeitung, Theme- und Variantenwechsel im Client - registry.tsx wie die Blöcke aussehen, durchgehend über CSS-Variablen + registry.tsx wie die Blöcke aussehen, über CSS-Variablen llms.txt/ die Route /llms.txt api/generate Erzeugung api/edit Änderungen diff --git a/docs/README.es.md b/docs/README.es.md index c5cea27..11fb325 100644 --- a/docs/README.es.md +++ b/docs/README.es.md @@ -289,7 +289,7 @@ El origen se lee de la petición y no de una constante de compilación, porque e El mismo texto generado bajo los seis temas. Cambiar de tema es modificar una sola prop en el cliente: sin llamada al modelo, sin regeneración. -Seis temas, cada uno un conjunto completo de tokens de diseño. `app/registry.tsx` **no contiene ni un solo valor hexadecimal**: todo se lee de variables CSS. +Seis temas, cada uno un conjunto completo de tokens de diseño. Todos los colores de `app/registry.tsx` se leen de variables CSS, con **una sola excepción** que un test mantiene en una: los tres puntos del semáforo de macOS en la captura simulada del hero partido, que dibujan el marco de ventana de otro sistema operativo y no la paleta de loom. | Tema | Carácter | Encaja con | |---|---|---| @@ -332,7 +332,7 @@ La auditoría necesita el servidor en marcha y `npx playwright install chromium` ```bash -npm test # 250, unos 1,5 s, sin red +npm test # 347, unos 1,5 s, sin red ``` Cubren **las tres reglas recuperadas del modelo**: el número de argumentos decide rejilla o lista, el número de planes decide tarjeta única o comparativa, y la existencia de una captura de interfaz decide la composición de la portada. Si alguna se desvía, la decisión vuelve en silencio a un modelo que no puede tomarla, así que son las que no deben moverse. @@ -381,7 +381,7 @@ fixtures/ en|zh|ja|ko.jsonl ejecuciones reales grabadas, para el modo demo app/ page.tsx el editor, consumo del stream, cambio de tema y variante en cliente - registry.tsx el aspecto de cada bloque, todo mediante variables CSS + registry.tsx el aspecto de cada bloque, mediante variables CSS llms.txt/ la ruta /llms.txt api/generate generación api/edit ediciones diff --git a/docs/README.fr.md b/docs/README.fr.md index dca3626..65ab490 100644 --- a/docs/README.fr.md +++ b/docs/README.fr.md @@ -290,7 +290,7 @@ L'origine est lue dans la requête et non dans une constante de compilation, par Le même texte généré sous les six thèmes. Changer de thème, c'est modifier une seule prop côté client : aucun appel au modèle, aucune régénération. -Six thèmes, chacun un jeu complet de tokens de design. `app/registry.tsx` ne contient **aucune valeur hexadécimale** : tout passe par des variables CSS. +Six thèmes, chacun un jeu complet de tokens de design. Toutes les couleurs de `app/registry.tsx` passent par des variables CSS, à **une seule exception** qu'un test maintient à une : les trois pastilles du feu tricolore macOS dans la capture simulée du hero en deux colonnes, qui dessinent le chrome de fenêtre d'un autre système d'exploitation et non la palette de loom. | Thème | Caractère | Convient à | |---|---|---| @@ -333,7 +333,7 @@ L'audit a besoin d'un serveur lancé et de `npx playwright install chromium`, il ```bash -npm test # 250, environ 1,5 s, sans réseau +npm test # 347, environ 1,5 s, sans réseau ``` Ils couvrent **les trois règles reprises au modèle** : le nombre d'arguments décide grille ou liste, le nombre de paliers décide carte unique ou comparatif, et la présence d'une capture d'interface décide la mise en page de l'en-tête. Si l'une dérive, la décision retourne en silence à un modèle incapable de la prendre : ce sont celles qui ne doivent pas bouger. @@ -382,7 +382,7 @@ fixtures/ en|zh|ja|ko.jsonl exécutions réelles enregistrées, pour le mode démo app/ page.tsx l'éditeur, consommation du flux, changement de thème et de variante côté client - registry.tsx l'apparence de chaque bloc, uniquement via des variables CSS + registry.tsx l'apparence de chaque bloc, via des variables CSS llms.txt/ la route /llms.txt api/generate génération api/edit modifications diff --git a/docs/README.ja.md b/docs/README.ja.md index b0c858d..4941b67 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -292,7 +292,7 @@ origin はビルド時の定数ではなくリクエストから取ります。 同じ文章を 6 テーマで。切り替えはクライアント側の prop 一つの変更で、モデルは呼ばれず再生成も起きません。 -6 テーマ、それぞれが完結したデザイントークンの一式です。`app/registry.tsx` には**十六進数の色が一つもありません**——すべて CSS 変数経由です。 +6 テーマ、それぞれが完結したデザイントークンの一式です。`app/registry.tsx` の色はすべて CSS 変数経由で、**例外は一箇所だけ**、テストがそれを一箇所に留めています——分割ヒーロー内のモック画面にある macOS の信号機ドット 3 つで、これは loom のパレットではなく別の OS のウィンドウ装飾を描いたものです。 | テーマ | 特徴 | 向き先 | |---|---|---| @@ -335,7 +335,7 @@ npm run audit:a11y # 生成結果に axe-core、6 テーマすべて ```bash -npm test # 250 件、約 1.5 秒、ネットワーク不使用 +npm test # 347 件、約 1.5 秒、ネットワーク不使用 ``` 中心は**モデルから取り戻した三つの規則**です——売りの個数がグリッドかリストかを決め、料金段階数が単一か比較かを決め、画面写真の有無がファーストビューの版を決める。ここが戻ると、判断は答えられないモデルに黙って返されてしまうので、最も動いてはいけない部分です。 @@ -381,7 +381,7 @@ locales/ en|zh|ja|ko|es|fr|de|pt.json UI 翻訳、zh が基準 app/ page.tsx エディタ、ストリーム消費、クライアント側の再テーマ・再版 - registry.tsx ブロックの見た目、すべて CSS 変数経由 + registry.tsx ブロックの見た目、CSS 変数経由 llms.txt/ /llms.txt のルート api/generate 生成 api/edit 修正 diff --git a/docs/README.ko.md b/docs/README.ko.md index e1ea9c9..a0e42f7 100644 --- a/docs/README.ko.md +++ b/docs/README.ko.md @@ -292,7 +292,7 @@ origin 은 빌드 시점 상수가 아니라 요청에서 가져옵니다. 같 같은 문구를 여섯 테마로. 전환은 클라이언트 prop 하나를 바꾸는 일이며 모델 호출도 재생성도 없습니다. -6개 테마, 각각이 완결된 디자인 토큰 한 벌입니다. `app/registry.tsx` 에는 **16진수 색상이 하나도 없습니다** — 전부 CSS 변수를 거칩니다. +6개 테마, 각각이 완결된 디자인 토큰 한 벌입니다. `app/registry.tsx` 의 색은 전부 CSS 변수를 거치고 **예외는 딱 한 군데**이며, 테스트가 그 수를 하나로 붙들어 둡니다 — 분할 히어로 안 목업 스크린샷의 macOS 신호등 점 3개로, loom 의 팔레트가 아니라 다른 운영체제의 창 장식을 그린 것입니다. | 테마 | 특징 | 어울리는 곳 | |---|---|---| @@ -335,7 +335,7 @@ npm run audit:a11y # 생성 결과에 axe-core, 여섯 테마 전부 ```bash -npm test # 250개, 약 1.5초, 네트워크 미사용 +npm test # 347개, 약 1.5초, 네트워크 미사용 ``` 핵심은 **모델에게서 되찾아온 세 규칙**입니다 — 강점 개수가 그리드냐 리스트냐를, 요금 단계 수가 단일이냐 비교냐를, 화면 스크린샷 유무가 첫 화면 판을 정합니다. 여기가 되돌아가면 결정이 답할 수 없는 모델에게 조용히 넘어가므로, 가장 흔들리면 안 되는 부분입니다. @@ -381,7 +381,7 @@ locales/ en|zh|ja|ko|es|fr|de|pt.json UI 번역, zh 가 기준 app/ page.tsx 에디터, 스트림 소비, 클라이언트 측 리테마·리스타일 - registry.tsx 블록의 생김새, 전부 CSS 변수 경유 + registry.tsx 블록의 생김새, CSS 변수 경유 llms.txt/ /llms.txt 라우트 api/generate 생성 api/edit 수정 diff --git a/docs/README.pt.md b/docs/README.pt.md index 21c15ca..6f4fc08 100644 --- a/docs/README.pt.md +++ b/docs/README.pt.md @@ -289,7 +289,7 @@ A origem vem da requisição e não de uma constante de build, porque o mesmo bu O mesmo texto gerado sob os seis temas. Trocar de tema é mudar uma prop no cliente: sem chamada ao modelo, sem regeração. -Seis temas, cada um um conjunto completo de tokens de design. `app/registry.tsx` **não contém nenhum valor hexadecimal** — tudo vem de variáveis CSS. +Seis temas, cada um um conjunto completo de tokens de design. Todas as cores em `app/registry.tsx` vêm de variáveis CSS, com **uma única exceção** que um teste mantém em uma: os três pontos do semáforo do macOS na captura simulada do hero dividido, que desenham a moldura de janela de outro sistema operacional e não a paleta do loom. | Tema | Caráter | Combina com | |---|---|---| @@ -332,7 +332,7 @@ A auditoria precisa do servidor no ar e de `npx playwright install chromium`, po ```bash -npm test # 250 deles, cerca de 1,5 s, sem rede +npm test # 347 deles, cerca de 1,5 s, sem rede ``` Eles cobrem **as três regras retomadas do modelo**: a quantidade de diferenciais decide grade ou lista, a quantidade de planos decide cartão único ou comparativo, e a existência de uma captura de interface decide o layout do topo. Se alguma delas escorregar, a decisão volta em silêncio para um modelo que não consegue tomá-la — são as que não podem se mexer. @@ -381,7 +381,7 @@ fixtures/ en|zh|ja|ko.jsonl execuções reais gravadas, para o modo demo app/ page.tsx o editor, consumo do stream, troca de tema e variante no cliente - registry.tsx a aparência de cada bloco, tudo via variáveis CSS + registry.tsx a aparência de cada bloco, via variáveis CSS llms.txt/ a rota /llms.txt api/generate geração api/edit edições diff --git a/docs/README.zh.md b/docs/README.zh.md index 8cf9c6f..14ce3c3 100644 --- a/docs/README.zh.md +++ b/docs/README.zh.md @@ -292,7 +292,7 @@ JSON-RPC 那层是手写的,没用 MCP SDK,因为**这次改动没有加任 同一份生成的文案,六套主题。切换只是客户端改一个 prop——不调模型,不重新生成。 -6 套主题,每套是一组完整的设计 token。`app/registry.tsx` 里**没有任何十六进制色值**,全部走 CSS 变量: +6 套主题,每套是一组完整的设计 token。`app/registry.tsx` 里的颜色全部走 CSS 变量,**只有一处例外**,并且有测试把它钉在一处:分栏 hero 那张模拟截图上的三个 macOS 红绿灯圆点,它画的是另一个操作系统的窗口装饰,不是 loom 的配色: | 主题 | 特征 | 适合 | |---|---|---| @@ -335,7 +335,7 @@ npm run audit:a11y # axe-core 扫生成结果,六套主题全过一遍 ```bash -npm test # 250 个,约 1.5 秒,不碰网络 +npm test # 347 个,约 1.5 秒,不碰网络 ``` 测的是**三条被从模型手里拿回来的规则**——卖点条数决定网格还是列表、价格档数决定单档还是对比、有没有界面截图决定首屏版式。这几条一旦回归,决策就悄悄还给了一个答不了的模型,所以它们最不该漂。 @@ -384,7 +384,7 @@ fixtures/ zh|en|ja|ko.jsonl 真实录制的运行,供演示模式回放 app/ page.tsx 界面、流式消费、客户端改主题和版式 - registry.tsx 区块长什么样,全部读 CSS 变量 + registry.tsx 区块长什么样,读 CSS 变量 llms.txt/ /llms.txt 路由 api/generate 生成 api/edit 改 diff --git a/lib/compose.test.ts b/lib/compose.test.ts new file mode 100644 index 0000000..d4ebac1 --- /dev/null +++ b/lib/compose.test.ts @@ -0,0 +1,306 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { composePlanned } from "./compose"; +import { catalog } from "./catalog"; + +/** + * The orchestration seam had no test, and three of the audit's findings lived in it: a copy chunk that failed both its attempts took the whole page down, a Jev round that rejected while the identity chunk was still in flight became an unhandled rejection, and an auto slot that layoutRules never spoke for stayed a skeleton through to `complete` while the run still reported `finish`. + * + * Both transports are stubbed at `fetch`, so nothing here touches the network and the whole file runs in milliseconds. The Jev stand-in answers every question with its first criterion, which is enough to fix an archetype and a theme deterministically; the copy stand-in is told per chunk what to return, including how to fail. + */ + +const real = globalThis.fetch; +afterEach(() => { + globalThis.fetch = real; +}); + +type ChunkName = "identity" | "features" | "commerce" | "place" | "social"; + +/** Which chunk a request is, read off the schema in its system turn — the same way a reader would tell them apart. */ +const MARKER: Record<ChunkName, string> = { + identity: '"brand"', + features: '"featuresTitle"', + commerce: '"pricingTitle"', + place: '"galleryTitle"', + social: '"testimonialsTitle"', +}; + +const IDENTITY = { + brand: "青石", + tagline: "城东的手冲咖啡", + heroTitle: "每天现烘的豆子", + heroSubtitle: "小批量烘焙,当天出杯,豆单每周轮换。", + heroBullets: ["当日烘焙", "单一产区", "自带杯减五元"], + primaryCta: "看豆单", + secondaryCta: "找过来", + navLinks: ["豆单", "空间", "课程", "关于"], + footerColumns: ["豆单", "空间", "课程", "关于"], + footerNote: "青石咖啡 · 城东", + visualKind: "scene", +}; + +const FEATURES = { + featuresTitle: "我们怎么做咖啡", + features: [{ icon: "◈", title: "当日烘焙", body: "每天早上烘完,下午就进磨豆机。" }], + featuresDeep: [{ title: "当日烘焙", body: "烘好的豆子在第三天风味最稳,我们按这个节奏排烘焙表。" }], +}; + +const COMMERCE = { + pricingTitle: "价目", + pricingCta: "来一杯", + tiers: [ + { name: "手冲", price: "38", period: "每杯", features: ["单一产区", "可带走"], highlighted: true }, + ], +}; + +const SOCIAL = { + testimonialsTitle: "客人说", + testimonials: [{ quote: "豆子新鲜,坐一下午也没人赶。", name: "林", role: "常客" }], + faqTitle: "常见问题", + faq: [{ q: "有位子吗?", a: "工作日下午通常有,周末建议早点来。" }], + ctaTitle: "明天早上八点开门", + ctaBody: "带上自己的杯子,减五元。", +}; + +const PLACE = { + galleryTitle: "店里", + galleryCaption: "常在店里的几样东西", + gallery: [{ title: "吧台", note: "六个座位,看得到手冲。" }], + stepsTitle: "第一次来", + steps: [{ title: "先看豆单", body: "豆单在门口黑板上,每周换一次。" }], + contactTitle: "找过来", + address: "示例区示例路 1 号", + hours: "08:00-19:00", + phone: "000-0000-0000", + contactNote: "门口可停两辆自行车。", + contactLabels: { address: "地址", hours: "营业时间", phone: "电话" }, + teamTitle: "店里的人", + team: [{ name: "林", role: "烘豆师", bio: "烘了六年豆子,每天五点到店。" }], +}; + +const DEFAULT_COPY: Record<ChunkName, unknown> = { + identity: IDENTITY, + features: FEATURES, + commerce: COMMERCE, + place: PLACE, + social: SOCIAL, +}; + +type Event = Record<string, unknown>; +type SpecElement = { type: string; props?: Record<string, unknown> }; +type Spec = { root: string; elements: Record<string, SpecElement> }; + +/** `null` for a chunk means the model answers with prose instead of JSON, on both attempts, which is what `两次都没拿到合法 JSON` is. */ +async function run(options: { + copy?: Partial<Record<ChunkName, unknown | null>>; + wants?: Record<string, number>; + secondJevRound?: "ok" | "500"; + abortOn?: ChunkName; +} = {}) { + const copy = { ...DEFAULT_COPY, ...options.copy }; + const wants = options.wants ?? {}; + const controller = new AbortController(); + const calls: ChunkName[] = []; + let jevRounds = 0; + const unhandled: unknown[] = []; + const record = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", record); + + const json = (payload: unknown) => + new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + globalThis.fetch = vi.fn().mockImplementation(async (url: string, init: RequestInit) => { + const sent = JSON.parse(init.body as string); + if (String(url).includes("typesafe.ai")) { + jevRounds += 1; + if (jevRounds > 1 && options.secondJevRound === "500") { + return new Response("upstream is down", { status: 500 }); + } + const questions = sent.questions as Record<string, { criteria?: Record<string, string> }>; + const answers: Record<string, unknown> = {}; + for (const [key, question] of Object.entries(questions)) { + const slot = key.startsWith("want_") ? key.slice("want_".length) : null; + answers[key] = { + choice: Object.keys(question.criteria ?? {})[0] ?? "", + confidence: 0.9, + noul: slot ? (wants[slot] ?? 0) : 0, + }; + } + return json({ answers, usage: { input_tokens: 10 } }); + } + const system = (sent.messages as { role: string; content: string }[]).find( + (message) => message.role === "system", + )!.content; + const name = (Object.keys(MARKER) as ChunkName[]).find((chunk) => + system.includes(MARKER[chunk]), + )!; + calls.push(name); + if (name === options.abortOn) { + // What the 110-second timeout in the route does to a request already in flight. + controller.abort(new DOMException("aborted", "AbortError")); + throw new DOMException("aborted", "AbortError"); + } + const answer = copy[name]; + return json({ + choices: [{ message: { content: answer === null ? "抱歉,我先说明一下:" : JSON.stringify(answer) } }], + usage: { completion_tokens: 100 }, + }); + }); + + const events: Event[] = []; + let threw: unknown = null; + try { + for await (const event of composePlanned( + "jev-key", + "llm-key", + "a coffee bar on the east side", + controller.signal, + "en", + )) { + events.push(event as Event); + } + } catch (error) { + threw = error; + } + // Give a rejection created but never awaited a turn to surface before the listener comes off. + await new Promise((resolve) => setTimeout(resolve, 10)); + process.off("unhandledRejection", record); + + const complete = events.find((event) => event.type === "complete"); + return { + events, + calls, + threw, + unhandled, + complete, + spec: complete?.spec as Spec | undefined, + types: (name: string) => (complete!.spec as Spec).elements[name]?.type, + }; +} + +/** Landing, with the optional blocks the three surviving chunks fill plus the gallery the failing one would have. */ +const LANDING_WANTS = { social: 0.9, pricing: 0.9, faq: 0.9, gallery: 0.9 }; + +describe("composePlanned", () => { + it("keeps every block the other chunks filled when one chunk fails both attempts, instead of ending the run", async () => { + const { threw, unhandled, complete, types, events } = await run({ + copy: { place: null }, + wants: LANDING_WANTS, + }); + + expect(threw).toBeNull(); + expect(unhandled).toEqual([]); + expect(complete).toBeDefined(); + expect(events.some((event) => event.type === "error")).toBe(false); + + // Everything the surviving chunks paid for is in the finished page. + expect(types("slot_nav")).toBe("Nav"); + expect(types("slot_hero")).toBe("HeroCentered"); + expect(types("slot_features")).toBe("FeatureList"); + expect(types("slot_pricing")).toBe("Pricing"); + expect(types("slot_social")).toBe("Testimonials"); + expect(types("slot_faq")).toBe("FAQ"); + expect(types("slot_cta")).toBe("CTABand"); + expect(types("slot_footer")).toBe("Footer"); + // And only the failed chunk's block is missing, still holding its place. + expect(types("slot_gallery")).toBe("Skeleton"); + }); + + it("reports the failed chunk as a phase that produced no copy rather than as a new kind of event, so the stream's vocabulary is unchanged", async () => { + const { events } = await run({ copy: { place: null }, wants: LANDING_WANTS }); + + expect(new Set(events.map((event) => event.type))).toEqual( + new Set(["plan", "partial", "content", "select", "picks", "complete"]), + ); + const place = events.find((event) => event.type === "partial" && event.phase === "place"); + expect(place).toBeDefined(); + expect(place!.failed).toMatch(/JSON/); + // The other three still report themselves as ordinary phases. + expect( + events.filter((event) => event.type === "partial" && event.failed === undefined).map((e) => e.phase), + ).toEqual(expect.arrayContaining(["frame", "identity", "features", "commerce", "social"])); + }); + + it("still hands back a spec the catalog accepts and JSON can carry, which is what every export is built from", async () => { + const { spec } = await run({ copy: { place: null }, wants: LANDING_WANTS }); + + const roundTripped = JSON.parse(JSON.stringify(spec)); + expect(catalog.validate(roundTripped).success).toBe(true); + const known = new Set(catalog.componentNames as readonly string[]); + for (const element of Object.values(spec!.elements)) { + expect(known.has(element.type), `${element.type} is not in the catalog`).toBe(true); + } + }); + + it("spends both attempts on the failing chunk and no more, so one bad chunk cannot double the bill for the others", async () => { + const { calls } = await run({ copy: { place: null }, wants: LANDING_WANTS }); + + expect(calls.filter((name) => name === "place")).toHaveLength(2); + for (const name of ["identity", "features", "commerce", "social"] as const) { + expect(calls.filter((call) => call === name), name).toHaveLength(1); + } + }); + + it("survives a variant round that fails after the request went out, which used to be an unhandled rejection rather than a caught one", async () => { + const { threw, unhandled, events, types } = await run({ + secondJevRound: "500", + wants: LANDING_WANTS, + }); + + expect(threw).toBeNull(); + expect(unhandled).toEqual([]); + // No select line: the round produced nothing, and saying otherwise would be a lie in the decision log. + expect(events.some((event) => event.type === "select")).toBe(false); + // The page is still whole, because the variants it could not ask about resolve from the copy instead. + expect(types("slot_nav")).toBe("Nav"); + expect(types("slot_social")).toBe("Testimonials"); + expect(types("slot_hero")).toBe("HeroCentered"); + }); + + it("renders the hero even when the identity answer omits visualKind, because a required block with no layout rule used to stay a skeleton for the whole run", async () => { + const withoutVisualKind = { ...IDENTITY, visualKind: undefined }; + const { types } = await run({ copy: { identity: withoutVisualKind }, wants: LANDING_WANTS }); + + expect(types("slot_hero")).toBe("HeroCentered"); + }); + + it("reports unavailable when nothing rendered, instead of calling a page of grey boxes a finish", async () => { + const { complete, types } = await run({ + copy: { identity: { ok: true }, features: null, commerce: null, place: null, social: null }, + wants: LANDING_WANTS, + }); + + expect(types("slot_hero")).toBe("Skeleton"); + expect(complete!.stopReason).toBe("unavailable"); + }); + + it("survives a chunk that answers a field with the wrong type, which is valid JSON and so never triggers a retry", async () => { + // A free project priced as `"tiers": "免费"`. Every variant's props are built eagerly, so the string reached `.slice(...).map` in elementsFor and threw inside the generator — on pages that had no pricing block at all. + const { threw, spec, types } = await run({ + copy: { commerce: { pricingTitle: "价目", pricingCta: "来一杯", tiers: "免费" } }, + wants: LANDING_WANTS, + }); + + expect(threw).toBeNull(); + expect(types("slot_hero")).toBe("HeroCentered"); + expect(types("slot_gallery")).toBe("Gallery"); + // The wrong-typed field never reaches the renderer as itself. + expect(Array.isArray(spec!.elements.slot_pricing!.props!.tiers)).toBe(true); + }); + + it("ends the run when the signal is aborted rather than reporting each remaining chunk as a block that produced no copy", async () => { + // Tolerating a failed chunk must not tolerate a cancelled run: after the route's 110-second timeout every chunk still in flight rejects, and the caller needs to hear that once, as a failure. + const { threw, complete } = await run({ abortOn: "place", wants: LANDING_WANTS }); + + expect(threw).toBeInstanceOf(DOMException); + expect(complete).toBeUndefined(); + }); + + it("reports finish when at least one block carries copy, so the two outcomes stay distinguishable", async () => { + const { complete } = await run({ wants: LANDING_WANTS }); + + expect(complete!.stopReason).toBe("finish"); + }); +}); diff --git a/lib/compose.ts b/lib/compose.ts index de63ab8..0a1dfe0 100644 --- a/lib/compose.ts +++ b/lib/compose.ts @@ -238,6 +238,7 @@ export async function* composePlanned( const variants = Object.keys(SLOTS[slot].variants); if (variants.length === 1) picks[slot] = variants[0]!; } + // The catch is attached here, where the promise is created, and not at the await below. This request goes out now but is not awaited until the identity chunk has landed, and a rejection arriving in between is an unhandled rejection: it kills the process before any try/catch at the await site is installed. Observed against a Jev 500 — `PromiseRejectionHandledWarning`, then the run dies with a page already on screen. A variant lookup that fails costs the variants and not the page: the single-variant seeds, the layoutRules picks and the per-slot fallback below still resolve every slot. The decision log simply has no `select` line, which is the honest record of a round that produced nothing. const selectPromise = multi.length > 0 ? ask( @@ -257,7 +258,7 @@ export async function* composePlanned( ]), ), signal, - ) + ).catch(() => null) : null; let content: Partial<SiteContent> = {}; @@ -268,9 +269,15 @@ export async function* composePlanned( root: { type: "Page", props: { theme: themeKey }, children: [] as string[] }, }; const children: string[] = []; + let rendered = 0; for (const slot of SLOT_ORDER) { if (!chosen.has(slot)) continue; const key = `slot_${slot}`; + // An auto slot is never put to jev and is not seeded above, so layoutRules is the only thing that can ever give it a pick — and every one of those branches is conditional on content that may not arrive. An identity answer that omits the trailing `visualKind` leaves the hero, required by all six archetypes, a grey skeleton from the frame event through to `complete`, and it exports that way; the same happens to features when the chunk returns an empty `features` array beside a populated `featuresDeep`. So a slot that reaches build time unpicked takes the first of its own variants the copy can actually fill, read off SLOTS rather than from a table of defaults copied in here. It is written back into `picks` so the stream, the exports and the edit endpoint all describe the same page, and a layoutRules rule landing later still overrides it: a default is a default, not a lock. + if (!picks[slot]) { + const fallback = Object.keys(SLOTS[slot].variants).find((id) => isReady(id, content)); + if (fallback) picks[slot] = fallback; + } const id = picks[slot]; const element = id ? elements[id] : undefined; // The frame is drawn from the plan alone; a block that has no copy yet @@ -279,12 +286,14 @@ export async function* composePlanned( specElements[key] = ready ? { ...element, children: [] } : { type: "Skeleton", props: { kind: SKELETON_KIND[slot] ?? "section" }, children: [] }; + if (ready) rendered += 1; children.push(key); } (specElements.root as { children: string[] }).children = children; return { + // Blocks that actually carry copy, not blocks that were planned. `children.length` counted the skeletons too, and since every archetype has a non-empty `required` it could never be zero — so the `unavailable` branch below was unreachable and a page of nothing but grey boxes reported `finish`. spec: { root: "root", elements: specElements, state: {} }, - count: children.length, + count: rendered, }; }; @@ -317,8 +326,8 @@ export async function* composePlanned( }; } - if (selectPromise) { - const second = await selectPromise; + const second = selectPromise ? await selectPromise : null; + if (second) { inputTokens += second.usage?.input_tokens ?? 0; const detail: Record<string, { choice: string; confidence?: number }> = {}; for (const slot of multi) { @@ -348,12 +357,33 @@ export async function* composePlanned( const context = contextFrom(prompt, content as Record<string, unknown>); const names: ChunkName[] = ["features", "commerce", "social", "place"]; const jobs = names.map((name) => - generateChunk(llmKey, name, context, signal, language).then((result) => ({ - name, - result, - })), + generateChunk(llmKey, name, context, signal, language).then( + (result) => ({ name, result }), + (error: unknown) => { + // The chunk's name is pinned to the error here because asSettled reports the reason without saying which promise produced it, and `两次都没拿到合法 JSON` on its own does not tell the reader which blocks are missing. The original object is kept rather than re-wrapped: an abort arrives as a DOMException, which is not an Error under jsdom, and rebuilding it as one would strip the type the caller distinguishes a cancelled run by. + if (error && typeof error === "object") throw Object.assign(error, { chunk: name }); + throw Object.assign(new Error(String(error)), { chunk: name }); + }, + ), ); - for await (const { name, result } of asSettled(jobs)) { + for await (const settled of asSettled(jobs)) { + if (settled.status === "rejected") { + // README measures malformed JSON at roughly one run in two, so one chunk failing both its attempts is an ordinary outcome, not a catastrophe. It used to be one: the rejection escaped composePlanned, the route turned it into an `error` event, and the client froze a page whose nav, hero, testimonials and pricing had already painted — every export button unmounted with it. A failed chunk now costs its own blocks and nothing else. There is no new event tag for it: the blocks it would have filled stay skeletons, which is exactly what this stream already means by "no copy here", and the phase line in the decision log names the chunk that produced none. + const failure = settled.reason as Error & { chunk?: ChunkName }; + // An abort is not a per-chunk failure. The whole run is over, every remaining chunk will reject the same way, and the caller needs to hear it once. + if (signal.aborted) throw failure; + const { spec } = buildSpec(); + yield { + type: "partial", + phase: failure.chunk ?? "chunk", + spec, + failed: failure.message, + outputTokens, + elapsedMs: Date.now() - started, + }; + continue; + } + const { name, result } = settled.value; outputTokens += result.outputTokens; content = { ...content, ...(result.parsed as Partial<SiteContent>) }; elements = elementsFor(content as SiteContent); diff --git a/lib/content-parallel.test.ts b/lib/content-parallel.test.ts index 32f0ca8..4f5b3fb 100644 --- a/lib/content-parallel.test.ts +++ b/lib/content-parallel.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { asSettled, isReady, SLOT_NEEDS } from "./content-parallel"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { asSettled, generateChunk, isReady, SLOT_NEEDS, type Settled } from "./content-parallel"; import { SLOTS } from "./plan"; import type { SiteContent } from "./content"; @@ -29,6 +29,29 @@ describe("isReady", () => { expect(isReady("nav_minimal", { brand: "某某", primaryCta: "了解" })).toBe(true); }); + it("rejects a null field, because valid JSON carries it past both retries and it throws in the renderer instead", () => { + expect( + isReady("contact", { + contactTitle: "找我们", + address: "示例路 1 号", + hours: "10:00-19:00", + contactLabels: null, + } as unknown as Partial<SiteContent>), + ).toBe(false); + }); + + it("rejects a blank string, because a button whose label is empty renders as an unlabelled button", () => { + expect( + isReady("hero_centered", { + heroTitle: "标题", + heroSubtitle: "副标题", + tagline: "定位", + primaryCta: "开始", + secondaryCta: " ", + } as Partial<SiteContent>), + ).toBe(false); + }); + it("refuses a variant it knows nothing about", () => { expect(isReady("nav_does_not_exist", { brand: "某某" })).toBe(false); }); @@ -43,25 +66,31 @@ describe("isReady", () => { }); describe("asSettled", () => { + /** Only the fulfilled values, so the ordering and draining invariants read the way they did before this helper reported failures too. */ + const fulfilled = <T>(settled: Settled<T>) => { + if (settled.status !== "fulfilled") throw new Error("expected a fulfilled outcome"); + return settled.value; + }; + it("yields in completion order, not call order", async () => { const delayed = <T>(value: T, ms: number) => new Promise<T>((resolve) => setTimeout(() => resolve(value), ms)); const seen: string[] = []; - for await (const value of asSettled([ + for await (const settled of asSettled([ delayed("slow", 40), delayed("fast", 5), delayed("middle", 20), ])) { - seen.push(value); + seen.push(fulfilled(settled)); } expect(seen).toEqual(["fast", "middle", "slow"]); }); it("drains everything exactly once", async () => { const values: number[] = []; - for await (const value of asSettled([1, 2, 3, 4].map((n) => Promise.resolve(n)))) { - values.push(value); + for await (const settled of asSettled([1, 2, 3, 4].map((n) => Promise.resolve(n)))) { + values.push(fulfilled(settled)); } expect([...values].sort()).toEqual([1, 2, 3, 4]); }); @@ -71,4 +100,93 @@ describe("asSettled", () => { for await (const value of asSettled([])) values.push(value); expect(values).toEqual([]); }); + + it("reports a rejection as an outcome and keeps draining, so one failed chunk cannot discard the copy that already landed", async () => { + const boom = new Error("两次都没拿到合法 JSON"); + const seen: string[] = []; + for await (const settled of asSettled([ + Promise.resolve("a"), + Promise.reject(boom), + Promise.resolve("c"), + ])) { + seen.push(settled.status === "fulfilled" ? settled.value : `rejected:${(settled.reason as Error).message}`); + } + expect([...seen].sort()).toEqual(["a", "c", "rejected:两次都没拿到合法 JSON"]); + }); + + it("attaches its handlers before it returns, so a rejection that lands before the caller starts iterating is not an unhandled rejection", async () => { + const unhandled: unknown[] = []; + const record = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", record); + try { + const outcomes = asSettled([Promise.reject(new Error("jev 500"))]); + // The gap the old generator left open: everything the caller does between constructing the iterator and pulling from it. + await new Promise((resolve) => setTimeout(resolve, 10)); + const seen: Settled<never>[] = []; + for await (const settled of outcomes) seen.push(settled); + expect(seen).toHaveLength(1); + expect(unhandled).toEqual([]); + } finally { + process.off("unhandledRejection", record); + } + }); +}); + +describe("retrying a truncated answer", () => { + const real = globalThis.fetch; + afterEach(() => { + globalThis.fetch = real; + }); + + const signal = new AbortController().signal; + + /** Records what was asked for, and answers the first attempt with an object the token cap cut in half. */ + function truncateOnce(finishReason: string) { + const sent: { max_tokens: number; messages: { role: string; content: string }[] }[] = []; + globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init: RequestInit) => { + const body = JSON.parse(init.body as string); + sent.push(body); + const first = sent.length === 1; + return new Response( + JSON.stringify({ + choices: [ + { + message: { + // Cut after a complete nested item, which is what truncation actually looks like: `lastIndexOf("}")` finds that inner brace and JSON.parse fails on the unbalanced rest. + content: first + ? '{"galleryTitle":"店里","gallery":[{"title":"吧台","note":"六个座位"},{"title":"手冲台","note":"看得到' + : '{"galleryTitle":"店里"}', + }, + finish_reason: first ? finishReason : "stop", + }, + ], + usage: { completion_tokens: 100 }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + }); + return sent; + } + + it("raises the cap the second time, because re-sending the budget the answer just overran cuts it in the same place", async () => { + const sent = truncateOnce("length"); + await generateChunk("k", "place", "一家咖啡馆", signal); + expect(sent).toHaveLength(2); + expect(sent[1]!.max_tokens).toBeGreaterThan(sent[0]!.max_tokens); + }); + + it("tells the model it ran out of room rather than quoting a parse error it cannot act on", async () => { + const sent = truncateOnce("length"); + await generateChunk("k", "place", "一家咖啡馆", signal); + const retry = sent[1]!.messages.at(-1)!.content; + expect(retry).toContain("截断"); + expect(retry).not.toMatch(/Unexpected|JSON\.parse/); + }); + + it("leaves the budget alone when the answer was malformed for some other reason, so a fenced or chatty reply does not buy tokens it will not use", async () => { + const sent = truncateOnce("stop"); + await generateChunk("k", "place", "一家咖啡馆", signal); + expect(sent).toHaveLength(2); + expect(sent[1]!.max_tokens).toBe(sent[0]!.max_tokens); + }); }); diff --git a/lib/content-parallel.ts b/lib/content-parallel.ts index f926970..f3c7fb7 100644 --- a/lib/content-parallel.ts +++ b/lib/content-parallel.ts @@ -61,6 +61,9 @@ function userFor(language: string, text: string): string { return `${text}\n\n${LANGUAGE_RULE[language] ?? LANGUAGE_RULE.zh}`; } +/** What the retry turn is told when the server reports the previous answer was cut off by the token cap rather than finished. */ +const TRUNCATED = "上次输出写到一半就被长度上限截断了,JSON 没收尾;这次每条都写短一些,先把结构写完整"; + async function call( apiKey: string, system: string, @@ -69,6 +72,8 @@ async function call( signal: AbortSignal, ): Promise<{ parsed: Record<string, unknown>; outputTokens: number }> { let lastProblem = ""; + // Attempt 2 used to re-send the identical cap, which is the one malformed-JSON cause a retry cannot help with: the same budget cuts the object at the same place. The recorded happy path leaves little room to absorb a wordier language — the `place` chunk spends 1067 of its 1300 tokens in German and 1098 in Japanese — so a response the server reports as cut short buys more room on the retry instead of the same wall. + let budget = maxTokens; for (let attempt = 1; attempt <= 2; attempt += 1) { const messages: { role: string; content: string }[] = [ { role: "system", content: system }, @@ -88,7 +93,7 @@ async function call( Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", }, - body: JSON.stringify({ model: MODEL, max_tokens: maxTokens, messages }), + body: JSON.stringify({ model: MODEL, max_tokens: budget, messages }), signal, cache: "no-store", }); @@ -113,16 +118,19 @@ async function call( throw new Error(`Copy model returned HTTP ${response.status}.${hint} ${body}`); } const body = (await response.json()) as { - choices?: { message: { content: string } }[]; + choices?: { message: { content: string }; finish_reason?: string }[]; usage?: { completion_tokens?: number }; error?: { message: string }; }; if (body.error) throw new Error(body.error.message); + // The server already knows why the JSON is broken when it ran out of budget mid-object. Reading it turns an unexplained parse error into a complaint the model can act on, and raises the ceiling it just hit. + const truncated = body.choices?.[0]?.finish_reason === "length"; + if (truncated) budget = Math.round(maxTokens * 1.5); const raw = body.choices?.[0]?.message?.content ?? ""; const start = raw.indexOf("{"); const end = raw.lastIndexOf("}"); if (start < 0 || end <= start) { - lastProblem = "没有找到 JSON"; + lastProblem = truncated ? TRUNCATED : "没有找到 JSON"; continue; } try { @@ -131,7 +139,8 @@ async function call( outputTokens: body.usage?.completion_tokens ?? 0, }; } catch (error) { - lastProblem = error instanceof Error ? error.message : String(error); + const message = error instanceof Error ? error.message : String(error); + lastProblem = truncated ? `${TRUNCATED}(${message})` : message; } } throw new Error(`两次都没拿到合法 JSON:${lastProblem}`); @@ -235,21 +244,51 @@ export const SLOT_NEEDS: Record<string, (keyof SiteContent)[]> = { footer: ["brand", "footerColumns", "footerNote"], }; +/** + * Present is not the same as usable, and the gate used to test only presence. + * + * `"contactLabels": null` is valid JSON, so neither retry attempt fires; it passed the old `value !== undefined` test, the contact block was declared ready, and the renderer threw on `labels.address` during render. `"secondaryCta": ""` passed the same way and drew a button with no label. The test is on the runtime value rather than on a table of which fields hold arrays, because that table already exists as the SiteContent type and a second copy of it would drift away from the first. + */ +function usable(value: unknown) { + if (value === null || value === undefined) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "string") return value.trim() !== ""; + return true; +} + export function isReady(id: string, content: Partial<SiteContent>) { const needs = SLOT_NEEDS[id]; if (!needs) return false; - return needs.every((field) => { - const value = content[field]; - return Array.isArray(value) ? value.length > 0 : value !== undefined; - }); + return needs.every((field) => usable(content[field])); } -/** Yields each promise's result in completion order rather than call order. */ -export async function* asSettled<T>(promises: Promise<T>[]): AsyncGenerator<T> { - const pending = new Map(promises.map((p, i) => [i, p.then((v) => [i, v] as const)])); - while (pending.size > 0) { - const [index, value] = await Promise.race(pending.values()); - pending.delete(index); - yield value; - } +export type Settled<T> = + | { status: "fulfilled"; value: T } + | { status: "rejected"; reason: unknown }; + +/** + * Yields each promise's outcome in completion order rather than call order. + * + * It was named for settling and did not settle: the loop raced the raw promises, so the first rejection took the race down with it and the caller lost every result that had already landed — one failed copy chunk discarded a page whose other blocks were already painted. It now reports each outcome the way Promise.allSettled does, and the caller decides what a failure costs. + * + * The handlers are attached here in the function body rather than inside the generator, which is why this is a plain function returning an AsyncGenerator instead of an `async function*`: a generator body does not run until its first next(), and a rejection landing in that gap is an unhandled rejection that takes the process down before any caller can catch it. + */ +export function asSettled<T>(promises: Promise<T>[]): AsyncGenerator<Settled<T>> { + const pending = new Map<number, Promise<readonly [number, Settled<T>]>>(); + promises.forEach((promise, index) => { + pending.set( + index, + promise.then( + (value) => [index, { status: "fulfilled", value }] as const, + (reason: unknown) => [index, { status: "rejected", reason }] as const, + ), + ); + }); + return (async function* () { + while (pending.size > 0) { + const [index, settled] = await Promise.race(pending.values()); + pending.delete(index); + yield settled; + } + })(); } diff --git a/lib/content.ts b/lib/content.ts index 5674b25..51982ad 100644 --- a/lib/content.ts +++ b/lib/content.ts @@ -65,6 +65,8 @@ export type SiteContent = { /** Fill every block template with the generated content. */ export function elementsFor(content: SiteContent) { + // Asked to price a free project, the model answers `"tiers": "免费"` — a string where the schema asked for an array, and valid JSON, so neither retry attempt fires. `(content.tiers ?? []).slice(0, 1).map(...)` was the only eager array operation in this function, so that string threw `.map is not a function` inside the generator and destroyed a page that had no pricing block in it at all. Both pricing variants now read the same guarded value, so a wrong-typed field costs its own block and nothing else. Every other array here is handed to the renderer untouched and only reaches `.map` if its block was chosen and passed isReady. + const tiers = Array.isArray(content.tiers) ? content.tiers : []; return { page: { type: "Page", props: { theme: "auto" } }, nav_standard: { @@ -144,12 +146,12 @@ export function elementsFor(content: SiteContent) { props: { cta: content.pricingCta, title: content.pricingTitle, - tiers: (content.tiers ?? []).slice(0, 1).map((t) => ({ ...t, highlighted: true })), + tiers: tiers.slice(0, 1).map((t) => ({ ...t, highlighted: true })), }, }, pricing_multi: { type: "Pricing", - props: { title: content.pricingTitle, tiers: content.tiers, cta: content.pricingCta }, + props: { title: content.pricingTitle, tiers, cta: content.pricingCta }, }, testimonials: { type: "Testimonials", diff --git a/lib/demo.test.ts b/lib/demo.test.ts index 59a607f..8846879 100644 --- a/lib/demo.test.ts +++ b/lib/demo.test.ts @@ -113,14 +113,26 @@ describe("replayEdit", () => { .map((m) => m[1]!.trim()) .filter((s) => s.length > 2); - it("honours every edit its own placeholder suggests, in every language", () => { + const placeholderOf = (locale: string) => + ( + JSON.parse( + readFileSync(join(import.meta.dirname, "..", "locales", `${locale}.json`), "utf8"), + ) as { editPlaceholder: string } + ).editPlaceholder; + + /** The blocks that locale's own recording ends up with — the page the demo user is looking at when they read the placeholder. */ + const fixtureSlots = (locale: string) => + readFileSync(join(FIXTURES, `${locale}.jsonl`), "utf8") + .split("\n") + .filter((l) => l.trim()) + .map((l) => JSON.parse(l) as { type?: string; slots?: string[] }) + .filter((event) => event.type === "plan") + .at(-1)!.slots!; + + it("recognises every edit its own placeholder suggests, in every language, so no suggestion is a phrase the word list has never heard of", () => { const everything = ["nav", "hero", "social", "pricing", "faq", "footer", "gallery"]; for (const locale of UI_LOCALES) { - const dict = JSON.parse( - readFileSync(join(import.meta.dirname, "..", "locales", `${locale}.json`), "utf8"), - ) as { editPlaceholder: string }; - - const proposed = suggestions(dict.editPlaceholder); + const proposed = suggestions(placeholderOf(locale)); expect(proposed.length, `${locale} placeholder suggests nothing parseable`).toBeGreaterThan(0); for (const suggestion of proposed) { @@ -132,6 +144,47 @@ describe("replayEdit", () => { } }); + /** + * The test above measured a page no user has: a hand-written list with every slot in it. zh's recording has no pricing block, so 「不要定价了」 — the phrase zh's own placeholder proposes — matched the pricing words, failed the presence check and fell through to the catch-all. Driving each locale's suggestions against its own fixture is the only version of this test that measures what the reader sees. + */ + it("answers every placeholder suggestion against the page that locale's own fixture records, so the catch-all is never what a suggested edit gets", () => { + for (const locale of UI_LOCALES) { + const slots = fixtureSlots(locale); + for (const suggestion of suggestions(placeholderOf(locale))) { + const outcome = replayEdit(suggestion, slots, "forest"); + const why = `${locale} suggests "${suggestion}" against [${slots.join(" ")}]`; + expect(outcome.blockedBy ?? "", why).not.toContain("only replays a few recorded edits"); + if (outcome.action !== "unclear") continue; + // The one refusal allowed is the honest one: this page does not have that block. Anything else means the suggestion silently did nothing. + const named = /has no (\w+) block/.exec(outcome.blockedBy ?? ""); + expect(named, `${why} and was refused without naming a block`).not.toBeNull(); + expect(slots, why).not.toContain(named![1]); + } + } + }); + + it("names the block a page lacks instead of adding it, so a request to drop something absent is not answered by putting it on the page", () => { + const zh = fixtureSlots("zh"); + expect(zh, "the zh fixture used to have no pricing block, which is what this guards").not.toContain("pricing"); + const outcome = replayEdit("不要定价了", zh, "forest"); + expect(outcome.action).toBe("unclear"); + expect(outcome.blockedBy).toContain("has no pricing block"); + }); + + it("refuses to restyle a block the page does not have, so the presence rule covers every branch and not only the ones a fixture happens to exercise", () => { + expect(replayEdit("换个导航栏样式", ["hero", "footer"], "forest")).toMatchObject({ + action: "unclear", + blockedBy: expect.stringContaining("has no nav block"), + }); + }); + + it("still adds a block the page lacks when nothing in the request says to take one away, so 加个 keeps working", () => { + expect(replayEdit("加个价格表", ["nav", "hero", "footer"], "forest")).toMatchObject({ + action: "add", + slot: "pricing", + }); + }); + it("reads an instruction the same with or without its accents", () => { // People type "enjoue" and "precos" on keyboards that make the accent work. const present = ["nav", "hero", "pricing", "footer"]; diff --git a/lib/demo.ts b/lib/demo.ts index 25d693a..bc9ae34 100644 --- a/lib/demo.ts +++ b/lib/demo.ts @@ -107,6 +107,8 @@ export async function* replay( * Portuguese users watching every suggestion in their own placeholder do * nothing. A test now feeds each locale's `editPlaceholder` suggestions back * through here, so the app cannot propose an edit it will not honour. + * + * That test measured the wrong page, though, and the same class of bug survived it: it handed every suggestion a synthetic block list containing every slot, while fixtures/zh.jsonl — the recording a Chinese-locale visitor actually gets — has no pricing block. So zh's own placeholder, 「不要定价了」, fell past every branch to the catch-all and the page did not move. The test now drives each locale's suggestions against the slots that locale's fixture really produces. */ /** Written without diacritics, and compared against input stripped the same way. */ @@ -122,25 +124,65 @@ const EDIT_WORDS = { nav: ["nav", "导航", "내비", "ナビ"], } as const; +/** + * Words that mean "take it away". The groups above name a block but not a direction, and presence decided it for them: the block is on the page so remove it, it is not so add it. That inverts the request when someone asks to drop a block their page never had, which is exactly what the zh placeholder suggests against the zh fixture. Direction is only consulted when the block is absent — when it is on the page presence still decides, so nothing here can turn a removal into something else. + * + * Kept to stems long enough to survive substring matching: "no" and "sin" occur inside ordinary words, so the removal phrasing each locale's placeholder actually uses is listed instead. + */ +const REMOVE_WORDS = [ + "不要", "去掉", "删", "取消", "いらない", "削除", "빼", "삭제", "없애", + "drop", "remove", "delete", "without", "get rid", + "weglassen", "entfernen", "loschen", "ohne", + "enleve", "retire", "supprime", "sans", + "quita", "elimina", "tire", "remova", "retira", +] as const; + /** "enjoué" and "enjoue" are the same instruction; so are "preços" and "precos". */ function fold(text: string): string { return text.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase(); } -export function replayEdit(prompt: string, present: string[], theme: string) { +/** The same fields lib/edit.ts reports, declared rather than inferred: a branch that returns through a helper otherwise narrows the union and the callers lose `theme` and `slot`. */ +export type DemoEdit = { + action: string; + confidence: number; + targetConfidence: number; + slot?: string; + theme?: string; + variant?: string; + arbitrary?: boolean; + blockedBy?: string; +}; + +export function replayEdit(prompt: string, present: string[], theme: string): DemoEdit { const folded = fold(prompt); const match = (group: keyof typeof EDIT_WORDS) => EDIT_WORDS[group].some((word) => folded.includes(fold(word))); - const removable = (slot: string) => present.includes(slot); + const onPage = (slot: string) => present.includes(slot); + const wantsRemoval = REMOVE_WORDS.some((word) => folded.includes(fold(word))); - if (match("pricing") && removable("pricing")) { - return { action: "remove", slot: "pricing", confidence: 1, targetConfidence: 1 }; + // When the request names a block this page does not have, say so. Forcing it into the nearest edit the word list can perform would mean answering 「不要定价了」 by adding a pricing section, and the live path does not do that: Jev scores remove_target over the blocks that exist, lands under the floor and reports unclear. A demo that shows an outcome the real system would never produce is the thing this module exists not to do. + const notOnPage = (slot: string): DemoEdit => ({ + action: "unclear", + confidence: 1, + targetConfidence: 0, + blockedBy: `this page has no ${slot} block, so there is nothing to change there`, + }); + const addOrExplain = (slot: string): DemoEdit => + wantsRemoval + ? notOnPage(slot) + : { action: "add", slot, confidence: 1, targetConfidence: 1 }; + + if (match("pricing")) { + return onPage("pricing") + ? { action: "remove", slot: "pricing", confidence: 1, targetConfidence: 1 } + : addOrExplain("pricing"); } if (match("faq")) { - return removable("faq") + return onPage("faq") ? { action: "remove", slot: "faq", confidence: 1, targetConfidence: 1 } - : { action: "add", slot: "faq", confidence: 1, targetConfidence: 1 }; + : addOrExplain("faq"); } if (match("playful")) { return { action: "theme", theme: "coral", confidence: 1, targetConfidence: 1 }; @@ -152,6 +194,7 @@ export function replayEdit(prompt: string, present: string[], theme: string) { return { action: "theme", theme: "terminal", confidence: 1, targetConfidence: 0.9 }; } if (match("nav")) { + if (!onPage("nav")) return notOnPage("nav"); return { action: "restyle", slot: "nav", diff --git a/lib/edit.test.ts b/lib/edit.test.ts new file mode 100644 index 0000000..6727e63 --- /dev/null +++ b/lib/edit.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MAX_REQUEST_CHARS, editPlan } from "./edit"; +import { SLOTS, type SlotKey } from "./plan"; +import { POST as editRoute } from "@/app/api/edit/route"; +import { POST as generateRoute } from "@/app/api/generate/route"; + +/** + * The confidence gate is the whole reason an edit is safe to apply blind: app/page.tsx splices the returned slot straight out of the spec with no second check, so whatever passes this floor is deleted from the user's page. It shipped with no test at all, which means a `<` flipped to `<=`, or a floor tuned down while working on an unrelated branch, would have removed a block the request never named and left all 250 other tests green. + * + * Jev is stubbed rather than called: these are assertions about what code does with an answer, and the answers are the interesting part. + */ +const real = globalThis.fetch; +afterEach(() => { + globalThis.fetch = real; + vi.unstubAllEnvs(); +}); + +const signal = new AbortController().signal; + +type Answer = { choice: string; confidence?: number }; +type Asked = Record<string, { criteria?: Record<string, string> }>; + +/** Replies with the given answers, and returns the questions the fan-out actually sent. */ +function stubJev(answers: Record<string, Answer>): { questions: Asked }[] { + const sent: { questions: Asked }[] = []; + globalThis.fetch = vi.fn(async (_input: unknown, init: { body: string }) => { + sent.push(JSON.parse(init.body) as { questions: Asked }); + return new Response(JSON.stringify({ answers, usage: { input_tokens: 42 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as unknown as typeof fetch; + return sent; +} + +const PAGE: SlotKey[] = ["nav", "hero", "contact", "footer", "gallery", "pricing"]; + +describe("editPlan's confidence gate", () => { + it("refuses a remove whose target Jev was unsure of, so a vague request cannot delete a block the user never named", async () => { + stubJev({ + action: { choice: "remove", confidence: 0.97 }, + remove_target: { choice: "pricing", confidence: 0.3 }, + }); + const plan = await editPlan("k", "tidy this up", PAGE, "forest", signal); + expect(plan.action).toBe("unclear"); + expect(plan.slot).toBeUndefined(); + expect(plan.targetConfidence).toBe(0.3); + expect(plan.blockedBy).toContain("0.30"); + }); + + it("treats the floor itself as confident enough, so the documented 0.50 executes and 0.49 does not", async () => { + stubJev({ + action: { choice: "remove", confidence: 0.9 }, + remove_target: { choice: "pricing", confidence: 0.5 }, + }); + const atFloor = await editPlan("k", "drop the pricing", PAGE, "forest", signal); + expect(atFloor.action).toBe("remove"); + expect(atFloor.slot).toBe("pricing"); + + stubJev({ + action: { choice: "remove", confidence: 0.9 }, + remove_target: { choice: "pricing", confidence: 0.49 }, + }); + const below = await editPlan("k", "drop the pricing", PAGE, "forest", signal); + expect(below.action).toBe("unclear"); + expect(below.slot).toBeUndefined(); + }); + + it("blocks a theme change the same way as a removal, so one gate covers every branch that needs a target", async () => { + stubJev({ + action: { choice: "theme", confidence: 0.88 }, + theme_target: { choice: "coral", confidence: 0.2 }, + }); + const plan = await editPlan("k", "make it nicer", PAGE, "forest", signal); + expect(plan.action).toBe("unclear"); + expect(plan.theme).toBeUndefined(); + }); + + it("executes a restyle whose destination is a coin flip and marks it arbitrary, so 换一个 is answered rather than refused", async () => { + stubJev({ + action: { choice: "restyle", confidence: 0.91 }, + restyle_target: { choice: "nav", confidence: 0.86 }, + variant_nav: { choice: "nav_centered", confidence: 0.34 }, + }); + const plan = await editPlan("k", "换个导航栏样式", PAGE, "forest", signal); + expect(plan.action).toBe("restyle"); + expect(plan.slot).toBe("nav"); + expect(plan.variant).toBe("nav_centered"); + expect(plan.arbitrary).toBe(true); + }); + + it("still refuses a restyle when it cannot tell which block was meant, so the low-confidence exception covers the variant only", async () => { + stubJev({ + action: { choice: "restyle", confidence: 0.9 }, + restyle_target: { choice: "nav", confidence: 0.31 }, + variant_nav: { choice: "nav_centered", confidence: 0.99 }, + }); + const plan = await editPlan("k", "换个样式", PAGE, "forest", signal); + expect(plan.action).toBe("unclear"); + expect(plan.variant).toBeUndefined(); + }); + + it("drops the variant already on screen from the options, so 换一个 cannot come back as the layout the page already has", async () => { + const sent = stubJev({ + action: { choice: "restyle", confidence: 0.9 }, + restyle_target: { choice: "nav", confidence: 0.9 }, + variant_nav: { choice: "nav_minimal", confidence: 0.8 }, + }); + await editPlan("k", "换一个导航", PAGE, "forest", signal, { nav: "nav_centered" }); + const options = Object.keys(sent[0]!.questions.variant_nav!.criteria!); + expect(options).not.toContain("nav_centered"); + expect(options.sort()).toEqual( + Object.keys(SLOTS.nav.variants).filter((id) => id !== "nav_centered").sort(), + ); + }); +}); + +describe("editPlan and the archetype's required blocks", () => { + it("keeps a required block out of the removal options, so the block its archetype exists to guarantee is never offered up", async () => { + const sent = stubJev({ + action: { choice: "remove", confidence: 0.9 }, + remove_target: { choice: "gallery", confidence: 0.9 }, + }); + await editPlan("k", "去掉联系方式", PAGE, "forest", signal, {}, "local"); + const offered = Object.keys(sent[0]!.questions.remove_target!.criteria!); + // local requires nav, hero, contact and footer; gallery and pricing are the only optional blocks on this page. + expect(offered.sort()).toEqual(["gallery", "pricing"]); + }); + + it("says the page has nothing removable rather than reporting a judgement Jev was never asked for, so an empty page is unreachable", async () => { + const sent = stubJev({ action: { choice: "remove", confidence: 0.99 } }); + const plan = await editPlan("k", "把首屏去掉", ["hero"], "forest", signal, {}, "minimal"); + expect(sent[0]!.questions.remove_target).toBeUndefined(); + expect(plan.action).toBe("unclear"); + expect(plan.blockedBy).toContain("必需"); + }); + + it("offers every present block when the caller sends no archetype, so a client that does not know about them is not silently restricted", async () => { + const sent = stubJev({ + action: { choice: "remove", confidence: 0.9 }, + remove_target: { choice: "pricing", confidence: 0.9 }, + }); + await editPlan("k", "drop the pricing", PAGE, "forest", signal); + expect(Object.keys(sent[0]!.questions.remove_target!.criteria!).sort()).toEqual( + [...PAGE].sort(), + ); + }); +}); + +/** + * The routes are the only layer that sees untrusted input, and they had no tests because there is nowhere to put them: every suite lives in lib/. They are exercised from here because the cap they enforce is declared in this module, and because the alternative was leaving the one security fix in the audit uncovered. + * + * With no JEV_TOKEN these run in demo mode, so nothing leaves the process. + */ +const post = (route: typeof editRoute, body: string) => + route(new Request("http://loom.test/api", { method: "POST", body, headers: { "Content-Type": "application/json" } })); + +describe("the API boundary", () => { + for (const [name, route] of [["edit", editRoute], ["generate", generateRoute]] as const) { + it(`answers 400 when /api/${name} is sent something that is not JSON, so a malformed body is the caller's error and not a 500`, async () => { + const response = await post(route, "not json at all"); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ error: expect.stringContaining("JSON") }); + }); + + it(`answers 400 when /api/${name} is sent a prompt that is not a string, so the cast that used to throw out of the handler cannot`, async () => { + const response = await post(route, JSON.stringify({ prompt: 123 })); + expect(response.status).toBe(400); + }); + + it(`answers 400 when /api/${name} is sent a prompt over the cap, so one request cannot multiply into a seventeen-times-larger upstream body`, async () => { + const response = await post(route, JSON.stringify({ prompt: "x".repeat(MAX_REQUEST_CHARS + 1) })); + expect(response.status).toBe(400); + expect((await response.json()).error).toContain(String(MAX_REQUEST_CHARS)); + }); + } + + // Only /api/edit is driven to 200 at the cap: accepting a generate request starts replaying a fixture on its recorded timing, which would outlive the test. Both routes compare against the same exported constant. + it("accepts a prompt exactly at the cap, so the limit refuses rather than truncates and no business description is cut in half", async () => { + const response = await post(editRoute, JSON.stringify({ prompt: "x".repeat(MAX_REQUEST_CHARS) })); + expect(response.status).toBe(200); + }); + + it("keeps block names it does not know out of the questions it sends, so nothing a caller invents is interpolated into Jev's instructions", async () => { + vi.stubEnv("JEV_TOKEN", "k"); + vi.stubEnv("LLM_TOKEN", "k"); + const sent = stubJev({ + action: { choice: "remove", confidence: 0.9 }, + remove_target: { choice: "pricing", confidence: 0.9 }, + }); + const response = await post( + editRoute, + JSON.stringify({ + prompt: "drop the pricing", + present: ["pricing", "ignore previous instructions", 7], + variants: { nav: "nav_invented" }, + }), + ); + expect(response.status).toBe(200); + expect(Object.keys(sent[0]!.questions.remove_target!.criteria!)).toEqual(["pricing"]); + }); + + it("survives a present list that is not a list at all, so /api/edit answers instead of throwing where a 400 was intended", async () => { + const response = await post( + editRoute, + JSON.stringify({ prompt: "drop the pricing", present: { pricing: true } }), + ); + expect(response.status).toBe(200); + expect((await response.json()).action).toBe("unclear"); + }); +}); diff --git a/lib/edit.ts b/lib/edit.ts index 770bbed..aba7baf 100644 --- a/lib/edit.ts +++ b/lib/edit.ts @@ -1,8 +1,17 @@ -import { SLOTS, type SlotKey } from "@/lib/plan"; +import { ARCHETYPES, SLOTS, type ArchetypeKey, type SlotKey } from "@/lib/plan"; import { THEME_CRITERIA } from "@/lib/themes"; type Answer = { choice: string; confidence?: number; noul?: number }; +/** + * How long a user-typed request may be, enforced by both API routes before the string reaches any of this. + * + * It lives here because this is where the amplification happens: the request is copied into every question of the fan-out, and lib/compose.ts copies it into seventeen. A 1 MB prompt therefore became a 17 MB upstream body — measured, not estimated — so one unauthenticated POST could drive a keyed deployment to gigabytes of RSS and about 24x the token spend the caller paid for. + * + * 2000 characters is roughly 22x the longest example prompt any locale ships (91 characters, pt.json) and still several paragraphs of business description, so nothing a person actually types is truncated; over the cap the routes answer 400 rather than silently cutting the request in half and generating a page for a sentence the user did not finish. + */ +export const MAX_REQUEST_CHARS = 2000; + const ACTIONS: Record<string, string> = { remove: "把页面上某个已有的区块整个去掉,比如「不要定价了」「删掉评价」「去掉常见问题」。注意是删掉整块,不是改它。", @@ -44,10 +53,14 @@ export async function editPlan( currentTheme: string, signal: AbortSignal, currentVariants: Partial<Record<SlotKey, string>> = {}, + archetype?: ArchetypeKey, ) { const absent = (Object.keys(SLOTS) as SlotKey[]).filter( (slot) => !present.includes(slot), ); + // A block the archetype declared required is not on the menu. lib/compose.ts seeds the page from ARCHETYPES[archetype].required and only ever asks about the optional ones, so "a landing page always has a hero" is structural at generation time — but the edit path used to offer every present block for removal, which is the one route to an unrecoverable page: drop `hero` from a `minimal` page and the spec root is left with no children, every export writes an empty document, and add is not implemented client-side. Callers that send no archetype keep the old behaviour, because there is nothing to derive the rule from. + const structural: readonly string[] = archetype ? ARCHETYPES[archetype].required : []; + const removable = present.filter((slot) => !structural.includes(slot)); const questions: Record<string, unknown> = { action: { @@ -57,7 +70,7 @@ export async function editPlan( }, }; - if (present.length > 0) { + if (removable.length > 0) { questions.remove_target = { type: "choice", instructions: { @@ -65,7 +78,7 @@ export async function editPlan( request, note: "只在用户确实要删东西时才会用到这个答案", }, - criteria: Object.fromEntries(present.map((slot) => [slot, SLOT_LABELS[slot]])), + criteria: Object.fromEntries(removable.map((slot) => [slot, SLOT_LABELS[slot]])), }; } if (absent.length > 0) { @@ -187,8 +200,16 @@ export async function editPlan( }); let target: { choice?: string; confidence: number | null } | null = null; - if (action === "remove") target = targetOf("remove_target"); - else if (action === "add") target = targetOf("add_target"); + if (action === "remove") { + // No question went out because everything on the page is structural, so say that instead of letting the missing answer read as a low-confidence judgement Jev never made. + if (removable.length === 0) { + result.action = "unclear"; + result.targetConfidence = null; + result.blockedBy = "页面上剩下的区块都是这类页面的必需项,删不掉"; + return result; + } + target = targetOf("remove_target"); + } else if (action === "add") target = targetOf("add_target"); else if (action === "theme") target = targetOf("theme_target"); else if (action === "restyle") { const slotPick = targetOf("restyle_target"); diff --git a/lib/erasable.test.ts b/lib/erasable.test.ts new file mode 100644 index 0000000..559c289 --- /dev/null +++ b/lib/erasable.test.ts @@ -0,0 +1,38 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { stripTypeScriptTypes } from "node:module"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * scripts/mcp.mjs and scripts/sources.mjs import lib/*.ts straight into Node and let Node strip the types — no build step, no dependency, which is the whole reason registering loom as an MCP server is one line. That only holds while every module in lib/ is erasable-syntax only, and until this file the constraint was stated in a prose comment in scripts/sources.mjs and enforced by nothing. + * + * Nothing else in the pipeline objects. `npx tsc --noEmit` accepts `export enum` and constructor parameter properties — isolatedModules rejects only `const enum` and `export =` — `next build` bundles them, and vitest transforms with esbuild, which compiles enums happily. So one enum in lib/plan.ts leaves 250 tests, the type check and the production build all green and kills `npm run mcp` at import with ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX: an MCP server that exits during the handshake, found by whichever agent registered loom rather than by CI. + * + * The check runs the stripper Node itself runs rather than grepping for `enum`, so it is also right about the cases nobody thinks to grep for: namespaces, parameter properties, `declare` in the wrong place. It covers all of lib/ and not only the three modules lib/mcp.ts imports today, because an import added later would otherwise walk out from under the guard silently. + */ +const LIB = import.meta.dirname; + +describe("lib/ under Node's own type stripping", () => { + const modules = readdirSync(LIB) + .filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts")) + .sort(); + + it("stays erasable-syntax only, so `npm run mcp` and `npm run sources` still boot after a refactor", () => { + expect(modules.length).toBeGreaterThan(0); + for (const file of modules) { + const source = readFileSync(join(LIB, file), "utf8"); + expect( + () => stripTypeScriptTypes(source, { mode: "strip" }), + `lib/${file} contains syntax Node cannot erase`, + ).not.toThrow(); + } + }); + + it("is checked by a stripper that still rejects the syntax in question, so a green run above means something", () => { + // If a future Node learned to transform enums, the assertion above would + // keep passing while guarding nothing. This is the canary for that. + expect(() => stripTypeScriptTypes("export enum Script { Han, Kana }", { mode: "strip" })).toThrow( + /ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX|not supported in strip-only mode/, + ); + }); +}); diff --git a/lib/export-agents.test.ts b/lib/export-agents.test.ts index 1e1d0df..f692881 100644 --- a/lib/export-agents.test.ts +++ b/lib/export-agents.test.ts @@ -7,17 +7,25 @@ import { buildAgentsMd } from "./export-agents"; /** * The brief is only worth its lines if every line is load-bearing, so these check the four facts an agent acts on: which file it may edit, which variables it must write against, which blocks it has, and which sources are real. A wrong value here is worse than no file at all — it sends the agent off confidently in the wrong direction. */ -function page(): Element { +/** + * The two-level root the app actually exports. + * + * The first fixture here was a single themed `<div class="antialiased">`, which is a DOM shape the app never renders: every exporter is handed `previewRef.current?.firstElementChild`, the unstyled `<div data-loom="site">` wrapper, and `themeVars(...)` lands one level further down on the registry's Page div. A fixture that collapses the two makes the brief's claim about where the tokens live true by accident, which is how it stayed wrong. The style is set through the DOM rather than written into the markup because the token values are lib/themes.ts's to own, and because the font stacks contain double quotes that an inline `style="…"` attribute would swallow. + */ +function page(theme = "forest"): Element { const host = document.createElement("div"); - host.innerHTML = `<div class="antialiased"><section>hero</section></div>`; - return host.firstElementChild!; + host.innerHTML = `<div data-loom="site"><div class="antialiased"><section>hero</section></div></div>`; + const root = host.firstElementChild!; + const themed = root.firstElementChild as HTMLElement; + for (const [name, value] of Object.entries(themeVars(theme))) themed.style.setProperty(name, value); + return root; } /** `team` is in here deliberately: it is the one slot no source in the table covers. */ const SLOTS = ["nav", "hero", "features", "team", "faq", "footer"]; const build = (theme = "forest", slots = SLOTS) => - buildAgentsMd(page(), "我开了家咖啡店", theme, slots); + buildAgentsMd(page(theme), "我开了家咖啡店", theme, slots); /** Table rows only, with the header and the separator dropped. */ function rows(markdown: string): string[][] { @@ -71,6 +79,37 @@ describe("buildAgentsMd", () => { for (const [name, value] of Object.entries(vars)) { expect(markdown, `${name} is missing or wrong`).toContain(`${name}: ${value};`); } + // The prose counts them out loud, so a token added to lib/themes.ts without the sentence following it would have the brief telling the agent to look for one fewer than it prints. + expect(markdown).toContain(`These ${Object.keys(vars).length} are the`); + }); + + it("names the element that declares the tokens rather than the export root, so a block pasted where the brief points inherits the theme instead of nothing", () => { + // The brief used to call the export root the themed element. It is one level up from the truth, and an agent that believes it appends its section as a sibling of the themed div, outside the scope every `var(--accent)` resolves in. + const root = page(); + const themed = root.firstElementChild as HTMLElement; + expect(themed.style.getPropertyValue("--bg"), "fixture stopped modelling the app's two-level root").not.toBe(""); + expect((root as HTMLElement).style.getPropertyValue("--bg"), "fixture root declares the tokens, so this test would pass on the old bug").toBe(""); + + const line = buildAgentsMd(root, "我开了家咖啡店", "forest", SLOTS) + .split("\n") + .find((l) => l.includes("set as an inline style on"))!; + const target = line.match(/set as an inline style on `(<[^`]+>)`/)?.[1]; + expect(target).toBe(`<${themed.tagName.toLowerCase()} class="${themed.getAttribute("class")}">`); + expect(line).toContain("one level inside the outermost"); + }); + + it("tells a declaration apart from a var() reference, so an element that merely reads a token cannot be mistaken for the one that sets it", () => { + // Almost every block in app/registry.tsx carries an inline `color: var(--muted)` or similar. Picking the first element with a style attribute would work only for as long as the themed div happens to come first in document order, and would send the brief — and the agent — at whichever block moved ahead of it. + const host = document.createElement("div"); + host.innerHTML = `<div data-loom="site"><span style="color: var(--bg)">reads it</span><div class="antialiased"><section>hero</section></div></div>`; + const root = host.firstElementChild!; + const themed = root.lastElementChild as HTMLElement; + for (const [name, value] of Object.entries(themeVars("forest"))) themed.style.setProperty(name, value); + + const line = buildAgentsMd(root, "我开了家咖啡店", "forest", SLOTS) + .split("\n") + .find((l) => l.includes("set as an inline style on"))!; + expect(line.match(/set as an inline style on `(<[^`]+>)`/)?.[1]).toBe(`<div class="antialiased">`); }); it("follows the theme it was given, so a retheme cannot leave the brief quoting the old palette", () => { diff --git a/lib/export-agents.ts b/lib/export-agents.ts index 98f8f27..3688018 100644 --- a/lib/export-agents.ts +++ b/lib/export-agents.ts @@ -67,6 +67,18 @@ function sourceTable(shown: Shown[]): string { .join("\n"); } +/** + * The element the theme tokens are actually declared on. + * + * Every exporter is handed `previewRef.current?.firstElementChild`, which is the unstyled `<div data-loom="site">` wrapper; `themeVars(...)` is applied one level further down, on the registry's Page div. The brief used to call the export root the themed element, which is off by one — an agent that believes it appends its new section as a sibling of the themed div, where every `var(--accent)` resolves to nothing and the block renders unthemed, which is the exact two-palettes-on-one-page failure this section exists to prevent. So the element is found by the declaration it carries rather than assumed, reading the property off the style object so a `var(--bg)` *reference* elsewhere cannot be mistaken for the declaration. If the root does carry them, the root is the answer and the prose says so. + */ +function themedElement(root: Element, marker: string): Element { + const declares = (element: Element) => + (element as HTMLElement).style?.getPropertyValue(marker) !== ""; + if (declares(root)) return root; + return Array.from(root.querySelectorAll("[style]")).find(declares) ?? root; +} + function tokenBlock(theme: string): string { const declarations = Object.entries(themeVars(theme)).map(([name, value]) => `${name}: ${value};`); return ["```css", ...declarations, "```"].join("\n"); @@ -91,7 +103,17 @@ export function buildAgentsMd(root: Element, prompt: string, theme: string, slot const uncovered = present.filter((slot) => sourcesFor(slot as SlotKey).length === 0); // themeVars falls back to forest for a name it does not know, so the prose names the theme actually printed. The HTML file is named after the string the export was given, whatever it was. const printed = theme in THEMES ? theme : "forest"; - const wrapper = root.tagName.toLowerCase(); + const tokens = themeVars(printed); + // The marker comes off the token map rather than being spelled out again, so a rename in lib/themes.ts cannot leave this looking for a property nothing declares. + const themed = themedElement(root, Object.keys(tokens)[0]!); + const named = (element: Element) => { + const className = element.getAttribute("class"); + return code(`<${element.tagName.toLowerCase()}${className ? ` class="${className}"` : ""}>`); + }; + const themedPlace = + themed === root + ? `the outermost ${named(root)}` + : `${named(themed)}, which sits one level inside the outermost ${named(root)} rather than being it`; const licences = shown .filter(({ source }) => source.name !== "Transitions") @@ -111,7 +133,7 @@ export function buildAgentsMd(root: Element, prompt: string, theme: string, slot ].join("\n"), `## The theme contract`, - `Nothing in the markup names a colour, a radius or a typeface directly — every one of them reads a CSS custom property. These seventeen are the ${code(printed)} theme, set as an inline style on the outermost ${code(`<${wrapper}>`)}, and every block on the page sits inside that element.`, + `Nothing in the markup names a colour, a radius or a typeface directly — every one of them reads a CSS custom property. These ${Object.keys(tokens).length} are the ${code(printed)} theme, set as an inline style on ${themedPlace}, and every block on the page sits inside that element. Custom properties inherit from the element that declares them, so a block added as its sibling rather than inside it gets none of them.`, tokenBlock(printed), `A component pasted in from anywhere else arrives with a palette of its own: hex literals, or another design system's token names such as ${code("text-foreground")} that resolve to nothing here. It will ignore the theme until it is rewritten onto the variables above. That rewrite is the step this workflow skips most often, and skipping it puts two colour schemes on one page.`, diff --git a/lib/export-tsx.test.ts b/lib/export-tsx.test.ts index a9158d6..759ac85 100644 --- a/lib/export-tsx.test.ts +++ b/lib/export-tsx.test.ts @@ -66,6 +66,34 @@ describe("buildReactSource", () => { expect(source).toContain('{"a {b} c"}'); }); + it("keeps a space that a text node carries at its edge, so the testimonial byline does not export as one run-on word", () => { + // app/registry.tsx renders `<span style={{ color: "var(--muted)" }}> · {item.role}</span>`; the leading space is the only separator between the name and the role. Trimming each text node on its own deleted it, and because JSX also discards whitespace next to a newline the two spans rendered as `Ada Lovelace· Founder` — correct in the HTML export of the same page, wrong here. + const source = buildReactSource( + element(`<p><span class="font-medium">Ada Lovelace</span><span> · Founder</span></p>`), + "t", + ); + expect(source).toContain('{" · Founder"}'); + }); + + it("still drops a text node that is only formatting, so blank lines in the markup do not become JSX children", () => { + const source = buildReactSource(element(`<div>\n <span>a</span>\n</div>`), "t"); + expect(source).not.toMatch(/\{" +"\}/); + expect(source).toContain("<span>"); + }); + + it("puts an attribute value carrying a quote in braces, because a JSX string literal processes no escapes and the file would not parse", () => { + // JSX takes an attribute string literal verbatim: `title="He said \"hi\""` ends the string at the backslash-quote and the rest is a syntax error. Nothing app/registry.tsx renders today reaches this — it emits only class, style and key — so the first block to carry an `alt` or `aria-label` from LLM copy would have been the one to find out. + const source = buildReactSource(element(`<div title='He said "hi"'></div>`), "t"); + expect(source).toContain('title={"He said \\"hi\\""}'); + expect(source).not.toContain('title="He said'); + }); + + it("leaves a quote-free attribute as a plain string, so the common output does not fill up with braces", () => { + const source = buildReactSource(element(`<div data-loom="site"><label for="x">Hi</label></div>`), "t"); + expect(source).toContain('data-loom="site"'); + expect(source).toContain('htmlFor="x"'); + }); + it("produces a default-exported component and records the request", () => { const source = buildReactSource(element(`<div>hi</div>`), "我开了家咖啡店"); expect(source).toContain("export default function Site()"); diff --git a/lib/export-tsx.ts b/lib/export-tsx.ts index c74c094..4af5c04 100644 --- a/lib/export-tsx.ts +++ b/lib/export-tsx.ts @@ -8,6 +8,8 @@ * What comes out is one flat component. That is deliberate: a generated file * you can read top to bottom and cut apart yourself beats a clever hierarchy * you have to reverse-engineer first. + * + * The bar this file is held to is that the two exports of one page agree. It has missed it twice. Generated quotation marks lived only in CSS and disappeared from the component while the HTML export kept them, and a per-text-node `.trim()` ate the space in `<span> · {role}</span>` so the byline exported as `Ada Lovelace· Founder` — correct in site.html, wrong in Site.tsx, and nothing compares the two. Both are pinned below. */ const VOID_TAGS = new Set([ @@ -74,6 +76,16 @@ function styleObject(style: string): string { return hasCustomProperty ? `{${body} as CSSProperties}` : `{${body}}`; } +/** + * An attribute value as JSX will actually read it. + * + * A JSX attribute string literal is taken verbatim — backslash escapes are not processed and an embedded `"` ends the string — so `JSON.stringify` alone emits `title="He said \"hi\""`, which does not parse. The brace form is a real JS expression where the escapes mean what they say. Bare strings are kept when they are safe so the common output stays readable. Nothing app/registry.tsx renders today reaches this (it emits only `class`, `style` and `key`), which is exactly why it went unnoticed; the first block to carry an `alt` or `aria-label` from LLM copy would have shipped a component that does not compile. + */ +function attributeValue(value: string): string { + const literal = JSON.stringify(value); + return /["\\\n]/.test(value) ? `{${literal}}` : literal; +} + function escapeText(text: string): string { // Braces are JSX syntax; everything else can sit in the tree as-is. return text.includes("{") || text.includes("}") @@ -111,8 +123,12 @@ function serialize(node: Node, depth: number): string { const pad = " ".repeat(depth); if (node.nodeType === Node.TEXT_NODE) { - const text = (node.textContent ?? "").replace(/\s+/g, " ").trim(); - return text ? `${pad}${escapeText(text)}` : ""; + const collapsed = (node.textContent ?? "").replace(/\s+/g, " "); + const text = collapsed.trim(); + if (!text) return ""; + // A leading or trailing space that survived the collapse is content, not formatting: this DOM was rendered by React, which already dropped JSX's own indentation, so the only way a space reaches an edge is because someone wrote it. Trimming it deleted the separator in the testimonial byline — `<span> · {role}</span>` — and the exported component read `Ada Lovelace· Founder` while the HTML export of the same page read `Ada Lovelace · Founder`. Bare JSX text cannot carry it back, since JSX discards whitespace adjacent to a newline, so the brace form does it instead. + if (collapsed !== text) return `${pad}{${JSON.stringify(collapsed)}}`; + return `${pad}${escapeText(text)}`; } if (node.nodeType !== Node.ELEMENT_NODE) return ""; @@ -138,11 +154,11 @@ function serialize(node: Node, depth: number): string { .replace(/\b(?:blk-in|quoted)\b/g, "") .replace(/\s+/g, " ") .trim(); - if (cleaned) attributes.push(`className=${JSON.stringify(cleaned)}`); + if (cleaned) attributes.push(`className=${attributeValue(cleaned)}`); continue; } const name = PROP_NAMES[attr.name] ?? attr.name; - attributes.push(`${name}=${JSON.stringify(attr.value)}`); + attributes.push(`${name}=${attributeValue(attr.value)}`); } const attrText = @@ -189,9 +205,11 @@ export function buildReactSource(root: Element, description: string): string { // Source request: ${description.replace(/\n/g, " ")} // // One flat component, no dependencies beyond React. Colours, fonts and radii -// are CSS custom properties set on the outermost element, so retheming means -// editing that one style object. Tailwind utility classes are preserved; drop -// this into a project that has Tailwind, or replace the classes with your own. +// are CSS custom properties, declared as an inline style on the one element +// below whose style object sets --bg; retheming means editing that object, and +// a block added outside that element inherits none of them. Tailwind utility +// classes are preserved; drop this into a project that has Tailwind, or +// replace the classes with your own. import type { CSSProperties } from "react"; diff --git a/lib/export.test.ts b/lib/export.test.ts new file mode 100644 index 0000000..7dbcf64 --- /dev/null +++ b/lib/export.test.ts @@ -0,0 +1,132 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { buildStandaloneHtml, usedCssFor } from "./export"; + +/** + * The CSS filter fails silently or not at all. + * + * A rule it wrongly drops costs nothing at export time; it surfaces as an exported page that renders differently from the one it was taken from, with no error in any console, and the only way to notice is to compare two renderings by eye. That is exactly what happened to `:first-child` — the strip list held `first`, which is not a pseudo-class name, so the selector was rewritten to a *valid* one that matched nothing, the catch below never fired, and the rule vanished. Everything here is a case where being wrong is quiet. + * + * The other half is the filter's deliberate cowardice: a selector it cannot parse is KEPT, because a slightly larger file beats a silently broken one. Nothing asserted that before, so a future rewrite could have turned the fallback into a drop and nothing would have said so. + */ +function mount(css: string, html: string): Element { + document.head.innerHTML = `<style>${css}</style>`; + document.body.innerHTML = html; + return document.body.firstElementChild!; +} + +afterEach(() => { + document.head.innerHTML = ""; + document.body.innerHTML = ""; +}); + +describe("usedCssFor", () => { + it("keeps the rules the subtree uses and drops the rest, which is the whole reason the export is not the entire Tailwind build", () => { + const root = mount(`.card { color: red } .nowhere { color: blue } li { color: green }`, `<div class="card"><ul><li>a</li></ul></div>`); + const css = usedCssFor(root); + expect(css).toContain(".card"); + expect(css).toContain("li"); + expect(css).not.toContain(".nowhere"); + }); + + it("matches a selector against the exported root itself, not only its descendants, so the outermost element keeps its own styling", () => { + const root = mount(`.card { color: red }`, `<div class="card"><span>a</span></div>`); + expect(usedCssFor(root)).toContain(".card"); + }); + + it("keeps the state and pseudo-element forms of a selector whose base element is present, since neither can be matched against a static tree", () => { + const root = mount(`a:hover { color: red } .card::before { content: "x" } a:not(.x) { color: blue }`, `<div class="card"><a href="#">go</a></div>`); + const css = usedCssFor(root); + expect(css).toContain("a:hover"); + expect(css).toContain(".card::before"); + expect(css).toContain("a:not(.x)"); + }); + + it("keeps a structural pseudo-class whose name merely starts with another one, because the old list rewrote li:first-child into the valid-but-wrong li-child and lost the rule", () => { + // `first`, `last`, `odd` and `even` are not pseudo-class names. Stripping them left a tail that still parsed, so querySelector returned null instead of throwing and the safety net below never ran. Every one of these is a stock Tailwind variant: `first:pt-0`, `last:border-0`, `focus-visible:ring-2`. + const root = mount( + `li:first-child { color: red } li:last-child { color: blue } p:first-of-type { color: green } input:focus-visible { outline: 1px solid } input:focus-within { outline: 2px solid }`, + `<div><ul><li>a</li></ul><p>b</p><input></div>`, + ); + const css = usedCssFor(root); + for (const selector of ["li:first-child", "li:last-child", "p:first-of-type", "input:focus-visible", "input:focus-within"]) { + expect(css, `${selector} was dropped from the export`).toContain(selector); + } + }); + + it("will not strip a pseudo-class nobody enumerated just because a shorter name is a prefix of it, so the next one CSS ships cannot repeat the :first-child failure", () => { + // `:has-slotted` is real (CSS Scoping) and is not in the strip list. Without the lookahead the list's `has` matches its first three characters and leaves `div-slotted`, which parses, matches nothing, and drops the rule with no error — the same shape as the bug this file exists for, one pseudo-class further along. Kept whole, it is unparseable here and the filter's own fallback keeps it. + const root = mount(`div:has-slotted { color: red }`, `<div><span>a</span></div>`); + expect(usedCssFor(root)).toContain("has-slotted"); + }); + + it("drops a structural pseudo-class whose base element is absent, so the prefix fix did not turn the filter into a pass-through", () => { + const root = mount(`table:first-child { color: red }`, `<div><span>a</span></div>`); + expect(usedCssFor(root)).not.toContain("table"); + }); + + it("keeps a Tailwind variant rule, whose selector contains an escaped colon that any structural pseudo-class strip would eat", () => { + // `.sm\:grid-cols-3` -> `.sm\3` under a regex that strips any `:ident`, and `\3` is a valid CSS hex escape, so querySelector accepts it, matches nothing, and every responsive rule disappears. This is the trap the fix deliberately did not walk into. + const root = mount(`.sm\\:grid-cols-3 { display: grid } .hover\\:bg-soft:hover { color: red }`, `<div class="sm:grid-cols-3 hover:bg-soft">a</div>`); + const css = usedCssFor(root); + expect(css).toContain("grid-cols-3"); + expect(css).toContain("bg-soft"); + }); + + it("keeps a rule whose selector it cannot parse rather than dropping it, because a larger file beats a page missing a style nobody will trace", () => { + const root = mount(`ul:totally-made-up { color: red }`, `<ul><li>a</li></ul>`); + expect(usedCssFor(root)).toContain("totally-made-up"); + }); + + it("keeps the document-level rules that no element can be tested against, which is where the theme variables live", () => { + const root = mount(`:root { --accent: green } body { margin: 0 } * { box-sizing: border-box }`, `<div>a</div>`); + const css = usedCssFor(root); + expect(css).toContain(":root"); + expect(css).toContain("body"); + expect(css).toContain("box-sizing"); + }); + + it("rebuilds a media query around the rules that survived it, so a responsive rule arrives still wrapped in its own condition", () => { + const root = mount(`@media (min-width: 40rem) { li { color: teal } .nowhere { color: blue } }`, `<ul><li>a</li></ul>`); + const css = usedCssFor(root); + expect(css).toContain("@media (min-width: 40rem)"); + expect(css).toContain("color: teal"); + expect(css).not.toContain(".nowhere"); + expect(css.indexOf("@media")).toBeLessThan(css.indexOf("color: teal")); + }); + + it("emits no empty media block when nothing inside it matched, since an empty prelude is bytes with no effect", () => { + const root = mount(`@media (min-width: 40rem) { .nowhere { color: blue } }`, `<ul><li>a</li></ul>`); + expect(usedCssFor(root)).not.toContain("@media"); + }); + + it("carries a font face through whole, because it belongs to no selector and dropping it changes every typeface on the page", () => { + const root = mount(`@font-face { font-family: "X"; src: url(x.woff2) }`, `<div>a</div>`); + expect(usedCssFor(root)).toContain("@font-face"); + }); +}); + +describe("buildStandaloneHtml", () => { + it("escapes the title rather than deleting from it, so a business name keeps its ampersand instead of exporting a double space", () => { + // The first version stripped `[<>&]`, which turned "Ben & Jerry's" into "Ben Jerry's" in the browser tab. Both callers pass the user's own prompt through unfiltered. + const root = mount("", `<div>a</div>`); + const title = buildStandaloneHtml(root, "Ben & Jerry's Coffee").match(/<title>(.*)<\/title>/)![1]; + expect(title).toBe("Ben & Jerry's Coffee"); + }); + + it("encodes angle brackets in the title instead of removing the words between them, which keeps markup out while keeping the name intact", () => { + const root = mount("", `<div>a</div>`); + const title = buildStandaloneHtml(root, "<Ice> Cream").match(/<title>(.*)<\/title>/)![1]; + expect(title).toBe("<Ice> Cream"); + expect(title).not.toContain("<Ice>"); + }); + + it("inlines only the matched CSS into the head and the subtree into the body, so the file opens with no network at all", () => { + const root = mount(`.card { color: red } .nowhere { color: blue }`, `<div class="card">hi</div>`); + const html = buildStandaloneHtml(root, "t", "zh"); + expect(html).toContain(`<html lang="zh-CN">`); + expect(html).toContain(".card"); + expect(html).not.toContain(".nowhere"); + expect(html).toContain(`<div class="card">hi</div>`); + expect(html).not.toContain("<link"); + }); +}); diff --git a/lib/export.ts b/lib/export.ts index c3ed6f7..83543c0 100644 --- a/lib/export.ts +++ b/lib/export.ts @@ -6,6 +6,8 @@ * this walks the live stylesheets and keeps only the rules whose selectors * actually match something inside the exported subtree — no second renderer, so * the block layouts never exist in two places. + * + * Every failure mode here is silent by construction: a rule wrongly dropped costs nothing at export time and shows up later as an exported page that renders differently from the one it was taken from, with no error anywhere. Two of them shipped. The pseudo-class strip list held `first|last|odd|even`, which are not pseudo-class names, so `:first-child` was rewritten to a different valid selector and the rule vanished; and the `<title>` was sanitised by deletion, so `Ben & Jerry's` exported as `Ben Jerry's`. Both are fixed below and both are pinned in lib/export.test.ts, which is the real repair — the filter is a place where a test is worth more than the fix. */ /** Selectors that style the document itself rather than any element we can test. */ @@ -17,11 +19,13 @@ function selectorMatches(root: Element, selectorText: string): boolean { if (!selector) continue; if (ALWAYS_KEEP.test(selector)) return true; - // Pseudo-classes and elements cannot be matched against a static tree, so - // the rule is tested against its own base selector instead. + // Pseudo-classes and elements cannot be matched against a static tree, so the rule is tested against its own base selector instead. Two things the first version got wrong, both of which produced a *valid* replacement selector so the catch below never fired and the rule was silently dropped from the export: the list held `first|last|odd|even`, which are not pseudo-class names, so `li:first-child` became `li-child` and `p:first-of-type` became `p-of-type`; and `focus` sat ahead of `focus-visible`, so `input:focus-visible` became `input-visible`. Hence real names only, longest-first, and a `(?![a-z-])` guard so no alternative can ever match a prefix of a longer name again. The enumeration stays an enumeration on purpose — stripping any `:ident` structurally also eats the escaped colon in Tailwind's own class names (`.sm\:grid-cols-3` -> `.sm\3`), which is the same silent-drop failure one level worse. const base = selector .replace(/::[a-z-]+(\([^)]*\))?/g, "") - .replace(/:(hover|focus|focus-visible|focus-within|active|visited|checked|disabled|first|last|odd|even|not|is|where|has)(\([^)]*\))?/g, "") + .replace( + /:(hover|focus-visible|focus-within|focus|active|visited|checked|disabled|first-child|last-child|first-of-type|last-of-type|only-child|only-of-type|not|is|where|has)(?![a-z-])(\([^)]*\))?/g, + "", + ) .trim(); if (!base) return true; @@ -97,6 +101,11 @@ function htmlLang(language: string): string { return /^[a-z]{2}(-[A-Za-z]+)?$/.test(language) ? language : "en"; } +/** `<title>` is RCDATA, so escaping is enough and deleting is lossy: the first version stripped `[<>&]` outright and turned "Ben & Jerry's" into "Ben Jerry's", double space included, for every business name with an ampersand. Encoding `<` keeps injection impossible while the name survives. */ +function escapeTitle(title: string): string { + return title.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); +} + export function buildStandaloneHtml(root: Element, title: string, language = "en"): string { const css = usedCssFor(root); return `<!doctype html> @@ -104,7 +113,7 @@ export function buildStandaloneHtml(root: Element, title: string, language = "en <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<title>${title.replace(/[<>&]/g, "")} +${escapeTitle(title)} diff --git a/lib/llms.test.ts b/lib/llms.test.ts index 457e8fd..f26cdc2 100644 --- a/lib/llms.test.ts +++ b/lib/llms.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { ARCHETYPES, SLOTS, SLOT_ORDER } from "@/lib/plan"; import { COMPONENT_SOURCES } from "@/lib/sources"; import { THEMES, themeVars } from "@/lib/themes"; +import { TOOLS } from "@/lib/mcp"; import { buildLlmsTxt, EXPORTS } from "./llms"; /** @@ -110,6 +111,17 @@ describe("buildLlmsTxt", () => { } }); + it("names loom's own MCP server and every tool on it, or the one document an agent fetches unattended hides the typed surface and the agent keeps re-parsing this prose for facts a tool would hand it", () => { + // The first version of this file named beUI's MCP endpoint and not loom's, in the same commit that shipped lib/mcp.ts. + const prose = lines.slice(1, firstH2).join("\n"); + expect(prose, "the MCP server is not named in the prose block").toContain("npm run mcp"); + for (const tool of TOOLS) { + expect(prose, `MCP tool ${tool.name} is missing`).toContain(`\`${tool.name}\``); + } + // It has no url, so it can only live in the prose block: the sections below take link items only. + expect(sectionLines.join("\n")).not.toContain("npm run mcp"); + }); + it("repeats the finding that no source ships a page section, the one claim in lib/sources.ts that an agent acting on this file must not be left to contradict", () => { expect(doc).toMatch(/no hero, no testimonial block, no team grid and no footer/); }); diff --git a/lib/llms.ts b/lib/llms.ts index c541bbd..7ccd610 100644 --- a/lib/llms.ts +++ b/lib/llms.ts @@ -1,3 +1,5 @@ +import { MAX_REQUEST_CHARS } from "./edit"; +import { TOOLS } from "./mcp"; import { ARCHETYPES, SLOTS, SLOT_ORDER, type SlotKey } from "./plan"; import { COMPONENT_SOURCES } from "./sources"; import { THEMES, themeVars } from "./themes"; @@ -9,7 +11,9 @@ import { THEMES, themeVars } from "./themes"; * * Every name and count below is computed from the modules the app runs on. A block list typed out by hand keeps reading as authoritative for months after it stopped being true, and this document is aimed squarely at readers who cannot tell — adding a slot, a theme or a source is meant to change the output with no edit here, and lib/llms.test.ts iterates the real tables so that a stale copy fails rather than ships. * - * The structure follows llmstxt.org literally: an H1, a blockquote, prose that may contain no heading at all, then H2 sections whose every line is a `- [title](url): details` link item. That last rule is why the blocks, themes, tokens, exports and sources are prose rather than sections of their own — none of them has a URL, and inventing `/docs/blocks` to satisfy the shape would send an agent to a 404. The only links here are paths this origin actually answers; other people's endpoints appear as code spans, so nothing an agent might follow points off the deployment it fetched this from. + * The structure follows llmstxt.org literally: an H1, a blockquote, prose that may contain no heading at all, then H2 sections whose every line is a `- [title](url): details` link item. That last rule is why the blocks, themes, tokens, exports, sources and the MCP server are prose rather than sections of their own — none of them has a URL, and inventing `/docs/blocks` to satisfy the shape would send an agent to a 404. The only links here are paths this origin actually answers; other people's endpoints appear as code spans, so nothing an agent might follow points off the deployment it fetched this from. + * + * The MCP server was missing from this document for its whole first life, which made the document argue against itself: lib/llms.ts and lib/mcp.ts shipped in the same commit, and the only string containing "mcp" in the generated output was beUI's endpoint — loom advertised somebody else's MCP server and not its own. llms.txt exists so an agent can find what is reachable unattended, and a stdio server started by one command is exactly that. It has no URL, so it is a code span in the prose block rather than a link item, and the tool names are read out of `TOOLS` so the list cannot go stale the way the export list did. * * The export filenames are the one list stated rather than derived. They live as `download` attributes inside a React component, and reaching into the browser layer from a pure builder to recover them would trade a real duplicate for a worse coupling. It stayed stale for exactly one commit: `bundle.zip` shipped and this list still said five, which is the failure the rest of the file is built to avoid. So the test reads the `download` attributes straight out of app/page.tsx and fails when the two disagree — the list is written twice and checked once, rather than trusted. */ @@ -125,13 +129,15 @@ export function buildLlmsTxt(origin: string): string { `Component sources loom catalogues, for an agent sent to find something better than what loom drew. There are ${Object.keys(COMPONENT_SOURCES).length} of them and not one ships a marketing page section — no hero, no testimonial block, no team grid and no footer anywhere in the set — so each improves a block loom already renders and never replaces one:`, ...sourceLines(), "", + `Everything stated above is also served over MCP, for an agent that can spawn a process but not fetch a URL. ${code("npm run mcp")} runs a JSON-RPC server on stdio — register it with ${code("claude mcp add loom -- npm run --silent mcp")} — and it exposes ${TOOLS.length} tools: ${list(TOOLS.map((tool) => tool.name))}. They read the same modules this file is generated from, so the two surfaces cannot disagree, and a tool reply is typed rather than prose you have to parse back out of this document. Neither surface generates a page; the two endpoints below are the only thing that does.`, + "", "## Generating a page", "", - `- [POST /api/generate](${base}/api/generate): Body ${code('{"prompt": "...", "locale": "en"}')}. Responds ${code("application/x-ndjson")} — one JSON object per line, each tagged with a ${code("type")}: ${code("choice")} for a judgement Jev made and the confidence it made it at, ${code("plan")} for the blocks chosen, ${code("partial")} each time copy lands in another block, ${code("complete")} last. A failure arrives as an ${code("error")} event inside the stream rather than as a non-200; only a missing prompt is a 400. Allow up to 120 seconds. With no keys configured the server replays a recorded session instead of refusing, and the ${code("X-Loom-Mode")} response header reads ${code("demo")} or ${code("live")} so a caller can tell which it got.`, + `- [POST /api/generate](${base}/api/generate): Body ${code('{"prompt": "...", "locale": "en"}')}. Responds ${code("application/x-ndjson")} — one JSON object per line, each tagged with a ${code("type")}: ${code("choice")} for a judgement Jev made and the confidence it made it at, ${code("plan")} for the blocks chosen, ${code("partial")} each time copy lands in another block, ${code("complete")} last. A failure arrives as an ${code("error")} event inside the stream rather than as a non-200, but the body is validated before the stream opens: a non-JSON body, a missing or non-string prompt, a prompt over ${MAX_REQUEST_CHARS} characters, or a ${code("locale")} that is not a BCP-47 tag are each a 400. Allow up to 120 seconds. With no keys configured the server replays a recorded session instead of refusing, and the ${code("X-Loom-Mode")} response header reads ${code("demo")} or ${code("live")} so a caller can tell which it got.`, "", "## Editing a page that already exists", "", - `- [POST /api/edit](${base}/api/edit): Body ${code('{"prompt": "...", "present": ["nav", "hero"], "theme": "forest", "variants": {"hero": "hero_split"}}')}, where ${code("present")} is the page's current blocks and ${code("variants")} its current variant ids. Returns one JSON object: the judged ${code("action")} with its ${code("confidence")}, whichever of ${code("slot")} / ${code("theme")} / ${code("variant")} that action needs, ${code("blockedBy")} when the request named no target confidently enough to act on, and ${code("speculative")} holding every branch Jev scored. One round trip, and no copy is regenerated.`, + `- [POST /api/edit](${base}/api/edit): Body ${code('{"prompt": "...", "present": ["nav", "hero"], "theme": "forest", "variants": {"hero": "hero_split"}, "archetype": "landing"}')}, where ${code("present")} is the page's current blocks, ${code("variants")} its current variant ids, and ${code("archetype")} the kind of page it is. Send the archetype: without it every present block is offered for removal, and a landing page can lose the hero its own archetype marks required. The same validation as above applies, plus ${code("theme")}, ${code("archetype")} and every entry of ${code("variants")} having to name something that exists. Returns one JSON object: the judged ${code("action")} with its ${code("confidence")}, whichever of ${code("slot")} / ${code("theme")} / ${code("variant")} that action needs, ${code("blockedBy")} when the request named no target confidently enough to act on, or named one the page will not give up, and ${code("speculative")} holding every branch Jev scored. One round trip, and no copy is regenerated.`, "", "## Optional", "", diff --git a/lib/mcp.test.ts b/lib/mcp.test.ts index b2d6067..66e5e77 100644 --- a/lib/mcp.test.ts +++ b/lib/mcp.test.ts @@ -157,6 +157,16 @@ describe("the tools' answers", () => { expect(payload.message).toContain("hero"); }); + it("treats a block name inherited from Object.prototype as a name loom does not have, so an agent is never told to go improve a block loom cannot render", () => { + // `"__proto__" in SLOTS` is true, and sourcesFor then returns [] for it perfectly calmly — which is what made the wrong answer look like a right one: known: true, count: 0, and a message pointing at loom's own rendering of a block that does not exist. + for (const block of ["__proto__", "toString", "constructor", "hasOwnProperty"]) { + const payload = structured("sources_for_block", { block }); + expect(payload.known, `${block} was reported as a real loom block`).toBe(false); + expect(payload.sources).toEqual([]); + expect(payload.message, `${block} was answered without saying loom has no such block`).toContain(block); + } + }); + it("says so when a real block has no source covering it, instead of returning an empty list that reads like a failed lookup", () => { const uncovered = SLOT_ORDER.filter((block) => sourcesFor(block).length === 0); expect(uncovered.length, "every block is covered now, so this test no longer guards anything: check lib/sources.ts").toBeGreaterThan(0); @@ -213,6 +223,17 @@ describe("the tools' answers", () => { expect(payload.tokens).toEqual(themeVars(FALLBACK_THEME)); }); + it("answers a theme name inherited from Object.prototype with the labelled fallback instead of throwing, because an exception here does not cost a reply, it costs the session", () => { + // This is the crash that was shipping: `"toString" in THEMES` is true, so the fallback was skipped and themeVars destructured `tokens` off Object.prototype.toString. initialize was answered, the two requests behind it never were, and the client watched the process exit. + for (const theme of ["toString", "constructor", "valueOf", "__proto__", "isPrototypeOf"]) { + const payload = structured("theme_tokens", { theme }); + expect(payload.matched, `${theme} was matched to a theme loom does not have`).toBe(false); + expect(payload.theme).toBe(FALLBACK_THEME); + expect(payload.tokens, `${theme} was answered with something other than the fallback palette`).toEqual(themeVars(FALLBACK_THEME)); + expect(payload.message).toContain(theme); + } + }); + it("still names the theme themeVars really falls back to, so changing that fallback in lib/themes.ts cannot leave this server mislabelling it", () => { expect(themeVars("no-such-theme"), `themeVars no longer falls back to ${FALLBACK_THEME}`).toEqual(themeVars(FALLBACK_THEME)); }); @@ -234,18 +255,20 @@ describe("messages that arrive broken", () => { }); it("answers rather than throws for anything that is not a well-formed request, since the sender is on the other side of a pipe and cannot be trusted to be one", () => { + // Arrays other than the empty one live in the batch test below, since a batch is answered with an array of replies rather than the single envelope this loop asserts. const malformed: unknown[] = [ 42, "not a message", null, [], - [{ jsonrpc: "2.0", id: 1, method: "tools/list" }], { jsonrpc: "2.0", id: 1 }, { jsonrpc: "2.0", id: 1, method: 5 }, { jsonrpc: "2.0", id: {}, method: "tools/list" }, { jsonrpc: "2.0", id: 1, method: "tools/call" }, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "list_blocks", arguments: "nope" } }, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "theme_tokens", arguments: { theme: 12 } } }, + { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "theme_tokens", arguments: { theme: "toString" } } }, + { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "sources_for_block", arguments: { block: "constructor" } } }, ]; for (const request of malformed) { @@ -257,6 +280,43 @@ describe("messages that arrive broken", () => { } }); + it("carries the request's id on an error whenever the message had a readable one, because a reply with id null settles no pending request and reaches the user as a timeout rather than as the reason", () => { + for (const id of [9, "abc"]) { + // A broken `method` is the case where the id is plainly visible and was being thrown away anyway. + expect((ask({ jsonrpc: "2.0", id, method: 5 }) as Response).id, `id ${id} was dropped from the reply to a non-string method`).toBe(id); + expect((ask({ jsonrpc: "2.0", id }) as Response).id, `id ${id} was dropped from the reply to a message with no method`).toBe(id); + } + // An id the protocol does not allow is still no id: there is nothing to echo, and null is the spec's answer. + expect((ask({ jsonrpc: "2.0", id: {}, method: "tools/list" }) as Response).id).toBeNull(); + }); + + it("answers every request in a batch with its own reply, since a batch answered by one id-less error leaves every id in it pending until the client gives up on its own", () => { + // SUPPORTED_PROTOCOL_VERSIONS advertises 2025-03-26, the revision that requires a server to receive batches, so a client is entitled to send one. + expect(SUPPORTED_PROTOCOL_VERSIONS, "2025-03-26 is gone, and with it the reason this server must accept batches").toContain("2025-03-26"); + + const replies = handle([ + { jsonrpc: "2.0", id: 1, method: "tools/list" }, + { jsonrpc: "2.0", method: "notifications/initialized" }, + { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "list_themes" } }, + { jsonrpc: "2.0", id: 3, method: "resources/list" }, + ]) as Response[]; + + expect(Array.isArray(replies), "a batch was not answered with an array of replies").toBe(true); + // The notification is the one message that must not appear: it has no id, so a reply to it is a response the client can never claim. + expect(replies.map((reply) => reply.id)).toEqual([1, 2, 3]); + expect(replies[0]!.result!.tools.length).toBe(TOOLS.length); + expect(replies[1]!.result!.structuredContent.themes.length).toBe(Object.keys(THEMES).length); + expect(replies[2]!.error!.code).toBe(-32601); + }); + + it("returns nothing for a batch of pure notifications and an error for an empty array, keeping the one case the spec calls Invalid Request distinct from the one it forbids answering", () => { + expect(handle([{ jsonrpc: "2.0", method: "notifications/initialized" }, { jsonrpc: "2.0", method: "notifications/cancelled" }])).toBeNull(); + + const empty = handle([]) as Response; + expect(empty.error!.code).toBe(-32600); + expect(Array.isArray(empty), "an empty batch was answered with an array, which is the one shape the spec rules out here").toBe(false); + }); + it("treats a message with no id as a notification even when the rest of it is nonsense, because answering one would put an unclaimed response on the stream", () => { expect(handle({ jsonrpc: "2.0", method: "nothing/at/all" })).toBeNull(); }); diff --git a/lib/mcp.ts b/lib/mcp.ts index bcbd4c5..8c3e84f 100644 --- a/lib/mcp.ts +++ b/lib/mcp.ts @@ -13,6 +13,8 @@ import { THEMES, themeVars } from "./themes"; * * The one fact this module adds is which theme a fallback landed on. `themeVars()` answers an unknown name with forest's palette, which is right for rendering — a page still gets colours — and wrong to hand an agent unlabelled, because it would paste a green button in believing it had asked for terminal. So the reply names the theme actually returned, and a test below checks that FALLBACK_THEME is still the one themeVars picks. * + * Every lookup keyed by a string the client chose goes through `Object.hasOwn`, never `in` and never a bare index. The two that did not cost a server: `theme_tokens` with `{"theme":"toString"}` passed `"toString" in THEMES`, so the fallback was skipped, `themeVars("toString")` destructured `tokens` off `Object.prototype.toString` and threw `Cannot read properties of undefined (reading 'bg')` out of handle() — initialize was answered, the two requests behind it never were, and the process was gone. `sources_for_block` with `{"block":"__proto__"}` failed more quietly through the same hole: `known: true` and a suggestion to go read loom's rendering of a block that does not exist, from the tool whose description promises "never a guess". A JSON object off a pipe inherits from Object.prototype, so "toString", "constructor", "valueOf", "hasOwnProperty" and "isPrototypeOf" are all live keys in any table written as an object literal, and the `enum` in an inputSchema constrains a well-behaved client only — nothing on the wire enforces it. + * * This module is pure and synchronous: no stdio, no fetch, no process. scripts/mcp.mjs is the pump and holds nothing else. */ @@ -125,7 +127,8 @@ export const TOOLS: Tool[] = [ run: (args) => { const block = requireString(args, "block"); if (block === null) return badArgument(`This tool needs a \`block\` name. loom's blocks are: ${SLOT_ORDER.join(", ")}.`); - if (!(block in SLOTS)) { + // hasOwn rather than `in`: "__proto__" and "toString" are in SLOTS by inheritance, and this branch is the only thing standing between them and a confident answer about a block loom does not have. + if (!Object.hasOwn(SLOTS, block)) { return ok({ block, known: false, @@ -186,7 +189,8 @@ export const TOOLS: Tool[] = [ run: (args) => { const requested = requireString(args, "theme"); if (requested === null) return badArgument(`This tool needs a \`theme\` name. loom's themes are: ${Object.keys(THEMES).join(", ")}.`); - const matched = requested in THEMES; + // hasOwn rather than `in`: `themeVars` takes whatever this decides is a real theme, and an inherited member reaches it as a function rather than a palette. + const matched = Object.hasOwn(THEMES, requested); const theme = matched ? requested : FALLBACK_THEME; return ok({ requested, @@ -249,18 +253,21 @@ function callTool(id: string | number, params: unknown) { * * The null is not an oversight path: JSON-RPC calls an id-less message a notification and forbids a reply, and `notifications/initialized` is the one every client sends immediately after the handshake. Writing anything back for it puts a response on the stream that no client is waiting for, and from there every later id lines up against the wrong request. * + * The id is read before anything else is judged, and every error carries it when the message had a usable one. A reply with `id: null` is correlated to nothing: the client's pending promise for id 9 stays pending and what the user sees is a timeout, not "your method field was not a string". The spec permits null for an Invalid Request; it does not make it useful. The ordering is load-bearing — the method check stays ahead of the notification check, or a notification with a broken method starts getting an unsolicited reply. + * * Nothing in here throws. A malformed message arrives from the other side of a pipe, and the cost of an exception is not a bad reply but a dead server: the pump exits, the client sees the process go, and the session ends mid-task. */ -export function handle(request: unknown): object | null { +function single(request: unknown): object | null { if (!isObject(request)) { - return failure(null, INVALID_REQUEST, "A JSON-RPC request must be a single JSON object; batches and bare values are not supported."); + return failure(null, INVALID_REQUEST, "A JSON-RPC request must be a single JSON object. A batch is an array of them, not an array of arrays."); } + const declared = request.id; + const id = typeof declared === "string" || typeof declared === "number" ? declared : null; if (typeof request.method !== "string") { - return failure(null, INVALID_REQUEST, "A JSON-RPC request must carry a string `method`."); + return failure(id, INVALID_REQUEST, "A JSON-RPC request must carry a string `method`."); } if (!("id" in request)) return null; - const id = request.id; - if (typeof id !== "string" && typeof id !== "number") { + if (id === null) { return failure(null, INVALID_REQUEST, "A JSON-RPC `id` must be a string or a number."); } @@ -275,3 +282,21 @@ export function handle(request: unknown): object | null { return failure(id, METHOD_NOT_FOUND, `Unknown method: ${request.method}. This server implements initialize, tools/list and tools/call.`); } } + +/** + * The same, plus the array form: a batch in, an array of replies out, or null when every message in it was a notification. + * + * A batch was answered with one id-less error until this existed, and that is a hang rather than a refusal — the client gets nothing it can match to id 1 or id 2 and waits for its own timeout on both. MCP 2025-03-26 requires a server to receive batches and SUPPORTED_PROTOCOL_VERSIONS advertises that revision, so a client is entitled to send one. Dropping the revision instead would be the worse trade: every 2025-03-26-only client would disconnect at the handshake, which is certain and common, to avoid a batch, which is rare — no shipping client batches by default. + * + * An empty array is not a batch of nothing, it is an Invalid Request by name in the spec, so it gets the single error object the spec asks for rather than silence. + */ +export function handle(request: unknown): object | object[] | null { + if (Array.isArray(request)) { + if (request.length === 0) { + return failure(null, INVALID_REQUEST, "A JSON-RPC batch must be an array carrying at least one request."); + } + const replies = request.map(single).filter((reply): reply is object => reply !== null); + return replies.length > 0 ? replies : null; + } + return single(request); +} diff --git a/lib/page.test.ts b/lib/page.test.ts index b490840..a89259a 100644 --- a/lib/page.test.ts +++ b/lib/page.test.ts @@ -41,6 +41,25 @@ describe("page callbacks", () => { expect(found.map((c) => c.name)).toContain("run"); }); + it("declares every piece of state it puts in a request body, because a stale one is sent as if it were current", () => { + // The dictionary rule below caught one instance of this and the parser it uses generalises for free. The instance it did not catch: applyEdit sent `variants` — the page's current variant ids — without declaring it, so after any restyle the next edit told Jev the page still looked the way it did two edits ago, and Jev answered a question about a page that no longer existed. Nothing threw; the answer was just quietly about the wrong page. + const state = [...SOURCE.matchAll(/const \[(\w+), set\w+\] = useState/g)].map((m) => m[1]!); + expect(state.length, "no useState declarations found — the parser stopped matching").toBeGreaterThanOrEqual(10); + + for (const cb of found) { + const body = /body: JSON\.stringify\(\{([^}]*)\}/.exec(cb.body); + if (!body) continue; + for (const field of body[1]!.split(",")) { + // `key: value` sends `value`; a bare `key` is shorthand and sends the state of that name. + const sent = (field.includes(":") ? field.slice(field.indexOf(":") + 1) : field).trim(); + if (!state.includes(sent)) continue; + expect(cb.deps, `${cb.name} sends ${sent} but does not depend on it`).toMatch( + new RegExp(`\\b${sent}\\b`), + ); + } + } + }); + it("declares the dictionary as a dependency wherever it is read", () => { for (const cb of found) { if (!/\bt\./.test(cb.body)) continue; @@ -48,6 +67,101 @@ describe("page callbacks", () => { } }); + const run = () => found.find((c) => c.name === "run")!; + + it("returns the editor to idle from a finally, so a dropped connection cannot leave Generate disabled until a page reload", () => { + // The original had two `setRunning(false)` calls, neither on the failure path: one early return for a missing body and one after the read loop. A rejected fetch skipped both and the button stayed dead. + const body = run().body; + expect(body).toMatch(/\}\s*finally\s*\{\s*setRunning\(false\);\s*\}/); + expect((body.match(/setRunning\(false\)/g) ?? []).length).toBe(1); + }); + + it("reads the response status before the body, so the 400 for an empty prompt is reported rather than parsed as an event", () => { + // `{"error":"prompt is required"}` is one valid JSON line. Handed to the event loop it matches no branch, and the status sits on "Sending…" with nothing shown. + const body = run().body; + expect(body).toContain("response.ok"); + expect(body.indexOf("response.ok")).toBeLessThan(body.indexOf("getReader")); + }); + + it("skips a line it cannot parse instead of throwing out of the read loop, so one bad line does not discard the blocks already on screen", () => { + const body = run().body; + const parsed = body.indexOf("JSON.parse(line)"); + expect(parsed).toBeGreaterThan(-1); + const opened = body.lastIndexOf("try {", parsed); + const caught = body.indexOf("catch", parsed); + expect(opened).toBeGreaterThan(-1); + expect(caught).toBeGreaterThan(parsed); + expect(body.slice(caught, caught + 300)).toContain("continue"); + }); + + it("assigns the token total rather than accumulating it, because both jev rounds report the same running sum and adding them would bill a 3908-token run as 7038", () => { + const calls = [...SOURCE.matchAll(/setTokens\(([^\n]*)/g)].map((m) => m[1]!); + expect(calls.length).toBeGreaterThanOrEqual(2); + for (const call of calls) { + expect(call, `setTokens(${call} accumulates`).not.toMatch(/current\s*\+[^+]/); + expect(call, `setTokens(${call} accumulates`).not.toContain("+="); + } + // And the readout has to be fed by that state, not by the per-step field that was never written. + expect(SOURCE).not.toContain("step.inputTokens"); + // `complete` is the one event guaranteed to arrive and it carries the final total, so it is the writer that cannot be missing. Dropping the `plan` or `select` writer only changes what the readout shows mid-run; dropping this one means a finished run reports nothing. + const body = run().body; + const at = body.indexOf('event.type === "complete"'); + expect(at).toBeGreaterThan(-1); + const end = body.indexOf("} else if", at); + expect(body.slice(at, end)).toContain("setTokens("); + }); + + it("applies every edit action lib/edit.ts can return with a target, so a planned edit cannot print a success line and change nothing", () => { + // `unclear` is a routing label rather than an outcome, and `rewrite` has no implementation anywhere; both are named here so the exemption is visible instead of silently absent. + const exempt = new Set(["unclear", "rewrite"]); + const edit = readFileSync(join(import.meta.dirname, "edit.ts"), "utf8"); + const block = edit.slice(edit.indexOf("const ACTIONS"), edit.indexOf("const SLOT_LABELS")); + const actions = [...block.matchAll(/^ {2}(\w+):/gm)].map((m) => m[1]!); + expect(actions).toContain("add"); + const applyEdit = found.find((c) => c.name === "applyEdit")!; + const handled = new Set( + [...applyEdit.body.matchAll(/plan\.action === "(\w+)"/g)].map((m) => m[1]!), + ); + for (const action of actions) { + if (exempt.has(action)) continue; + expect(handled.has(action), `${action} is planned but never applied`).toBe(true); + } + }); + + it("gates every element lookup in applyEdit on isReady, because elementsFor returns a truthy element for a variant whose copy has not arrived", () => { + const body = found.find((c) => c.name === "applyEdit")!.body; + const lookups = [...body.matchAll(/elementsFor\(/g)].map((m) => m.index!); + expect(lookups.length).toBeGreaterThan(0); + for (const at of lookups) { + expect(body.lastIndexOf("isReady(", at), "an element is spliced in ungated").toBeGreaterThan(-1); + } + }); + + it("disables the edit form while a run is streaming, because the next partial event replaces the whole spec and reverts the edit", () => { + const start = SOURCE.indexOf("void applyEdit()"); + expect(start).toBeGreaterThan(-1); + const form = SOURCE.slice(start, SOURCE.indexOf("", start)); + expect((form.match(/disabled=\{running\}/g) ?? []).length).toBe(2); + }); + + it("titles the HTML export from the string it actually writes, so clearing the input after a run does not save a file called nothing", () => { + const body = found.find((c) => c.name === "exportHtml")!.body; + expect(body).not.toContain("pickedTheme"); + expect(body).toContain('"loom"'); + expect(body).toMatch(/prompt\.trim\(\)/); + }); + + it("states the selected theme and locale in ARIA and with a non-colour cue, because a border colour is announced to nobody", () => { + expect(SOURCE).toContain("aria-pressed={theme === key}"); + expect(SOURCE).toContain("aria-pressed={locale === code}"); + // The status line carries the whole twenty-second generation; without a live region a screen reader user hears nothing between pressing Generate and the page appearing. + const at = SOURCE.indexOf("{status && ("); + expect(at).toBeGreaterThan(-1); + expect(SOURCE.slice(at, at + 200)).toContain('role="status"'); + const swatch = SOURCE.indexOf("data-theme-key"); + expect(SOURCE.slice(swatch, swatch + 1200)).toContain("font-medium"); + }); + it("detects the locale in an effect rather than during render", () => { // Reading navigator during render would desync server and client markup. expect(SOURCE).toMatch(/useEffect\(\(\) => \{[\s\S]{0,200}detectLocale\(\)/); diff --git a/lib/themes.test.ts b/lib/themes.test.ts index 16338fe..2d4202e 100644 --- a/lib/themes.test.ts +++ b/lib/themes.test.ts @@ -1,11 +1,14 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { THEMES, themeVars } from "./themes"; /** - * Contrast is a property of the token values, so it can be checked without a - * browser. A theme that ships unreadable buttons is a defect in the generator, - * not a matter of taste, and it would otherwise only surface in an audit of one - * generated page that happened to use that theme. + * Contrast is a property of the token values, so it can be checked without a browser. A theme that ships unreadable buttons is a defect in the generator, not a matter of taste, and it would otherwise only surface in an audit of one generated page that happened to use that theme. + * + * The first version of this file constrained six pairs while app/registry.tsx painted text on three more grounds it never mentioned: --accent-soft, --hero-from and --hero-to. The gap was not theoretical. accent on accentSoft is 4.505 in `warm` and 4.54 in `coral` — AA by a hundredth, with nothing behind it. Darkening `warm.accentSoft` by one step, the kind of nudge a palette tweak makes without thinking, takes it to 4.24 and ships the Comparison header, the Team role line and the Team avatar below AA; before these assertions the suite stayed green through exactly that, which is how 570fef6 ("coral shipped unreadable buttons, and nothing was checking") happened the first time. Every pair below names the registry site that renders it, so the list can be checked against the file rather than trusted. + * + * Two numbers worth stating rather than hiding. muted on accentSoft is 4.35 in `ink`: below 4.5 and fine, because muted is secondary text and the project's bar for it is 3:1 — that is why this one pair is asserted at 3. And the gradient is checked at both stops rather than only at heroFrom, because a title that clears AA where it starts and fails where it ends is still an unreadable title. */ function relativeLuminance(hex: string): number { const h = hex.replace("#", ""); @@ -45,18 +48,61 @@ describe("theme contrast", () => { expect(contrast(t.bg, t.muted)).toBeGreaterThanOrEqual(3); expect(contrast(t.band, t.bandMuted)).toBeGreaterThanOrEqual(3); }); + + it("keeps the accent readable where the registry uses it as a text colour rather than a fill", () => { + // Comparison header and Team role: 13px accent on --surface and --bg. + expect(contrast(t.bg, t.accent)).toBeGreaterThanOrEqual(4.5); + expect(contrast(t.surface, t.accent)).toBeGreaterThanOrEqual(4.5); + // Team avatar initial: accent on --accent-soft. + expect(contrast(t.accentSoft, t.accent)).toBeGreaterThanOrEqual(4.5); + }); + + it("keeps the Contact note legible on its accent-soft panel", () => { + // registry.tsx paints props.note as --muted on --accent-soft. Muted is + // secondary text, so the bar is the same 3:1 used for bg/muted above. + expect(contrast(t.accentSoft, t.muted)).toBeGreaterThanOrEqual(3); + }); + + it("keeps hero copy readable at both ends of the gradient, not just where it starts", () => { + // Two heroes and the skeleton paint on linear-gradient(--hero-from → + // --hero-to), so a pair that only clears AA at one stop ships a title + // that fades out halfway down. + for (const ground of [t.heroFrom, t.heroTo]) { + expect(contrast(ground, t.text)).toBeGreaterThanOrEqual(4.5); + expect(contrast(ground, t.accent)).toBeGreaterThanOrEqual(4.5); + expect(contrast(ground, t.muted)).toBeGreaterThanOrEqual(3); + } + }); }); } - it("declares every colour as a six-digit hex, so the check above cannot be fooled", () => { + it("declares every colour as a six-digit hex, so the checks above cannot be fooled", () => { for (const [name, theme] of Object.entries(THEMES)) { - for (const key of ["bg", "surface", "text", "muted", "border", "accent", "accentText", "band", "bandText", "bandMuted"] as const) { + for (const key of ["bg", "surface", "text", "muted", "border", "accent", "accentText", "accentSoft", "band", "bandText", "bandMuted", "heroFrom", "heroTo"] as const) { expect(theme.tokens[key], `${name}.${key}`).toMatch(HEX); } } }); }); +/** + * All eight READMEs used to say app/registry.tsx contains no hex values at all. One grep disproved it: three sit in HeroSplit's mocked screenshot, the macOS traffic lights. They stay — they depict another operating system's window chrome rather than loom's palette, and a traffic light that followed the theme would stop reading as a traffic light — so the sentence was corrected to name the exception instead. This is what keeps the corrected sentence true: three hex literals in that file, all of them in that one array. + * + * The assertion pins the count and the location, not the values, so re-tinting the dots does not require editing a test, while a fourth hex anywhere in the file does. Matching values here would also put a colour literal outside lib/themes.ts, which is the rule this whole check exists to enforce. + */ +describe("the registry's colours", () => { + const source = readFileSync(join(import.meta.dirname, "..", "app", "registry.tsx"), "utf8"); + const TRAFFIC_LIGHTS = /\[("#[0-9a-fA-F]{6}", ){2}"#[0-9a-fA-F]{6}"\]\.map\(\(dot\)/; + + it("come from theme tokens everywhere but the window chrome the README names, so a theme change reaches every block", () => { + const literals = source.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []; + expect(literals, "app/registry.tsx grew a colour the themes cannot reach").toHaveLength(3); + expect(source, "the three hex literals are no longer the screenshot mock's traffic lights").toMatch( + TRAFFIC_LIGHTS, + ); + }); +}); + describe("themeVars", () => { it("emits a custom property for every token", () => { const vars = themeVars("forest"); diff --git a/scripts/sources.mjs b/scripts/sources.mjs index 1fdcac3..4b9cf38 100644 --- a/scripts/sources.mjs +++ b/scripts/sources.mjs @@ -86,9 +86,10 @@ async function probeMcp(url) { const body = await listed.text(); if (!listed.ok) return { status: listed.status, ok: false, note: "tools/list refused" }; - // Streamable HTTP wraps the reply in server-sent events; the JSON-RPC message is the `data:` line. - const line = body.split("\n").find((l) => l.startsWith("data:")); - const tools = line ? JSON.parse(line.slice(5)).result?.tools : undefined; + // Streamable HTTP lets the server answer a POST with either content type, and the request above accepts both, so the reply has to be read by what came back rather than by what beUI happened to send the day this was written. An SSE reply carries the JSON-RPC message on the `data:` line; a plain JSON reply is the message. Reading only `data:` lines reports "no tool list" for a healthy server that listed 30 tools, and with ok:true the exit code stays 0, so the row reads healthy while losing the count this script exists for. + const sse = listed.headers.get("content-type")?.includes("event-stream") ?? false; + const payload = sse ? JSON.parse((body.split("\n").find((l) => l.startsWith("data:")) ?? "").slice(5) || "null") : JSON.parse(body); + const tools = payload?.result?.tools; return { status: listed.status, ok: true, note: Array.isArray(tools) ? `${tools.length} tools` : "no tool list" }; }