Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
62 changes: 49 additions & 13 deletions app/api/edit/route.ts
Original file line number Diff line number Diff line change
@@ -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<Record<SlotKey, 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<string, unknown>;

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<Record<SlotKey, string>> = {};
if (typeof body.variants === "object" && body.variants !== null && !Array.isArray(body.variants)) {
for (const [slot, variant] of Object.entries(body.variants as Record<string, unknown>)) {
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: {},
Expand All @@ -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) {
Expand Down
32 changes: 27 additions & 5 deletions app/api/generate/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

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();
Expand Down
Loading
Loading