Most AI presentation tools make the same architectural mistake: they treat slide creation as a full-document replacement problem. When a user asks an LLM to "make this card look cleaner" or "add a four-step process", the model typically regenerates the whole slide HTML or returns an un-editable raster image. If you have spent ten minutes tweaking typography, spacing, and brand colours, watching an AI overwrite your entire canvas is infuriating.
The second failure mode is layout drift. LLMs have no spatial awareness. When asked to insert three columns or expand an explanation, they happily output HTML that overflows viewport boundaries, clips underneath headers, or pushes footers off the screen.
When we built DeckForge (an AI-assisted presentation workspace developed by our two-person team at SaneDevs), we took a different path. We treated presentations as living DOM documents rendered on a fixed 1920x1080 canvas. Instead of letting models regenerate slides from scratch on every prompt, we separated deck creation into two distinct systems: an isolated plain-text layout generator for new slides, and a declarative, client-side Patch Engine for editing. To protect client confidentiality, this portfolio edition runs against a fictional brand kit, sample deck, and local handbook.
Here is how the architecture works, how we solved layout collisions and hallucinations, and the code patterns you can use in your own canvas-based AI apps.
A deck application cannot afford unpredictable DOM rewrites. If an AI touches elements the user did not ask to change, undo stacks get bloated and manual customizations disappear.
We split the AI interaction into two pipelines:
_draft-<id>.html). The user previews it in a sandboxed iframe before approving it into the deck.css, text, attr, class, html, remove).
Once the patches reach the browser, our client-side Patch Engine runs a three-tier gate: it validates CSS against brand tokens, checks selectors, and executes a dry-run geometry pass to ensure no element gets pushed out of view.
When generating a completely new slide, asking an LLM for structured JSON containing thousands of characters of inline HTML almost always breaks. Models struggle with nested escaping (like escaped double quotes inside inline style="" attributes), resulting in JSON parse syntax errors.
In server/slidegen.js, we instruct the model to return plain text with delimiter boundaries instead of JSON:
// In server/slidegen.js
export const PANEL = { width: 1728, height: 772 };
export const SLIDE_SYSTEM_PROMPT = `You create ONE new, fully editable slide as real HTML/CSS.
The slide already exists as an empty template: background, logo, title, subtitle, footer.
You only fill the content panel.
The panel is exactly ${PANEL.width}px wide x ${PANEL.height}px high. Everything must fit inside it.
OUTPUT FORMAT (plain text, NOT JSON, no markdown fences):
TITLE: <title>
SUBTITLE: <subtitle>
SUMMARY: <one sentence layout summary>
SOURCES: <comma-separated passage numbers used, or none>
<<<HTML
<div style="position:relative;width:${PANEL.width}px;height:${PANEL.height}px;...">
<!-- Content elements here -->
</div>
HTML>>>`;
Parsing this on the server avoids JSON parser crashes entirely. We extract the metadata lines with simple regular expressions and isolate the markup between the tags:
// In server/slidegen.js
export function parseSlideText(text) {
const cleanText = String(text)
.replace(/\r\n/g, "\n")
.replace(/<think>[\s\S]*?<\/think>/g, "");
const line = (k) =>
(cleanText.match(new RegExp("^\\s*" + k + ":[ \\t]*(.*)$", "mi")) || [])[1] || "";
const open = cleanText.search(/<<<\s*HTML/i);
if (open < 0) {
throw httpError("The AI returned an unreadable response. Please try again.", 502);
}
let html = cleanText.slice(open).replace(/^<<<\s*HTML[ \t]*\n?/i, "");
const end = html.search(/HTML\s*>>>/i);
html = (end >= 0 ? html.slice(0, end) : html)
.replace(/^```
(?:html)?\s*/i, "")
.replace(/\s*
```\s*$/, "")
.trim();
const sources = (line("SOURCES").match(/\d+/g) || [])
.map(Number)
.filter((n) => n > 0 && n < 100);
return {
title: line("TITLE"),
subtitle: line("SUBTITLE"),
summary: line("SUMMARY"),
sources,
body_html: html,
};
}
Before this draft ever touches the deck, assertSafeBody(html) rejects <script>, <style>, <iframe>, javascript:, and inline event attributes like onclick. The draft is written to a temporary disk path (_draft-<id>.html) where the user can inspect it safely before committing it.
For editing an existing slide, we avoid regenerating HTML. In server/actions.js, the system prompt restricts the AI to returning a strictly typed JSON object containing an array of atomic changes.
In src/ai/patchSchema.ts, we defined the contract for what the AI can propose:
// In src/ai/patchSchema.ts
export interface PatchTarget {
selector: string; // CSS selector, or "@selected" for the focused element
all?: boolean;
}
export interface CssPatch {
type: "css";
target: PatchTarget;
changes: Record<string, string>; // e.g. { "color": "brand.color.navy" }
}
export interface TextPatch {
type: "text";
target: PatchTarget;
text: string;
}
export interface HtmlPatch {
type: "html";
target: PatchTarget;
position: "inner" | "outer" | "before" | "after" | "append" | "prepend";
html: string;
}
export type Patch = CssPatch | TextPatch | AttrPatch | ClassPatch | HtmlPatch | RemovePatch;
When the browser receives these patches, src/ai/patchEngine.ts parses and validates each item against the live DOM document inside the slide iframe.
Notice the value in the CssPatch example: "brand.color.navy". The LLM is never given hardcoded hex codes. Instead, it references design tokens from the brand kit. The Patch Engine resolves brand.* tokens into valid CSS color strings, checks that property names are safe (blocking dangerous properties like behavior or -webkit-user-modify), and sanitizes any HTML fragments using a browser <template> parser.
If a patch contains five valid property changes and one invalid property, the engine applies the five good ones and logs a warning for the bad one, rather than failing the whole user action.
The biggest issue with AI-applied CSS is element collision. An AI might suggest changing a card font size from 18px to 32px or increasing padding to 60px. On a fixed 1920x1080 slide, that extra height pushes the card below the bottom margin.
Static analysis cannot tell you if an element will overflow because flexbox, grid, and inline text wrapping depend on browser layout calculations.
To solve this, our Patch Engine performs a synchronous dry-run inside src/ai/patchEngine.ts:
getBoundingClientRect()) of all target elements. style and finally block, it restores the original attributes, guaranteeing zero unintended visual side-effects.
Here is the actual implementation from src/ai/patchEngine.ts:
// In src/ai/patchEngine.ts
function checkGeometry(doc: Document, patches: Patch[]): Map<number, string> {
const problems = new Map<number, string>();
const W = doc.documentElement.clientWidth || 1920;
const H = doc.documentElement.clientHeight || 1080;
const TOL = 2; // pixel tolerance for sub-pixel anti-aliasing
const box = (el: Element): Box | null => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0
? { l: r.left, t: r.top, r: r.right, b: r.bottom }
: null;
};
const inside = (b: Box) =>
b.l >= -TOL && b.t >= -TOL && b.r <= W + TOL && b.b <= H + TOL;
const styleLevel = patches
.map((p, i) => ({ p, i }))
.filter(({ p }) => p.type === "css" || p.type === "attr" || p.type === "class");
const perPatch = styleLevel.map(({ p, i }) => ({
i,
p,
els: queryTargets(doc, p.target, null, false).els,
}));
// 1. Snapshot original attributes and bounding boxes
const touched = new Map<Element, Map<string, string | null>>();
for (const { p, els } of perPatch) {
for (const el of els) {
const saved = touched.get(el) ?? new Map<string, string | null>();
const names = ["style", "class", ...(p.type === "attr" ? Object.keys(p.attributes) : [])];
for (const n of names) if (!saved.has(n)) saved.set(n, el.getAttribute(n));
touched.set(el, saved);
}
}
const before = new Map<Element, Box | null>();
touched.forEach((_, el) => before.set(el, box(el)));
try {
// 2. Synchronous test application
for (const { p } of perPatch) applyOne(doc, p);
// 3. Verify elements stay inside slide canvas
for (const { i, p, els } of perPatch) {
for (const el of els) {
const b0 = before.get(el);
const b1 = box(el);
if (b0 && inside(b0) && b1 && !inside(b1)) {
const name = p.target.selector.split(">").slice(-1)[0].trim();
problems.set(i, `it would push ${name} off the edge of the slide`);
break;
}
}
}
} finally {
// 4. Guaranteed rollback
touched.forEach((attrs, el) =>
attrs.forEach((v, n) => (v === null ? el.removeAttribute(n) : el.setAttribute(n, v)))
);
}
return problems;
}
This simple dry-run check eliminates 90% of layout-breaking AI edits before the user ever sees them.
Slide copy often requires real organizational context: architecture tiers, release milestones, or team ownership. If an AI does not know the answer, default model behavior is to hallucinate plausible-sounding fictional facts.
In server/knowledge/context.js, we feed relevant documentation chunks into the prompt, numbered sequentially (e.g. [1], [2]), and enforce a strict rule: if a fact is not present in the numbered passages, the AI must emit a short bracketed placeholder like [confirm owner] or [insert rollout date]:
// In server/knowledge/context.js
export const KNOWLEDGE_RULES = `RULES FOR FACTS:
- Facts, names, numbers and dates must come from the company profile or numbered passages above. Never invent them.
- If something the slide needs is not there, write a short placeholder in square brackets, for example [add the owner], instead of guessing.`;
When the response arrives, the server scans the slide markup for these placeholders and passes them to the frontend:
// In server/knowledge/context.js
export function placeholdersIn(html) {
const text = String(html || "").replace(/<[^>]+>/g, " ");
return [
...new Set(
[...text.matchAll(/\[([^\[\]\n]{3,60})\]/g)]
.map((m) => m[1].trim())
.filter(
(t) =>
/^(add|insert|enter|name|number|date|owner|tbd|your|list|describe|specify|confirm|check)\b/i.test(t) ||
/\b(missing|unknown|not found)\b/i.test(t)
)
),
].slice(0, 8);
}
In the UI, these placeholders render as clear warning badges. Users know immediately which facts were grounded in internal documentation and which specific fields still require human verification. Furthermore, by parsing SOURCES: 1, 3, the editor displays clickable source breadcrumbs linking back to the exact reference documentation.
Building this revealed several edge cases that standard web apps never encounter:
html2canvas visual drift during export:server/index.js using Playwright. The browser sends the live DOM (with scripts stripped), Playwright injects it into a 1920x1080 viewport, waits for document.fonts.ready and all image elements to complete, and renders an exact PNG screenshot.\\"style=\\"..."\\"). In server/slidegen.js, we implemented an unescaping normalizer that detects and cleans escaped quote sequences before HTML validation runs.gen-<name>.html once the user explicitly clicks "Approve".style="..." attributes and banned <style> blocks. While this made individual CSS properties straightforward to patch with query selectors, it made shared element styling verbose. In future iterations, generating scoped CSS custom properties on parent containers would reduce token counts significantly.try...finally block to catch overflow regressions before committing AI changes.<<<HTML ... HTML>>>) eliminate parse failures.[confirm owner] rather than hallucinating plausible organizational facts, and detect those brackets with regex to alert the user.
If you are building canvas or document-based AI tools, how are you handling layout regression and keeping LLM edits scoped to single elements?
The full write-up of this project is on our site: https://sanedevs.com/projects/deckforge.html