From 9aa8dab97baeeddb7a9b3c2d12143f765358388b Mon Sep 17 00:00:00 2001 From: sigma-andex <77549848+sigma-andex@users.noreply.github.com> Date: Sun, 22 Mar 2026 16:23:22 +0000 Subject: [PATCH 1/2] Improve summary prompts --- apps/backend/src/services/memory/service.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/apps/backend/src/services/memory/service.ts b/apps/backend/src/services/memory/service.ts index 77932c5..ce2a983 100644 --- a/apps/backend/src/services/memory/service.ts +++ b/apps/backend/src/services/memory/service.ts @@ -46,18 +46,18 @@ const MemorySummariesSchema = z.object({ .string() .nullable() .describe( - "Compressed user intent, 3-8 words. Drop articles/filler. Null if no prompts.", + "Compressed user intent. Drop articles/filler, keep all details — selectors, colors, text, layout info. Null if no prompts.", ), actionSummary: z .string() .nullable() .describe( - "Compressed action, 3-8 words. Drop articles/filler. Null if no actions.", + "Compressed action. Drop articles/filler, keep all details — element ids, values, event types. Null if no actions.", ), changeSummary: z .string() .describe( - "Compressed visual/functional change, 5-12 words. Drop articles/connectives/filler. Keep facts/numbers.", + "Compressed visual/functional change. Drop articles/connectives/filler. Describe what visually changed, not DOM operations. Keep ALL important details.", ), }); @@ -147,9 +147,11 @@ export class MemoryService extends Effect.Service()( ? `User actions:\n${op.actions.map((a, i) => `${i + 1}. ${a.action}${a.data ? ` (data: ${JSON.stringify(a.data)})` : ""}`).join("\n")}` : "No user actions."; - const prompt = `Analyze UI changes. Generate compressed summaries. + const prompt = `Summarize UI changes. Focus on WHAT visually changed, not DOM operations. -COMPRESSION: Drop articles, connectives, filler. Active voice, present tense. Keep facts/numbers/specifics. +STYLE: Caveman compression — drop articles, connectives, filler. Keep ALL important info. Describe visual result, not selectors/divs. +BAD: "replaced html #hero, appended content #left-col, updated attributes #root class" +GOOD: "redesigned hero section, added sidebar cards, changed color palette to dark theme" ${promptContext} @@ -158,9 +160,9 @@ ${actionContext} ${changeDescription} Generate: -1. promptSummary: Compressed user intent (3-8 words). Null if no prompts. -2. actionSummary: Compressed action (3-8 words). Null if no actions. -3. changeSummary: Compressed visual/functional change (5-12 words).`; +1. promptSummary: What user asked for, all key details. Null if no prompts. +2. actionSummary: What user did, all key details. Null if no actions. +3. changeSummary: What visually changed, all key details. Describe appearance not DOM ops.`; const result = yield* Effect.promise(() => generateText({ From 80f3bd6a91469d734d35e200de1f644f85306cbb Mon Sep 17 00:00:00 2001 From: sigma-andex <77549848+sigma-andex@users.noreply.github.com> Date: Sun, 12 Apr 2026 14:10:44 +0100 Subject: [PATCH 2/2] WIP --- .gitignore | 2 +- .../src/services/generate/service.test.ts | 3 + apps/backend/src/services/generate/service.ts | 18 +- apps/backend/src/services/generate/types.ts | 89 +-- apps/backend/src/services/memory/service.ts | 41 +- apps/backend/src/services/ui.ts | 4 +- .../src/services/vdom/patch-validator.ts | 6 +- apps/backend/src/services/vdom/vdom.ts | 6 +- apps/experiments/src/component.ts | 32 +- apps/webpage/src/main.ts | 27 +- docs/plans/OPTIMISE-HTML.md | 247 +++++++ docs/plans/STATE-SEPARATION.md | 614 ++++++++++++++++++ packages/common/src/patch.ts | 58 +- 13 files changed, 1000 insertions(+), 147 deletions(-) create mode 100644 docs/plans/OPTIMISE-HTML.md create mode 100644 docs/plans/STATE-SEPARATION.md diff --git a/.gitignore b/.gitignore index d7951e0..47e4eda 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ logs *.log -node_modules +node_modules/ dist # Editor directories and files diff --git a/apps/backend/src/services/generate/service.test.ts b/apps/backend/src/services/generate/service.test.ts index e963244..11ed1f4 100644 --- a/apps/backend/src/services/generate/service.test.ts +++ b/apps/backend/src/services/generate/service.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Stream, Layer, Chunk, Option, Ref } from "effect"; +import { NodeFileSystem } from "@effect/platform-node"; import { MockLanguageModelV3 } from "ai/test"; import { simulateReadableStream } from "ai"; import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; @@ -119,6 +120,7 @@ const createTestLayer = (mockModel: ReturnType) => Layer.provide(PatchValidator.Default), Layer.provide(createMockRegistryLayer(mockModel)), Layer.provide(MockToolServiceLayer), + Layer.provide(NodeFileSystem.layer), ); // ============================================================ @@ -205,6 +207,7 @@ const createToolTestLayer = ( Layer.provide(makeMockSandboxServiceLayer(manager)), ), ), + Layer.provide(NodeFileSystem.layer), ); // ============================================================ diff --git a/apps/backend/src/services/generate/service.ts b/apps/backend/src/services/generate/service.ts index d312e9d..c911b88 100644 --- a/apps/backend/src/services/generate/service.ts +++ b/apps/backend/src/services/generate/service.ts @@ -1,4 +1,4 @@ -import { Effect, Stream, pipe, DateTime, Duration, Ref, Option, Runtime } from "effect"; +import { Effect, Stream, pipe, DateTime, Duration, Ref, Option, Runtime, Schema, Either } from "effect"; import { streamText, tool, type TextStreamPart, type ToolSet } from "ai"; import { z } from "zod"; import type { LanguageModelConfig } from "@cuttlekit/common/server"; @@ -53,25 +53,25 @@ export class GenerateService extends Effect.Service()( }), }); - // Try parsing as LLM response (patches only) - const llmResult = LLMResponseSchema.safeParse(parseResult); - if (llmResult.success) { - return llmResult.data; + // Try parsing as LLM response + const llmResult = Schema.decodeUnknownEither(LLMResponseSchema)(parseResult); + if (Either.isRight(llmResult)) { + return llmResult.right; } // Fallback: check if it's a raw patch and wrap it - const patchResult = PatchSchema.safeParse(parseResult); - if (patchResult.success) { + const patchResult = Schema.decodeUnknownEither(PatchSchema)(parseResult); + if (Either.isRight(patchResult)) { return { op: "patches" as const, - patches: [patchResult.data], + patches: [patchResult.right], }; } // Neither valid - fail with JsonParseError return yield* new JsonParseError({ line, - message: z.prettifyError(llmResult.error), + message: String(llmResult.left), }); }); diff --git a/apps/backend/src/services/generate/types.ts b/apps/backend/src/services/generate/types.ts index 1da7088..921a6cb 100644 --- a/apps/backend/src/services/generate/types.ts +++ b/apps/backend/src/services/generate/types.ts @@ -1,70 +1,71 @@ -import { z } from "zod"; +import { Schema } from "effect"; import type { Option } from "effect"; import type { Action } from "@cuttlekit/common/client"; import type { SandboxContext } from "../sandbox/manager.js"; import type { ComponentSpec } from "../vdom/vdom.js"; // ============================================================ -// Zod Schemas +// Effect Schemas // ============================================================ -export const PatchSchema = z.union([ - z.object({ selector: z.string(), text: z.string() }), - z.object({ - selector: z.string(), - attr: z.record(z.string(), z.string().nullable()), - }), - z.object({ selector: z.string(), append: z.string() }), - z.object({ selector: z.string(), prepend: z.string() }), - z.object({ selector: z.string(), html: z.string() }), - z.object({ selector: z.string(), remove: z.literal(true) }), -]); +export const PatchSchema = Schema.Struct({ + selector: Schema.String, + text: Schema.optional(Schema.String), + attr: Schema.optional( + Schema.Record({ key: Schema.String, value: Schema.NullOr(Schema.String) }) + ), + append: Schema.optional(Schema.String), + prepend: Schema.optional(Schema.String), + html: Schema.optional(Schema.String), + remove: Schema.optional(Schema.Literal(true)), +}); -export const PatchArraySchema = z.array(PatchSchema); +export const PatchArraySchema = Schema.Array(PatchSchema); // Schema for define ops (component registration) -export const DefineOpSchema = z.object({ - op: z.literal("define"), - tag: z.string(), - props: z.array(z.string()), - template: z.string(), +export const DefineOpSchema = Schema.Struct({ + op: Schema.Literal("define"), + tag: Schema.String, + props: Schema.Array(Schema.String), + template: Schema.String, }); // Schema for LLM responses — discriminated on `op` -export const LLMResponseSchema = z.union([ - z.object({ - op: z.literal("patches"), - patches: PatchArraySchema, - }), - z.object({ - op: z.literal("full"), - html: z.string(), - }), +export const PatchesResponse = Schema.Struct({ + op: Schema.Literal("patches"), + patches: PatchArraySchema, +}); + +export const FullResponse = Schema.Struct({ + op: Schema.Literal("full"), + html: Schema.String, +}); + +export const LLMResponseSchema = Schema.Union( + PatchesResponse, + FullResponse, DefineOpSchema, -]); +); -export type LLMResponse = z.infer; +export type LLMResponse = typeof LLMResponseSchema.Type; // Full response schema includes stats (generated by code, not LLM) -export const UnifiedResponseSchema = z.union([ - LLMResponseSchema, - z.object({ - op: z.literal("stats"), - cacheRate: z.number(), - tokensPerSecond: z.number(), - mode: z.enum(["patches", "full"]), - patchCount: z.number(), - ttft: z.number(), - ttc: z.number(), - }), -]); +export type UnifiedResponse = + | LLMResponse + | { + op: "stats"; + cacheRate: number; + tokensPerSecond: number; + mode: "patches" | "full"; + patchCount: number; + ttft: number; + ttc: number; + }; // ============================================================ // Exported Types // ============================================================ -export type UnifiedResponse = z.infer; - export type UnifiedGenerateOptions = { sessionId: string; currentHtml: Option.Option; diff --git a/apps/backend/src/services/memory/service.ts b/apps/backend/src/services/memory/service.ts index ce2a983..5e435b8 100644 --- a/apps/backend/src/services/memory/service.ts +++ b/apps/backend/src/services/memory/service.ts @@ -14,7 +14,7 @@ import { StoreService } from "./store.js"; // ============================================================ export type MemoryChange = - | { type: "patches"; patches: Patch[] } + | { type: "patches"; patches: readonly Patch[] } | { type: "full"; html: string }; export type MemoryOperation = { @@ -65,35 +65,16 @@ const MemorySummariesSchema = z.object({ // Patch Description // ============================================================ -const describePatch = (patch: Patch): string => - Match.value(patch).pipe( - Match.when({ selector: Match.string, text: Match.string }, (p) => { - const text = p.text; - return `Set text in ${p.selector} to "${text.slice(0, 50)}${text.length > 50 ? "..." : ""}"`; - }), - Match.when( - { selector: Match.string, attr: Match.defined }, - (p) => - `Updated attributes on ${p.selector}: ${Object.keys(p.attr).join(", ")}`, - ), - Match.when( - { selector: Match.string, html: Match.string }, - (p) => `Replaced HTML in ${p.selector}`, - ), - Match.when( - { selector: Match.string, append: Match.string }, - (p) => `Appended content to ${p.selector}`, - ), - Match.when( - { selector: Match.string, prepend: Match.string }, - (p) => `Prepended content to ${p.selector}`, - ), - Match.when( - { selector: Match.string, remove: Match.boolean }, - (p) => `Removed ${p.selector}`, - ), - Match.exhaustive, - ); +const describePatch = (patch: Patch): string => { + const parts: string[] = []; + if (patch.remove) { parts.push(`Removed ${patch.selector}`); return parts.join("; "); } + if (patch.attr) parts.push(`Updated attributes on ${patch.selector}: ${Object.keys(patch.attr).join(", ")}`); + if (patch.text !== undefined) parts.push(`Set text in ${patch.selector} to "${patch.text.slice(0, 50)}${patch.text.length > 50 ? "..." : ""}"`); + if (patch.html !== undefined) parts.push(`Replaced HTML in ${patch.selector}`); + if (patch.append) parts.push(`Appended content to ${patch.selector}`); + if (patch.prepend) parts.push(`Prepended content to ${patch.selector}`); + return parts.join("; ") || `Patch on ${patch.selector}`; +}; const describePatches = (patches: readonly Patch[]): string => patches.map(describePatch).join("\n"); diff --git a/apps/backend/src/services/ui.ts b/apps/backend/src/services/ui.ts index 5b61e3f..94ac1d5 100644 --- a/apps/backend/src/services/ui.ts +++ b/apps/backend/src/services/ui.ts @@ -131,7 +131,7 @@ export class UIService extends Effect.Service()("UIService", { // Transform unified responses to stream events, applying to VDOM let lastHtml = Option.getOrElse(currentHtml, () => ""); - const handlePatchResponse = (patches: Patch[]) => + const handlePatchResponse = (patches: readonly Patch[]) => Effect.gen(function* () { const events = yield* Effect.forEach(patches, (patch) => Effect.gen(function* () { @@ -174,7 +174,7 @@ export class UIService extends Effect.Service()("UIService", { const handleDefineResponse = (r: { tag: string; - props: string[]; + props: readonly string[]; template: string; }) => Effect.gen(function* () { diff --git a/apps/backend/src/services/vdom/patch-validator.ts b/apps/backend/src/services/vdom/patch-validator.ts index 3c60343..5d51196 100644 --- a/apps/backend/src/services/vdom/patch-validator.ts +++ b/apps/backend/src/services/vdom/patch-validator.ts @@ -111,7 +111,7 @@ export class PatchValidator extends Effect.Service()( */ const defineComponent = ( ctx: ValidationContext, - spec: { tag: string; props: string[]; template: string }, + spec: { tag: string; props: readonly string[]; template: string }, ) => Effect.gen(function* () { const existing = ctx.registry.get(spec.tag); @@ -181,9 +181,9 @@ export class PatchValidator extends Effect.Service()( Effect.gen(function* () { const result = yield* validateAll(ctx.doc, patches); const hasStructuralMutation = patches.some( - (p) => "append" in p || "prepend" in p || "html" in p, + (p) => !!p.append || !!p.prepend || p.html !== undefined, ); - const hasAttrMutation = patches.some((p) => "attr" in p); + const hasAttrMutation = patches.some((p) => !!p.attr); if ((hasStructuralMutation || hasAttrMutation) && ctx.registry.size > 0) { yield* renderCETree(ctx.window, ctx.registry, hasAttrMutation); } diff --git a/apps/backend/src/services/vdom/vdom.ts b/apps/backend/src/services/vdom/vdom.ts index 00c4566..3eb4096 100644 --- a/apps/backend/src/services/vdom/vdom.ts +++ b/apps/backend/src/services/vdom/vdom.ts @@ -181,7 +181,7 @@ export class VdomService extends Effect.Service()("VdomService", { const define = ( sessionId: string, - op: { tag: string; props: string[]; template: string } + op: { tag: string; props: readonly string[]; template: string } ) => Effect.gen(function* () { const window = yield* getOrCreateWindow(sessionId) @@ -307,9 +307,9 @@ export class VdomService extends Effect.Service()("VdomService", { // Re-render CEs after structural mutations and CE attr updates. const hasStructuralMutation = patches.some( - (p) => "append" in p || "prepend" in p || "html" in p + (p) => !!p.append || !!p.prepend || p.html !== undefined ) - const hasAttrMutation = patches.some((p) => "attr" in p) + const hasAttrMutation = patches.some((p) => !!p.attr) if (hasStructuralMutation || hasAttrMutation) { const registry = yield* getRegistry(sessionId) if (registry.size > 0) { diff --git a/apps/experiments/src/component.ts b/apps/experiments/src/component.ts index f0646dd..3b00872 100644 --- a/apps/experiments/src/component.ts +++ b/apps/experiments/src/component.ts @@ -30,13 +30,15 @@ type DefineOp = { readonly template: string } -type Patch = - | { readonly selector: string; readonly append: string } - | { readonly selector: string; readonly prepend: string } - | { readonly selector: string; readonly attr: Record } - | { readonly selector: string; readonly remove: true } - | { readonly selector: string; readonly text: string } - | { readonly selector: string; readonly html: string } +type Patch = { + readonly selector: string + readonly text?: string + readonly attr?: Record + readonly append?: string + readonly prepend?: string + readonly html?: string + readonly remove?: true +} type Op = DefineOp | Patch type Registry = Map @@ -88,12 +90,12 @@ const applyPatch = (win: InstanceType, patch: Patch) => Effect.sync(() => { const el = win.document.querySelector(patch.selector) if (!el) return - if ("append" in patch) el.insertAdjacentHTML("beforeend", patch.append) - else if ("prepend" in patch) el.insertAdjacentHTML("afterbegin", patch.prepend) - else if ("html" in patch) el.innerHTML = patch.html - else if ("text" in patch) el.textContent = patch.text - else if ("attr" in patch) Object.entries(patch.attr).forEach(([k, v]) => v === null ? el.removeAttribute(k) : el.setAttribute(k, v)) - else if ("remove" in patch) el.remove() + if (patch.remove) { el.remove(); return } + if (patch.attr) Object.entries(patch.attr).forEach(([k, v]) => v === null ? el.removeAttribute(k) : el.setAttribute(k, v)) + if (patch.text !== undefined) el.textContent = patch.text + if (patch.html !== undefined) el.innerHTML = patch.html + if (patch.append) el.insertAdjacentHTML("beforeend", patch.append) + if (patch.prepend) el.insertAdjacentHTML("afterbegin", patch.prepend) }) // ============================================================ @@ -117,7 +119,7 @@ const executeDef = (win: InstanceType, registry: Registry, op: De const executePatch = (win: InstanceType, registry: Registry, patch: Patch) => Effect.gen(function* () { // Bootstrap #root on first structural mutation - const needsRoot = ("append" in patch || "html" in patch) && patch.selector === "#root" + const needsRoot = (!!patch.append || patch.html !== undefined) && patch.selector === "#root" if (needsRoot && !win.document.querySelector("#root")) { win.document.body.innerHTML = `
` } @@ -125,7 +127,7 @@ const executePatch = (win: InstanceType, registry: Registry, patc yield* applyPatch(win, patch) // Render CEs after structural mutations (attr handled by CE lifecycle) - if ("append" in patch || "prepend" in patch || "html" in patch) { + if (!!patch.append || !!patch.prepend || patch.html !== undefined) { yield* renderTree(win, registry) } }) diff --git a/apps/webpage/src/main.ts b/apps/webpage/src/main.ts index 0134886..981aac6 100644 --- a/apps/webpage/src/main.ts +++ b/apps/webpage/src/main.ts @@ -129,10 +129,10 @@ const app = { }, extractPatchContent(patch: Patch): string | null { - if ("html" in patch) return patch.html; - if ("append" in patch) return patch.append; - if ("prepend" in patch) return patch.prepend; - if ("attr" in patch && patch.attr.style) return patch.attr.style; + if (patch.html) return patch.html; + if (patch.append) return patch.append; + if (patch.prepend) return patch.prepend; + if (patch.attr?.style) return patch.attr.style; return null; }, @@ -149,11 +149,11 @@ const app = { loadIconsFromHTML(content); } - if ("remove" in patch) { + if (patch.remove) { el.remove(); return; } - if ("attr" in patch) { + if (patch.attr) { Object.entries(patch.attr).forEach(([key, value]) => { if (value === null) { el.removeAttribute(key); @@ -162,13 +162,16 @@ const app = { } }); } - if ("text" in patch) { + if (patch.text !== undefined) { el.textContent = patch.text; - } else if ("html" in patch) { + } + if (patch.html !== undefined) { el.innerHTML = patch.html; - } else if ("append" in patch) { + } + if (patch.append) { el.insertAdjacentHTML("beforeend", patch.append); - } else if ("prepend" in patch) { + } + if (patch.prepend) { el.insertAdjacentHTML("afterbegin", patch.prepend); } }, @@ -224,8 +227,8 @@ const app = { this.applyPatch(patch); // Re-render CEs after structural mutations and CE prop attr updates. const hasStructuralMutation = - "append" in patch || "prepend" in patch || "html" in patch; - const hasAttrMutation = "attr" in patch; + !!patch.append || !!patch.prepend || patch.html !== undefined; + const hasAttrMutation = !!patch.attr; if (hasStructuralMutation || hasAttrMutation) { renderTree(this.getElements().contentEl, hasAttrMutation); } diff --git a/docs/plans/OPTIMISE-HTML.md b/docs/plans/OPTIMISE-HTML.md new file mode 100644 index 0000000..a6400d9 --- /dev/null +++ b/docs/plans/OPTIMISE-HTML.md @@ -0,0 +1,247 @@ +# Optimise HTML Page State in LLM Prompts + +## Problem + +Despite the component registry compacting custom element instances (stripping template markup, keeping only `data-children` content), the `[PAGE STATE]` section still grows large. The example prompt shows ~4KB of HTML for a single meetup landing page, dominated by: + +1. **Tailwind class strings** — repeated verbose class lists on every element (`class="bg-white border-4 border-[#0a0a0a] px-4 py-2 font-black rotate-[-2deg]"`) +2. **Decorative/static elements** — stickers, blur divs, halftone overlays, azulejo tiles that never change +3. **Inline styles** — `style="font-family: 'Space Grotesk'"` repeated across elements +4. **Deeply nested non-component HTML** — the hero section, stats row, footer are not componentised and carry full markup + +The compact HTML pipeline (`getCompactHtmlFromCtx`) only strips CE template interiors — everything else passes through verbatim. + +--- + +## Current Compression Pipeline + +``` +Full VDOM (happy-dom) + → getCompactHtmlFromCtx(): strip CE template content, keep data-children + → [PAGE STATE] in prompt +``` + +Component instances like `` are already compact. The bloat comes from non-component HTML. + +--- + +## Proposed Optimisations + +### 1. LLM-Controlled Style Registry + +**Idea:** Mirror the component registry pattern — the LLM defines named styles via a new `{"op":"style",...}` operation, then references them with a `$` prefix in class attributes. The backend maintains a per-session style registry, expands tokens when applying to the real DOM, and compresses them back in the compact page state. + +**LLM defines a style:** +```jsonl +{"op":"style","name":"sticker-badge","class":"bg-white border-4 border-[#0a0a0a] px-4 py-2 font-black"} +``` + +**LLM uses it in patches:** +```jsonl +{"op":"patches","patches":[{"selector":"#stats-row","append":"
NEW
"}]} +``` + +**LLM restyles (same name, new class):** +```jsonl +{"op":"style","name":"sticker-badge","class":"bg-zinc-900 text-white border-2 border-zinc-700 px-4 py-2 font-mono"} +``` + +**Prompt sections:** +``` +[STYLES] +$sticker-badge = bg-white border-4 border-[#0a0a0a] px-4 py-2 font-black +$card-shadow = border-4 border-[#0a0a0a] shadow-[8px_8px_0px_0px_#0a0a0a] + +[PAGE STATE] +
120+ ATTENDEES
+
FREE SNACKS
+``` + +**Why LLM-controlled > backend extraction:** +- LLM knows semantic intent ("badge style" vs "classes that happen to overlap") +- Restyle works exactly like component restyle — redefine the name +- No fragile clustering heuristics or Jaccard thresholds +- Styles persist in `[STYLES]` like `[COMPONENTS]` — LLM reuses across requests +- Follows established pattern, lower cognitive overhead for the model + +**Implementation:** +- Add `StyleRegistry` (per-session `Map`) alongside component registry +- New op handler: `{"op":"style"}` → register/update style in registry +- **Expand on write:** When applying patches to the VDOM, expand `$token` references to full class strings (real DOM always has full classes for Tailwind to work) +- **Compress on read:** In `getCompactHtmlFromCtx()`, scan class attributes and substitute known style strings back to `$token` references +- Emit `[STYLES]` section in prompt (before `[PAGE STATE]`, after `[COMPONENTS]`) +- Add system prompt section explaining the style op +- Persist styles in session snapshots alongside component registry + +**Matching strategy for compress-on-read:** +- Exact substring match: if an element's class contains the full style string, replace it with `$name` and keep remaining classes +- Longest match first to avoid partial substitutions + +**Complexity:** Medium. Mirrors existing component registry infra. + +**Token savings estimate:** 20-40% of class-heavy pages. Compounds with component usage (components handle structure, styles handle appearance). + +--- + +### 2. Static Element Elision + +**Idea:** Elements marked `data-static` (or auto-detected as non-interactive + never patched) get replaced with a placeholder comment in the page state. + +**Before:** +```html +
+
+``` + +**After:** +```html + + +``` + +**Detection heuristics:** +- Has `pointer-events-none` class +- No `id`, `data-action`, or interactive children +- Never targeted by a patch in the session +- Pure decorative (blur, gradient, overlay patterns) + +**Implementation:** +- Track which element IDs have been patched (already have this in VDOM) +- During compact HTML generation, collapse qualifying subtrees to comments +- If LLM needs the full element, `get_page_state` could have a `full: true` option + +**Complexity:** Low-medium. Heuristic-based, low risk. + +**Token savings estimate:** 10-25% depending on decoration density. + +--- + +### 3. Auto-componentisation Suggestions + +**Idea:** Detect repeated HTML structures and prompt the LLM to define components for them. + +The stats row in the example has 9 nearly-identical badge elements that could be a `` component. If componentised: + +**Before (9 badges, ~900 chars):** +```html +
120+ ATTENDEES
+
FREE SNACKS
+... +``` + +**After (~300 chars):** +```html + + +... +``` + +**Implementation options:** +- A) **Prompt-level hint**: Add a system prompt instruction: "When you notice 3+ similar elements, define a component first" +- B) **Backend detection**: After generation, detect repeated structures and suggest componentisation in the next corrective/follow-up prompt +- C) **Aggressive**: Auto-extract components from repeated DOM patterns server-side + +Option A is cheapest and already partially covered by the COMPONENTS prompt section. Could strengthen the instruction. + +**Complexity:** A = trivial, B = medium, C = high. + +**Token savings estimate:** 30-60% for repetitive UIs (lists, grids, card layouts). + +--- + +### 4. Attribute Minimisation + +**Idea:** Strip attributes the LLM doesn't need to see from the compact page state. + +Candidates for stripping: +- `class` on elements the LLM isn't currently restyling (risky — LLM may need context) +- `style` attributes that duplicate what's in a component template +- `draggable="true"`, `data-drag-item`, `data-drop-zone` (DnD plumbing) +- Redundant `data-action-data` when the action context is clear from the element + +**Conservative approach:** Only strip attributes on elements inside registered components (template provides the style context). + +**Complexity:** Low. Post-process the compact HTML with attribute whitelist per context. + +**Token savings estimate:** 10-15%. + +--- + +### 5. Two-Tier Page State + +**Idea:** Default prompt gets a structural skeleton; full HTML available via `get_page_state`. + +**Skeleton view:** +``` +[PAGE STATE (skeleton)] +#root > .min-h-screen + + #landing > .max-w-3xl + #hero > header + h1 "EMBRACE:AI // 2026.02" + #stats-row (9 stat badges) + #main-grid > .grid.md:grid-cols-5 + #agenda-col (7 agenda-row items) + #sidebar (4 embrace-card items) + #speakers-col (2 speaker-bio items) + #footer (rsvp-btn, spots-left) + (5 sardine-sticker, 5 lisbon-sticker, 1 azulejo-tile) [decorative] +``` + +**When the LLM needs detail:** Call `get_page_state` or `get_element(selector)` for the specific subtree. + +**Implementation:** +- Build a tree summariser that outputs indented structure with element counts +- Show IDs, component tags, text content snippets, and child counts +- Add a `get_element` tool that returns HTML for a specific subtree + +**Complexity:** Medium-high. Requires the LLM to learn a new interaction pattern. + +**Token savings estimate:** 60-80% on large pages, but adds latency for tool calls. + +--- + +### 6. Incremental Page State + +**Idea:** After the first request, only send the diff from the last known state. + +**First request:** Full `[PAGE STATE]` +**Subsequent requests:** +``` +[PAGE STATE delta since last response] +Modified: #stat-claps (text: "420" → "421") +Added: #new-card under #sidebar +Removed: #old-banner +Unchanged: 47 elements +``` + +**Implementation:** +- Track a "last-sent HTML" snapshot per session +- Diff against current VDOM before building prompt +- Fall back to full state every N requests or when delta > 50% of full + +**Complexity:** Medium. Need a robust HTML differ. + +**Token savings estimate:** 50-90% for action-heavy sessions (most elements unchanged). + +--- + +## Recommendation: Phased Approach + +### Phase 1 — LLM-controlled style registry (#1) +Highest impact-to-effort ratio. Mirrors the proven component registry pattern, so existing infra applies. Compounds with components: components handle structure, styles handle appearance. The LLM already understands the define/reuse pattern. + +### Phase 2 — Static elision + componentisation nudge (#2, #3A) +Quick wins on top of the style registry. Heuristic elision for decorative elements, stronger prompt nudge for componentising repeated structures. + +### Phase 3 — Two-tier / incremental state (#5, #6) +Ambitious but highest ceiling. Only worth pursuing if Phase 1+2 don't bring page state under a target threshold (e.g. < 2K tokens). + +--- + +## Metrics to Track + +- `pageStateTokens`: token count of `[PAGE STATE]` section per request +- `componentRatio`: % of page elements that are component instances +- `staticElementCount`: elements eligible for elision +- `classRepetitionScore`: how much class dedup would save diff --git a/docs/plans/STATE-SEPARATION.md b/docs/plans/STATE-SEPARATION.md new file mode 100644 index 0000000..812d699 --- /dev/null +++ b/docs/plans/STATE-SEPARATION.md @@ -0,0 +1,614 @@ +# State Separation from HTML + +## Problem + +Today, **all state is embedded in the HTML**. Counters, names, prices, statuses — everything lives as text content, attribute values, or form input values inside the DOM. The LLM sees a single `[PAGE STATE]` blob where data and presentation are interleaved. + +This causes three problems: + +1. **Poor caching** — When the user clicks "increment", the entire prompt changes (HTML contains the new count). The system prompt and HTML structure haven't changed, but because state is inline, the LLM provider can't cache the prefix. If state were a separate section *after* the stable HTML, the HTML portion would be a cache hit. + +2. **Hallucinations / data drift** — The LLM sees `€42.99` and must preserve that exact value when restyling. But it's just text in a sea of HTML — easy to accidentally replace with invented data. A clearly separated `[STATE]` section makes data values explicit and harder to accidentally mutate. + +3. **Bloated context** — State is duplicated: once in component props (``) and again in the rendered template output. Even compact HTML doesn't fix this for non-component elements. + +--- + +## Future Constraints + +Any solution must be extensible toward two planned features: + +### 1. Framework Agnosticism (React, Vue, etc.) +Today we use vanilla HTML patches with happy-dom VDOM sync. In the future, the gen-UI should be embeddable as React components inside a React app. This means: +- State must exist **independent of the DOM** — React components are functions of state, not of DOM mutations +- Components must map to React components (props → React props, template → JSX-equivalent) +- Server-side React rendering replaces or wraps happy-dom +- Patches may become unnecessary for state-bound components (React re-renders from state) + +### 2. Multi-Page Support +The gen-UI should support internal navigation — links to pages that are also generated. This means: +- State must be **scopeable** — global state (cart, user) vs page-local state (form fields, filters) +- Components must be **shareable** across pages (define once, use on any page) +- Navigation is a state change, not a full page rebuild + +Both features require state to be a **first-class concept independent of the DOM**. + +--- + +## Current Architecture + +``` +[COMPONENTS] ← structure templates (cached across requests) +[PAGE STATE] ← compact HTML with ALL state inline +[RELEVANT CONTEXT] ← memory/RAG +[RECENT CHANGES] ← last N changes +[NOW] ← current actions +``` + +State lives in: +- Element text content: `42` +- Attributes: `
` +- Component props: `` +- Form input values: `` + +Components already *partially* separate state (props) from presentation (template), but only in the compact HTML view and only for componentised elements. + +--- + +## Hard Constraint: No Code Execution + +The template language **must not** contain executable code. No `vm.runInNewContext()`, no `new Function()`, no expression evaluators. Reasons: +- **Security** — prompt injection could craft expressions that escape any sandbox +- **Design principle** — the LLM generates data and declarative structure, never code +- **Library landscape** — safe expression evaluators like `jexl` are unmaintained and use non-JS syntax + +This means: **no computed expressions in templates.** Every value shown in the UI must exist as an explicit key in the state store. The LLM maintains all derived values (counts, totals, summaries) manually in state. + +--- + +## Approach A: Render Template + State Store + +### Core Idea + +The LLM emits a **render template** (HTML with declarative iteration/conditionals) alongside a **state store** (structured JSON). The backend evaluates the template against the state to produce full HTML for the VDOM. The prompt shows both separately — the template is stable across state-only changes (cacheable), while state changes on every action. + +This is `UI = f(state)`. The template is `f`, the state store is the data. **The LLM is the runtime** — it reads state, updates it, and the system derives the DOM. + +### Template Language: Two Options + +Since no code execution is allowed, the template language is limited to: **substitution**, **iteration**, and **conditional visibility**. Two syntax options are viable: + +#### Vue-style Directives + +```html +
+

{title}

+
+ +
+

{summary}

+

No todos yet

+ +
+``` + +- `{key}` — substitute from state (dot access: `{user.name}`) +- `c-for='t of arrayKey'` — iterate: clone element per array item, `t` is the loop variable +- `:prop='t.field'` — bind attribute to loop variable (or state key) +- `c-if='boolKey'` — conditional: show only when state key is truthy + +#### Minimal Attributes + +```html +
+

{title}

+
+ +
+

{summary}

+

No todos yet

+ +
+``` + +- `{key}` — substitute from state +- `each='arrayKey'` — iterate, `{$.field}` references current item +- `if='boolKey'` — conditional visibility + +#### Comparison + +| | Vue Directives | Minimal Attrs | +|---|---|---| +| **LLM familiarity** | ⭐⭐⭐⭐ Known from Vue training data | ⭐⭐⭐⭐⭐ Plain HTML, trivial | +| **Parsing** | ⭐⭐⭐⭐ HTML parser + extract `c-for`/`c-if`/`:` attrs | ⭐⭐⭐⭐⭐ HTML parser + extract `each`/`if` attrs | +| **Named loop variable** | ✅ `c-for='t of todos'` → `t.text` | ❌ Always `$` → `{$.text}` | +| **Nested iteration** | ✅ Inner loop uses different variable name | ⚠️ `$` always refers to innermost — ambiguous for nested | +| **Bound vs static attrs** | ✅ `:text='t.text'` (bound) vs `class='...'` (static) — explicit | ❌ `text='{$.text}'` — must scan all attrs for `{...}` | +| **React mapping** | ⭐⭐⭐⭐ `c-for` → `.map()`, `c-if` → `&&`, `:prop` → `{expr}` | ⭐⭐⭐ `each` → `.map()`, `if` → `&&`, string scan for bindings | +| **Vue mapping** | ⭐⭐⭐⭐⭐ Rename `c-` → `v-`, done | ⭐⭐⭐ Need structural conversion | +| **Complexity** | ~250 lines | ~150 lines | + +**Vue directives are better for the long term** — named loop variables handle nesting, the bound-vs-static distinction is explicit (`:text` vs `text`), and the mapping to Vue/React is cleaner. The extra ~100 lines of implementation is worth it. + +**Minimal attrs are better as a stepping stone** — simpler to build, no new syntax concepts. Could ship first and upgrade to Vue directives later if nesting becomes an issue. + +### What the LLM Emits + +**Initial generation — "create a todo app":** +```jsonl +{"op":"define","tag":"todo-item","props":["id","text","done"],"template":"
  • {text}
  • "} +{"op":"state","set":{"title":"My Todos","todos":[],"summary":"0 items","isEmpty":true}} +{"op":"render","html":"

    {title}

    {summary}

    No todos yet

    "} +``` + +Three ops working together: +- `define` — component templates (unchanged from today) +- `state` — structured data (new) +- `render` — template with declarative iteration/conditionals (new) + +**User clicks "Add":** +```jsonl +{"op":"state","set":{"todos":[{"id":"1","text":"New task","done":false}],"summary":"1 item","isEmpty":false}} +``` + +Only state changes. Template unchanged → system re-evaluates → diffs → sends patches to frontend. + +**User toggles todo:** +```jsonl +{"op":"state","set":{"todos":[{"id":"1","text":"New task","done":true}],"summary":"1 item, 1 done","isEmpty":false}} +``` + +**User says "make it dark mode":** +```jsonl +{"op":"render","html":"
    ...same structure, dark classes...
    "} +{"op":"define","tag":"todo-item","props":["id","text","done"],"template":"
  • ...dark styles...
  • "} +``` + +Template and component defs change, state stays the same. Data preserved by construction. + +### Prompt Structure + +``` +[COMPONENTS] ← cached (stable across state-only actions) + — leaf + template:
  • ...
  • + +[RENDER] ← cached (stable across state-only actions) +
    +

    {title}

    +
    + +
    +

    {summary}

    +

    No todos yet

    + +
    + +[STATE] ← volatile (changes on every action) +title: "My Todos" +todos: [{"id":"1","text":"Buy milk","done":false}, {"id":"2","text":"Walk dog","done":true}] +summary: "1 of 2 remaining" +isEmpty: false + +[NOW] +1. toggle [checkbox#cb-1 → todo-item#todo-1] {"id":"1"} +``` + +**Cache ordering**: `[COMPONENTS]` + `[RENDER]` form the prefix. For state-only actions (the most common case: toggle, add, delete, filter), the prefix is unchanged → cache hit. Only `[STATE]` + `[NOW]` are fresh. + +### Derived Values + +The LLM maintains all derived values manually in state. The template only substitutes — no computation. + +```jsonl +{"op":"state","set":{ + "todos": [{"id":"1","text":"Buy milk","done":true}, {"id":"2","text":"Walk dog","done":false}], + "summary": "1 of 2 remaining", + "isEmpty": false, + "progress": 50 +}} +``` + +Template: +```html +

    {summary}

    +
    +``` + +If the LLM forgets to update `summary` when toggling a todo, the UI shows stale data. This is a trade-off we accept — the LLM is the runtime. The corrective prompt for the next action will show the stale `[STATE]`, giving the LLM a chance to fix it. + +--- + +## How It Works: Concrete Pipeline + +### 1. Template Evaluation (render template + state → full HTML) + +The backend stores three things per session: +- **Component registry**: `Map` (existing) +- **Render template**: `string` (the template HTML with directives) +- **State store**: `Record` (structured JSON) + +**Evaluation algorithm:** + +``` +Input: template string + state object +Output: plain HTML string (no directives, no {key} placeholders) + +1. Parse template as HTML DOM (happy-dom) +2. Process c-if / if: + - Find all elements with c-if attribute + - Read the attribute value (a state key name, e.g. "isEmpty") + - Look up state[key] — if falsy, remove the element from DOM +3. Process c-for / each: + - Find all elements with c-for attribute + - Parse "t of todos" → variable name "t", array key "todos" + - Look up state["todos"] → array of objects + - For each item in array: + - Clone the element + - Remove the c-for attribute from clone + - Substitute :attr='t.field' → attr='value' (resolve t.field from item) + - Substitute {t.field} in text content and static attributes + - Generate unique id: 'todo-{t.id}' → 'todo-1' + - Replace original element with the N clones +4. Process remaining {key} substitutions: + - Walk all text nodes and attribute values + - Replace {key} with state[key] (supports dot access: {user.name}) +5. Render component instances (existing pipeline): + - Find custom elements, interpolate templates with props +6. Serialize DOM to HTML string +``` + +**Result:** A plain HTML string, identical to what today's patch pipeline produces. No directives remain. This HTML is applied to the session's happy-dom VDOM. + +### 2. Patch Validation (how to verify patches against the template) + +Today, the patch validator clones the VDOM, applies patches, and checks that target elements exist. With the template approach, the VDOM always contains **fully expanded HTML** (the result of template evaluation). Patches target this expanded HTML, same as today. + +**Flow on state-only change:** + +``` +1. LLM emits: {"op":"state","set":{...}} +2. Backend updates state store +3. Backend re-evaluates template with new state → new full HTML +4. Backend diffs new HTML against current VDOM → generates patches +5. Backend validates generated patches (same validator as today) +6. Backend sends patches to frontend via SSE +``` + +The LLM doesn't emit patches for state changes — the system generates them by diffing. Patch validation is the same as today because the VDOM always contains concrete HTML. + +**Flow on render template change:** + +``` +1. LLM emits: {"op":"render","html":"...new template..."} +2. Backend stores new template +3. Backend evaluates new template with current state → new full HTML +4. Backend diffs new HTML against current VDOM → generates patches +5. Backend validates + sends patches to frontend +``` + +Again, the LLM doesn't emit patches. The system diffs. + +**Flow when LLM emits patches directly (escape hatch):** + +The LLM can still emit `{"op":"patches",...}` for targeted micro-updates. These patches target the expanded VDOM, validated the same way as today. The backend then updates the VDOM but also needs to keep the template + state in sync — this is the tricky case. Options: +- a) Disallow direct patches when a template is active (simplest) +- b) Apply patches to VDOM but mark the template as "dirty" — next state change re-evaluates from template, overwriting manual patches +- c) Reverse-engineer state changes from patches (complex, fragile) + +**Recommendation: option (a) for now.** When a render template exists, all UI changes go through state or render ops. Patches are only allowed when no template is active (backward compatible with existing sessions). + +### 3. VDOM Diffing with diff-dom + +Given two HTML states (before and after), produce the minimal diff to transform one into the other. We use [`diff-dom`](https://github.com/fiduswriter/diffDOM) (npm: `diff-dom`, LGPL-3.0, actively maintained) on **both backend and frontend** — no format conversion needed. + +**Why diff-dom:** +- Works with HTML strings via `stringToObj()` — no browser DOM required, works in Node.js +- Can apply diffs to both virtual objects (backend) and real DOM (frontend) +- Non-destructive: prefers relocations over remove-then-insert (better for animated UIs) +- Diffs are JSON-serializable — send directly over SSE +- Route-based patching (index arrays) is faster than selector-based (`querySelector`) + +**diff-dom uses route arrays, not CSS selectors.** A route like `[0, 1, 3]` means "root → first child → second child → fourth child". This is direct traversal — O(depth), no DOM search. Converting routes to our `#id`-based selectors would be wasteful computation. Instead, we **use diff-dom's native format directly**. + +**Two patch formats, two modes:** + +| Mode | Who generates patches | Format | Frontend applies with | +|---|---|---|---| +| **Template active** (Approach A) | System via diff-dom | diff-dom route-based diffs | `dd.apply(rootEl, diffs)` | +| **No template** (legacy/backward compat) | LLM | Our selector-based patches | Existing `applyPatch()` | + +A session is either template-based or legacy — the formats never mix within a session. The frontend checks the SSE event type and routes to the right applicator. + +**Backend flow (state change):** + +```typescript +import { DiffDOM, stringToObj } from "diff-dom"; + +const dd = new DiffDOM({ valueDiffing: true }); + +// 1. Re-evaluate template with new state → new HTML string +const newHtml = evaluateTemplate(template, newState); + +// 2. Diff against current VDOM HTML +const oldObj = stringToObj(currentHtml); +const newObj = stringToObj(newHtml); +const diffs = dd.diff(oldObj, newObj); + +// 3. Apply to backend VDOM (happy-dom) — keeps VDOM in sync +dd.apply(vdomRoot, diffs); + +// 4. Send raw diffs to frontend via SSE (JSON-serializable, no conversion) +stream.emit({ type: "diff", diffs }); +``` + +**Frontend flow:** + +```typescript +import { DiffDOM } from "diff-dom"; + +const dd = new DiffDOM(); + +eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.type === "diff") { + // Template-based session: apply diff-dom diffs directly + dd.apply(document.getElementById("root"), data.diffs); + } else if (data.type === "patch") { + // Legacy session: apply selector-based patches (existing code) + applyPatch(data.patch); + } +}; +``` + +**No translation layer, no route-to-selector conversion, no coalescing.** diff-dom produces the diffs, diff-dom applies them. We're just a transport layer in between. + +**diff-dom operation types (for reference):** + +| Action | What it does | +|---|---| +| `addAttribute`, `modifyAttribute`, `removeAttribute` | Attribute changes | +| `modifyTextElement`, `addTextElement`, `removeTextElement` | Text node changes | +| `addElement`, `removeElement`, `replaceElement` | Element add/remove/replace | +| `modifyValue`, `modifyChecked`, `modifySelected` | Form state changes | +| `relocateGroup` | Move a group of nodes (drag & drop, reorder) | + +### 4. Prompt Construction (VDOM + state store → prompt) + +Today, `getCompactHtmlFromCtx()` strips component template interiors from the VDOM to produce compact HTML for the prompt. With the template approach, we don't need to derive compact HTML from the VDOM at all — **the template IS the compact representation**. + +**Current flow:** +``` +VDOM (full HTML) → getCompactHtmlFromCtx() → [PAGE STATE] in prompt +``` + +**New flow:** +``` +Stored template → [RENDER] in prompt (verbatim) +Stored state → [STATE] in prompt (JSON.stringify) +``` + +No VDOM-to-compact conversion needed. The template is already compact (it has directives like `c-for` instead of expanded elements). The state is already structured JSON. Both are stored directly and inserted into the prompt. + +**Building the [RENDER] section:** +```typescript +const renderSection = template + ? `[RENDER]\n${template}` + : `[RENDER]\nEmpty — no template defined yet.`; +``` + +**Building the [STATE] section:** +```typescript +const stateSection = Object.keys(state).length > 0 + ? `[STATE]\n${Object.entries(state).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join('\n')}` + : `[STATE]\nEmpty`; +``` + +**Full prompt assembly:** +``` +System prompt (static) +[COMPONENTS] — component catalog (from registry, same as today) +[RENDER] — stored template (verbatim) +[STATE] — stored state (serialised JSON) +[RELEVANT CONTEXT] — memory/RAG (same as today) +[RECENT CHANGES] — same as today +[NOW] — current actions (same as today) +``` + +### 5. State Storage & Persistence + +- **Backend**: State store per session — `Record` in a `Ref`, alongside component registry +- **Template**: Stored per session as a `string` in a `Ref` +- **DB snapshots**: State + template persisted alongside component registry and HTML +- **Session recovery**: Restore state, template, and registry. Re-evaluate template with state to reconstruct VDOM. +- **Frontend**: Receives patches via SSE (same as today). Optionally receives state ops for devtools / future React integration. + +### 6. Frontend Handling + +**Today (vanilla HTML):** +- Frontend receives **patches** via SSE (system-generated from diff, not LLM-generated) +- Frontend applies patches to DOM (same as today) +- Frontend optionally stores state ops for devtools +- Frontend does NOT evaluate templates — it only applies patches + +**Future (React):** +- Frontend receives **state ops** via SSE +- Maps state to React component props +- Component definitions → React components +- State change → React re-render (no patches needed) +- Template becomes the React component tree definition + +--- + +## How It Maps to React / Vue / Solid + +### The Template IS a Component Tree + +The render template describes the same thing as a React component's render function — just in a different syntax: + +**Cuttlekit template:** +```html +
    +

    {title}

    +
    + +
    +

    No todos yet

    +

    {summary}

    +
    +``` + +**React equivalent:** +```jsx +function App({ title, todos, isEmpty, summary }) { + return ( +
    +

    {title}

    +
    + {todos.map(t => )} +
    + {isEmpty &&

    No todos yet

    } +

    {summary}

    +
    + ); +} +``` + +**Vue equivalent:** +```html +
    +

    {{ title }}

    +
    + +
    +

    No todos yet

    +

    {{ summary }}

    +
    +``` + +The conversion is mechanical: + +| Cuttlekit | React | Vue | +|---|---|---| +| `c-for='t of todos'` | `{todos.map(t => ...)}` | `v-for='t of todos'` | +| `c-if='isEmpty'` | `{isEmpty && ...}` | `v-if='isEmpty'` | +| `:text='t.text'` | `text={t.text}` | `:text='t.text'` | +| `{title}` | `{title}` | `{{ title }}` | +| `{"op":"state","set":{...}}` | `setState(...)` | `store.commit(...)` | +| `{"op":"define","tag":"todo-item",...}` | `function TodoItem(props) {...}` | `Vue.component('todo-item', {...})` | + +### Migration Path + +1. **Today**: Backend evaluates template + state → HTML → happy-dom VDOM → diff → patches → vanilla frontend +2. **React**: Backend sends state ops to React frontend → React evaluates component tree → React reconciler handles DOM → no patches needed +3. **The template definition migrates from backend-evaluated to frontend-evaluated** — same structure, different runtime + +The state store maps directly to React/Vue state management (useState, Vuex, Zustand, etc.). No extraction needed — the state is already a structured JSON object. + +--- + +## Approach B: Aggressive Componentisation + +### Core Idea + +Don't build new infrastructure. Lean harder into the existing component system. Components already separate state (props) from presentation (template). The compact HTML already strips templates, showing only ``. If *everything* is a component — including one-off elements like headers, counters, buttons — then the compact HTML becomes a clean, minimal representation where all styling lives in `[COMPONENTS]` (cached) and all data lives in component props (in the compact HTML). + +### What "Aggressive" Means + +Today, the system prompt says: "When creating 3+ similar elements, ALWAYS define a component first." + +Aggressive componentisation changes this to: "Define a component for ANY element that has significant styling (3+ Tailwind classes) or holds data. Even one-off elements." + +**After aggressive componentisation:** +``` +[COMPONENTS] + — leaf + template:

    {text}

    + + — leaf + template:
    {label}
    + + — leaf + template: {text} + +[PAGE STATE] (compact HTML) +
    + +
    + + + +
    + +
    +``` + +All styling in `[COMPONENTS]` (cached). Compact HTML is almost pure data + structure. + +### Component Granularity + +**Make it a component when:** +- Element has 3+ Tailwind classes (styling bloat) +- Element pattern repeats 2+ times (structural repetition) +- Element holds data the LLM must preserve (data fidelity) + +**Keep it inline when:** +- Simple structural wrapper (`
    `) with 0-2 classes +- Pure layout element (`
    `) with no data + +Realistic page: ~9 component definitions, ~37 instances. ~450-900 tokens in `[COMPONENTS]`. Very manageable. + +### What It Does NOT Solve + +1. **Data is still in the HTML.** Props change on every action → `[PAGE STATE]` never fully stable for caching. +2. **No explicit data model.** LLM infers lists from individual instances. After 20 ops, drift accumulates. +3. **Multi-page state loss.** Navigate away → data gone from DOM, no store to reconstruct from. +4. **React mapping requires extraction.** Must reverse-engineer state from component instance props. + +--- + +## Comparative Analysis + +### Summary + +| | Caching | Data Fidelity | Impl. Complexity | LLM Reliability | React Future | Multi-Page | +|---|---|---|---|---|---|---| +| **A: Render + State** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | +| **B: Aggressive Components** | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ | + +### Where Each Shines + +**Approach A shines when:** +- Pages are data-heavy (dashboards, lists, forms, e-commerce) +- Users trigger many state-changing actions (high cache value) +- Data fidelity is critical (prices, quantities, names must never drift) +- Multi-page or React migration is planned + +**Approach B shines when:** +- Pages are presentation-heavy (landing pages, marketing, creative layouts) +- Users mostly restyle and iterate on design +- The UI is simple with few data interactions +- Speed of implementation matters — works today with a prompt change + +--- + +## Path Forward + +These are not mutually exclusive. They can be sequenced: + +1. **Now:** Approach B — update system prompt to encourage aggressive componentisation. Immediate token savings, no code changes. + +2. **Next:** Build Approach A. Template evaluator (~200 lines), state store, VDOM differ (~250 lines), new op types. Start with minimal attributes (`each`/`if`), upgrade to Vue directives if nesting becomes an issue. + +3. **Later:** Roll out A alongside B. Components handle styling. Template handles structure and iteration. State handles data. Three-way separation: + +``` +[COMPONENTS] ← styling (component templates) +[RENDER] ← structure (template with c-for/c-if) +[STATE] ← data (structured JSON) +``` + +Components handle styling. Template handles structure. State handles data. Clean separation of all three concerns. diff --git a/packages/common/src/patch.ts b/packages/common/src/patch.ts index fd28d56..cb130f5 100644 --- a/packages/common/src/patch.ts +++ b/packages/common/src/patch.ts @@ -1,14 +1,17 @@ /** - * Patch types for DOM manipulation. + * Patch type for DOM manipulation. + * Single flat object — all operation fields optional, apply whichever are present. * Used by both frontend (browser DOM) and backend (happy-dom). */ -export type Patch = - | { selector: string; text: string } - | { selector: string; attr: Record } - | { selector: string; append: string } - | { selector: string; prepend: string } - | { selector: string; html: string } - | { selector: string; remove: true }; +export type Patch = { + selector: string; + text?: string; + attr?: Record; + append?: string; + prepend?: string; + html?: string; + remove?: true; +}; export type ApplyPatchResult = | { _tag: "Success" } @@ -22,10 +25,7 @@ const isSimpleIdSelector = (s: string): boolean => /** * Apply a single patch to a document. * Works with both browser DOM and happy-dom. - * - * @param doc - Document or DocumentFragment to apply patch to - * @param patch - The patch to apply - * @returns Result indicating success or failure + * Applies all present fields — attr, then content mutations. */ export const applyPatch = ( doc: Document | DocumentFragment, @@ -52,9 +52,11 @@ export const applyPatch = ( } try { - if ("text" in patch) { - el.textContent = patch.text; - } else if ("attr" in patch) { + if (patch.remove) { + el.remove(); + return { _tag: "Success" }; + } + if (patch.attr) { Object.entries(patch.attr).forEach(([key, value]) => { if (value === null) { el.removeAttribute(key); @@ -62,14 +64,18 @@ export const applyPatch = ( el.setAttribute(key, value); } }); - } else if ("append" in patch) { + } + if (patch.text !== undefined) { + el.textContent = patch.text; + } + if (patch.html !== undefined) { + el.innerHTML = patch.html; + } + if (patch.append) { el.insertAdjacentHTML("beforeend", patch.append); - } else if ("prepend" in patch) { + } + if (patch.prepend) { el.insertAdjacentHTML("afterbegin", patch.prepend); - } else if ("html" in patch) { - el.innerHTML = patch.html; - } else if ("remove" in patch) { - el.remove(); } return { _tag: "Success" }; } catch (e) { @@ -83,10 +89,6 @@ export const applyPatch = ( /** * Apply multiple patches to a document. - * - * @param doc - Document to apply patches to - * @param patches - Array of patches to apply - * @returns Summary of applied patches */ export const applyPatches = ( doc: Document | DocumentFragment, @@ -111,8 +113,8 @@ export const applyPatches = ( * Useful for font/icon loading. */ export const getPatchHtmlContent = (patch: Patch): string | null => { - if ("html" in patch) return patch.html; - if ("append" in patch) return patch.append; - if ("prepend" in patch) return patch.prepend; + if (patch.html) return patch.html; + if (patch.append) return patch.append; + if (patch.prepend) return patch.prepend; return null; };