A web page with live slide previews
Build the browser side: read NDJSON progress, draw each slide from its render plan as scalable HTML, escape model text to prevent XSS, and download the finished .pptx.
The last missing piece is the one users see. The page sends the brief, reads progress events as they stream in, draws each slide the moment it arrives, and offers the file at the end.
Drawing a slide from its plan
The render plan already has every position. public/preview.js turns each element into an absolutely positioned <div>, the same way the Slides tab in these exercises does:
{ type: "text",
x: 72, y: 40.5,
w: 360, fontSize: 36,
lines: ["<b>Hi</b>"] }<div class="el text" style=" left:10%; top:10%; width:50%; font-size:5cqw"> <span><b>Hi</b></span> </div>
- Positions as percentages of the 720 × 405 slide, so the preview works at any width.
- Font sizes in cqw: 1cqw is 1% of the slide's width (the slide is a CSS container). A 36pt font on a 720pt slide is 5cqw, whatever size the preview is drawn at.
- Lines as they are: the engine already wrapped them, so each line is a
<span>and nothing wraps again.
The model's text is untrusted
If a slide says <img src=x onerror=…>, perhaps because a web page in the research said so, inserting it as HTML runs that code in your user's browser. That's . escapeHtml turns < into < and friends, so text always shows up as text.
<b>Hi</b> & "you" → <b>Hi</b> & "you"app.js: the rest of the page
- Sends the form with
fetchto/api/deck/stream, and splits the streamed body into lines. - Stage and retry events update a status line with
textContent(neverinnerHTML, for the same reason as above). - Slide events append a preview; the done event replaces them with the final, harmonized slides and turns the base64 file into a download link.
Your project so far
35 files · 3 new or changed in this lesson
public/app.js
// The browser side: send the brief, show progress as NDJSON events arrive, preview
// each slide as soon as it's written, and offer the finished file for download.
import { PREVIEW_CSS, slideToHtml } from "./preview.js";
const form = document.querySelector("#brief");
const status = document.querySelector("#status");
const slides = document.querySelector("#slides");
const download = document.querySelector("#download");
document.querySelector("#preview-css").textContent = PREVIEW_CSS;
const STAGE_NAMES = { research: "Researching", outline: "Planning the slides", deck: "Writing slides", repair: "Fixing layout problems" };
form.addEventListener("submit", async (event) => {
event.preventDefault();
const data = new FormData(form);
const button = form.querySelector("button");
button.disabled = true;
slides.innerHTML = "";
download.hidden = true;
setStatus("Starting…");
try {
const response = await fetch("/api/deck/stream", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
topic: data.get("topic"),
slideCount: Number(data.get("slideCount")),
theme: data.get("theme"),
research: data.get("research") === "on",
}),
});
if (!response.ok) throw new Error((await response.json()).error ?? "The request failed.");
await readEvents(response, handleEvent);
} catch (error) {
setStatus(error.message, true);
} finally {
button.disabled = false;
}
});
/** Split the streamed body into lines, and each line into one JSON event. */
async function readEvents(response, onEvent) {
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
let newline;
while ((newline = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim()) onEvent(JSON.parse(line));
}
}
}
function handleEvent(event) {
if (event.type === "stage" && event.status === "start") setStatus(`${STAGE_NAMES[event.stage] ?? event.stage}…`);
if (event.type === "slide") slides.insertAdjacentHTML("beforeend", slideToHtml(event.slide));
if (event.type === "retry") setStatus(`Gemini is busy, retrying in ${Math.round(event.delayMs / 1000)}s…`);
if (event.type === "fallback") setStatus("Switching to a less busy model…");
if (event.type === "error") setStatus(event.error, true);
if (event.type === "done") showDeck(event.deck);
}
function showDeck(deck) {
// Replace the streamed previews with the final, harmonized slides.
slides.innerHTML = deck.slides.map(slideToHtml).join("");
const bytes = Uint8Array.from(atob(deck.file), (char) => char.charCodeAt(0));
download.href = URL.createObjectURL(new Blob([bytes], { type: "application/vnd.openxmlformats-officedocument.presentationml.presentation" }));
download.download = `${deck.title.replace(/[^\w]+/g, "-").toLowerCase() || "deck"}.pptx`;
download.hidden = false;
const problems = deck.problems.length ? ` · ${deck.problems.length} layout warnings` : "";
setStatus(`${deck.slides.length} slides ready${deck.cached ? " (saved copy)" : ""}${problems}`);
}
// textContent, never innerHTML, for anything that could contain model or error text.
function setStatus(message, isError = false) {
status.textContent = message;
status.className = isError ? "error" : "";
}Key takeaways
- Draw slides from the render plan: percentages for positions, cqw for font sizes, so they scale.
- Text from a model is untrusted: escape it before it goes into HTML.
- Check colors and alignment too, because anything placed in a style attribute can break out of it.
Sign in to run the exercise
Reading is free. Writing code here needs an account so we have somewhere to keep your Gemini key and the +40 XP you are about to earn.