Module 1: The problem with making carousels by hand
About 35 minutes · Needs: nothing; start here · Code:research/reference/,prototype/index.html,BUILD_PLAN.md,frontend/src/render/renderer.ts,backend/app/schemas/deck.py
Where this fits
Before any agent or container, there was a person making LinkedIn carousels by hand, with an AI assistant typing HTML. This module looks at that workflow closely, because every part of Slidesmith Studio exists to remove one of its pains. If you understand the problem, the rest of the design stops looking arbitrary.
What it does
A LinkedIn carousel is a short deck of square-ish slides, 540 by 675 pixels each, posted as a PDF that readers swipe through. Making a good one takes three different skills: knowing the topic, writing tight copy, and laying it out so nothing spills off the slide. The reference folder in research/reference/ shows how this was done before the studio: a 220-line instruction file describing a design system, and a 594-line Python script that writes every slide as an HTML string.
The obvious shortcut is to ask a language model for the whole thing in one go: "write me a carousel on Kubernetes costs, as HTML". This module explains why that fails, and why the studio instead lets the model write only structured content while code does everything visual. That split, words from the model and pixels from code, is the main idea of the whole project.
At a glance
| Practice | What it is | Why you need it | How this project does it | When you need it | Where it lives |
|---|---|---|---|---|---|
| Agentic app | A model working in several steps, with code checking between them. | One model call cannot research, plan, write and check its own layout. | Research, plan and design steps, then a code check that can loop back to repair. | When a task has stages and a result that can be checked. | backend/app/ai/pipeline.py (Module 8) |
| Content apart from presentation | The model writes a structured spec; code turns it into HTML. | A model that writes markup invents styles, breaks layouts and cannot be edited safely. | The deck spec: slides, blocks and text fields with limits, drawn by one renderer. | Whenever generated content must look consistent. | schemas/deck.py, render/renderer.ts (Module 7) |
| Brand as data | Colours, font, name, photo, logo and call to action stored once as a record. | A brand pasted into markup drifts and cannot be changed without code. | The onboarding questionnaire saves a brand; the renderer themes every slide from it. | Any product that makes output for many brands or people. | services/brand.py, render/brand.ts (Module 6, brand kit) |
| Overflow as a measured defect | Text that does not fit is found by measuring the laid-out slide in pixels. | Overflow is invisible in the HTML and easy to miss by eye. | Headless Chromium measures every slide; anything more than 2 px over is repaired. | Any fixed-size output: slides, labels, certificates, ads. | render/measure.ts, render/chromium.py (Module 11) |
| Prototype before the build | A single-page mock-up that makes the risky parts real before the backend exists. | Look, measuring and export are cheaper to get right in one HTML file. | prototype/index.html: scripted agents, real rendering, measuring, editing and export. | Before a build that depends on a visual or layout idea. | prototype/index.html |
Use cases and examples
Agentic app
- Northwind Labs wants a 6-slide carousel on cutting Kubernetes costs. One step plans six slide titles, another writes each slide, and code checks each one fits before the user sees it.
- A bank's report assistant drafts a monthly risk summary. A planning step lists the sections, a writing step fills each, and code checks that every figure quoted exists in the source tables.
- A newsroom tool turns a long article into a social thread. The steps are summarise, split into posts, then a code check that no post exceeds the platform's length.
Content apart from presentation
- An online shop generates product cards. The model writes the name, three features and a price note as fields; the shop's own template draws them, so a model cannot change the font on one card.
- An insurer writes policy summaries for customers. Fields go into an approved layout that legal has already checked, instead of free HTML that nobody reviewed.
- A school produces weekly newsletters. Teachers edit fields in a form; the layout stays the same whoever writes it.
Brand as data
- Tensor Notes (Arjun Mehta, a staff ML engineer) and Northwind Labs use the same studio. Each sees their own colours, font and footer on every slide, with no code changed.
- A SaaS company with three product lines keeps one brand record per line. Marketing switches the record, not the templates.
- A government office must use its official colours and crest. Storing them once means every generated notice is correct by construction.
Overflow as a measured defect
- A designer slide comes back with a fourth card. On a 540 by 675 slide the last card runs under the footer; the check measures it and sends it back for shortening.
- A hospital prints wristband labels from patient records. A long surname must be measured, not assumed to fit, or part of the name is cut off.
- An online shop renders banner ads in six sizes. A headline that fits the large banner overflows the small one, and only measuring each size catches it.
Prototype before the build
- Before writing any backend, the author built the studio's look as one HTML page with a scripted sample deck. The overflow check found a real layout bug there, at no cost.
- A clinic wants a new booking screen. A clickable mock-up with real date pickers shows the hard cases before the database work starts.
When not to bother. A single carousel you make once a year can be built by hand in a design tool. Plain text posts need no renderer and no measuring. If only one person with one brand ever uses the tool, a brand record is still tidy, but hard-coding is not a disaster.
How it works
We walk through the hand-made workflow that came before the studio, then the problems a "one prompt, finished HTML" shortcut would add, then what the studio does instead. Counts of lines and bytes come from running wc on the reference files on 24 October 2026; timings come from the facts sheet.
Step 1: a style guide written in prose
The reference folder starts with CLAUDE.md, an instruction file meant for an AI assistant. It fixes the slide size at 540 by 675 pixels, names the fonts (Outfit, Fira Code, Font Awesome icons), lists 17 CSS variables, describes six slide types from cover to cheat sheet, and gives HTML snippets for each component. It also fixes the footer: one author, one photo, one logo.
Notice what this file cannot do. It can describe a rule, but it cannot enforce it. Nothing stops a slide from using a colour that is not in the list, or a card title three lines long.
Step 2: every slide is a string of HTML
The carousel itself is produced by build_ai_finops_carousel.py, 594 lines and 47 KB. It holds the stylesheet as one long string, a few helper functions (head, card, bottom), and then sixteen S.append(...) calls, each one a slide written as HTML with numbers, icons and chart SVG typed inline. Running it writes ai_finops_carousel.html: 470 lines, 72 KB, 16 slides.
This works, and the result looks good. But the content and the design are woven together. To change one sentence you edit an HTML string inside Python. To change the template you rewrite the stylesheet and probably every slide.
Step 3: the brand is pasted in
The footer of every slide is one Python string, FOOT, with a fixed author name, a profile photo path and a logo path. The colours are CSS variables copied into this carousel's stylesheet. For one person with one brand, that is fine. For a product with many users, it is impossible: each brand would need its own script.
Brand consistency means every slide shows the same colours, font, name and call to action, and that changing the brand changes all of them at once. Hand-made decks get there by discipline. A product has to get there by design.
Step 4: overflow you cannot see
The hardest defect is overflow. A card's text looks harmless as HTML, but once the browser lays it out at the slide's real width, the last lines may run under the footer or past the slide's edge. The HTML is perfectly valid. Only a browser that actually draws the slide, with the real fonts, knows where every line ends.
By hand, the author opens the file and looks. That catches most problems, misses some, and gets slower with every slide.
Step 5: why "one prompt, finished HTML" fails
It is tempting to replace the hand work with one model call that returns the finished HTML. Here is what goes wrong, and each row is something this project had to design around:
- Invented markup. The model uses class names and inline styles that the template never defined, so slides look slightly different from each other.
- Brand drift. Over a long answer, colours, fonts and the footer change from slide to slide.
- Silent overflow. The model writes characters, never sees pixels, and cannot know that slide 4 spills.
- Edit means regenerate. Fixing one word means opening HTML, or asking the model again and hoping nothing else changes.
- Format locked in. A 16:9 version of the same deck means generating it again.
- Unsafe text. Any
<script>in the output runs when the file is opened.
A local 27B model adds one more: it is slow. On the author's Mac each designed slide took 26 to 67 seconds, so "try again until it looks right" is not a strategy.
Step 6: an agentic app instead
The studio is an agentic app: a model works through several steps (research, plan, design), and code sits between them, saving, validating and measuring. The model never decides on its own that the work is done. A code step, the layout check, measures each slide, and when one is too tall the graph loops back to a repair step. Module 2 (the solution) gives the full picture, and Module 8 shows the LangGraph pipeline that runs it.
Step 7: a prototype first
Before the backend existed, the author built prototype/index.html: one 573-line page with the studio's layout, a scripted sample deck instead of agents, and everything else real. It renders slides in the three templates, switches format, lets you click text to edit it, measures every slide for overflow with the same element-rect method the product uses, and exports. The build plan calls it "the approved look" and the renderer was ported from it.
The prototype paid for itself. Its first overflow check missed content that spilled out of the top of a slide, a bug that would have been much harder to find inside the full stack (see What can go wrong).
Step 8: brand and limits as data
In the studio, the brand is a record saved by the onboarding questionnaire (Module 6, onboarding and brand kit). For Northwind Labs it holds the colours #01153a, #00b0f0 and #008a9d, the font Outfit, the handle @northwindlabs and the call to action "Follow Northwind Labs for weekly playbooks". The renderer reads it every time it draws a slide, so ledger, classic and midnight all carry the same footer and colours.
Text limits are data too: a title has 60 visible characters, a card text 110, a check item 90. The prompts quote these numbers to the model, and the layout check measures what actually happened.
Code tour
Most files in this module are not part of the running app: they are the evidence for the problem. Reference files are shown as plain text. Two short excerpts from source-code/ show how the product answers it.
New concept: an agentic app An agentic app gives a model a goal and lets it work in steps, with tools or code in between. The difference from a chatbot is that the app, not the person, decides what happens next: it checks each result and can loop back. Here the steps are research, plan, design, check and repair, and the checks are ordinary code.
The instruction file sets slide size and fonts in prose:
### Slide Dimensions
- **Width**: 540px | **Height**: 675px
...
- Code font: `Fira Code` (Google Fonts)
- Icons: Font Awesome 6.4.0And the build script writes each slide from small helpers that return HTML strings:
def head(no, kicker, title):
return f'<div class="kick"><span class="no">§{no:02d}</span>{kicker}</div><h2 class="t">{title}</h2><div class="dbl"></div>'
def card(icon, h, d, cls=''):
return f'<div class="card {cls}"><div class="h"><i class="fas {icon}"></i>{h}</div><div class="d">{d}</div></div>'These helpers are handy, but notice that title, h and d go straight into the HTML. Whatever text you pass, including markup, becomes part of the page, and nothing knows how long d may be.
New concept: separating content from presentation Separating content from presentation means the words and the choices (which block, which icon) are stored as data, and one piece of code turns that data into a visual. A model can then write the data, while the code guarantees the look. The same data can be drawn as a carousel or as 16:9 slides, in any template.
The prototype holds the layout rule that keeps overflow measurable:
.s .blocks>*{margin:auto 0} /* auto margins share free space evenly, and collapse to 0 when content is too tall, so it overflows downward */In the product, the brand comes from data. This is the footer of every slide:
// frontend/src/render/renderer.ts
function footerHTML(brand: Brand, deck: Deck, i: number): string {
const f = brand.footer;
const name = brand.display_name || brand.brand_name;
const who: string[] = [];
const photo = safeImageSrc(brand.photo_url);
const logo = safeImageSrc(brand.logo_url);
if (f.show_name && photo) who.push(`<img class="pic" src="${esc(photo)}" alt="">`);
if (f.show_name && name)
who.push(
`<span class="id"><span class="nm">${esc(name)}</span>${brand.handle ? `<span class="hd">${esc(brand.handle)}</span>` : ""}</span>`,
);
if (f.show_cta && brand.cta_text) who.push(`<span class="fw">${esc(brand.cta_text)}</span>`);
if (f.show_logo && logo) who.push(`<span class="lgw"><img src="${esc(logo)}" alt=""></span>`);
// ...
return `<div class="foot"><div class="who">${who.join("")}</div><div class="rp">${right}</div></div>`;
}Code walkthrough
footerHTMLreceives the brand record, the deck and the slide's index. Nothing in it names a person or a logo.nameprefers the display name ("Northwind Labs") and falls back to the brand name.safeImageSrconly accepts image addresses the app trusts, such as the user's own uploads; anything else becomes empty (Module 16 explains the attack it blocks).- Each
ifadds one footer part only when the user switched it on in the brand kit: photo and name, handle, call to action, logo. esc(...)escapes every value before it goes into HTML, so a brand name containing<script>is shown as text, never run.- The
// ...cut buildsright: "Swipe" with an arrow on carousel slides, "Repost" on the last one, a page number on 16:9 slides.
New concept: overflow as a defect Overflow is content that leaves the area it must stay in: under the footer, off the right edge, or above the content box. Treating it as a defect means measuring it as a number of pixels on every slide, after real layout with real fonts, and refusing to call a slide finished until the number is small.
Limits live next to the deck spec, as data shared by the prompts and the checks:
# backend/app/schemas/deck.py
# Soft length limits in characters of visible text (tags not counted). Used by prompts and `length_issues`.
LIMITS = {"title": 60, "kicker": 40, "lede": 160, "card_h": 32, "card_d": 110, "check_item": 90, "kpi_v": 10,
"kpi_x": 60, "flow_b": 18, "flow_s": 28, "takeaway": 110}
# Item counts (min, max). Extra items are cut; too few are kept (the layout still renders).
COUNTS = {"cards": (1, 4), "kpis": (2, 3), "bars": (2, 6), "table_head": (2, 4), "table_rows": (2, 8),
"check": (2, 5), "flow": (2, 4), "blocks": (1, 3)}
CODE_MAX_LINES = 12
MAX_TEXT = 2000 # hard cap on any single text field (abuse guard, far above any layout limit)
MAX_SLIDES = 30Code walkthrough
LIMITSgives every text field a target length in visible characters. These are soft limits: the prompts ask the model to respect them, but text that is longer is kept and left for the layout check to judge.COUNTSgives each block type a range: 1 to 4 cards, 2 to 3 KPIs, up to 8 table rows, and 1 to 3 blocks per slide. Extra items are cut; too few are kept.CODE_MAX_LINEScaps a code block at 12 lines.MAX_TEXTandMAX_SLIDESare hard limits against abuse: no field longer than 2,000 characters, no deck longer than 30 slides. Module 7 explains why soft and hard limits differ.
What happens when a card's text is too long
This trace follows the code and test_put_deck_sanitises_and_switches, which saves a card text of 300 characters.
- A user (or the designer) puts 300 characters into a card's text, well over the soft limit of 110.
PUT /api/projects/{id}/deckrunsnormalise_deck. The text is cleaned of markup but not cut: the test checks that all 300 characters are stored.- The editor redraws the slide with
renderDeckfrom the stored spec and the user's brand. - 350 ms after the change, the editor measures the slide in the browser. The card's last lines sit below the content box, so
overflow_pxis above the 2 px tolerance and the slide's badge turns red with "overflows N px". - During generation, the same measurement runs in the server's Chromium; a slide over the tolerance goes to repair: up to 2 designer "shorten" calls, then up to 3 deterministic trims (Module 11).
- Nothing is hidden. If a slide still does not fit, its badge stays red and the user can edit it or ask for "Shorter text".
Design decisions
| Decision | Chosen | Alternatives | Why |
|---|---|---|---|
| What the model writes | a structured deck spec (JSON) | finished HTML | one renderer for preview, editor and export; edits without HTML; template and format switch without regenerating |
| Where the brand lives | a record per user | strings in the markup | one change themes every slide; many users, one code base |
| How fit is judged | measured in Chromium, 2 px tolerance | by eye, or by counting characters | overflow depends on fonts and width; only layout knows |
| When a slide does not fit | repair with a cap, then show the badge | retry until it fits | a slide takes 26 to 67 s on the local model; an endless loop is not affordable |
| How to start | a one-page prototype with scripted agents | build the backend first | look, measuring and export were proven before any model call |
| Which model | an open-weight model on the user's Mac | a hosted API only | private by default and free per call; Module 3 compares them |
The split in this figure is the contract the rest of the course relies on. Whenever you wonder why a feature is built a certain way, ask which side of this line it sits on. A title's wording is the model's; its font, colour and position are code.
The cost of the agentic approach is time. On the author's Mac a 7-slide deck took 414 s with the model already loaded and 523 s for a 5-slide deck from a cold start. That is why the studio streams progress and shows each slide as soon as it is designed (Module 13, live progress), and why a redo touches one slide, 97 s, instead of the whole deck.
When to choose differently
| If ... | Switch to ... | Why | In this project |
|---|---|---|---|
| you make one bespoke carousel a year | a design tool by hand | no pipeline beats ten careful minutes for a one-off | Not built |
| decks need more slides than 12 | a higher slide cap | the strategist plans exactly N slides; each costs another designer call | Setting: MAX_SLIDES=12 |
| waiting minutes per deck is not acceptable | a hosted copy of the same model | much faster, but the text leaves your machine | Setting: LLM_PROVIDER=openai |
| a team must share one brand | brand kits shared by a workspace | today every account has exactly one brand | Not built |
| output is plain text, such as posts or emails | no renderer and no measuring | there is no fixed-size layout to overflow | Not built |
| you need exact pixel art per slide | a designer-made template set | the spec's 8 block types cover common layouts, not every idea | Built: frontend/src/render/slides.css |
What can go wrong
A slide does not fit. The check measures overflow_px per slide; over 2 px goes to repair, which shortens and trims with a cap and re-measures after each round. test_generate_end_to_end_with_retry_icons_and_repair drives a slide that is 48 px over back to the designer and checks that it is re-measured as fit; test_repair_gives_up_after_the_cap checks that the loop stops.
Fixed: overflow escaped through the top.
In the prototype, blocks were spaced with justify-content: space-evenly. When the content was too tall, that pushed it out of both the top and the bottom of the content area, and the first check only looked below. It was found while testing the prototype. The fix has two parts: blocks are spaced with margin: auto 0, which shares free space evenly and collapses to zero when there is none, so overflow always goes downward; and the measurement uses the element-rect method, where any element whose box leaves the content area, above or below, counts. Both moved into frontend/src/render/slides.css and measure.ts. There is no jsdom test, because jsdom has no layout engine; the Playwright render tests in tests/test_ai_render.py measure real slides.
Fixed: an invented quote credited to the user's own brand.
On a live run with research off, so with no sources at all, the designer wrote a customer quote and attributed it to the user's own brand. A carousel like that is worse than useless: it puts words in the author's mouth. The fix has two layers. Every prompt now carries honesty rules (never invent quotes, testimonials, customers or people; never present a made-up number as data; never attribute statements to the user's brand), and a deterministic guard after the model, enforce_honesty, removes quote blocks when there are no sourced facts and logs the change. test_unsourced_quotes_and_slide_numbers_are_removed proves it (Module 10, honesty rules and prompts).
The model writes broken output. Text that is not valid JSON, or a field of the wrong shape, is repaired in code and sent back to the model once with the error; a second failure gives a plain checklist slide from the outline instead of a failed deck (test_designer_failure_uses_fallback_slide).
Markup in the text. Anything other than <b>, <i> and <br> is removed when a deck is saved, and the renderer escapes on draw (test_sanitize_keeps_only_b_i_br, and the security tests in Module 16).
Known limits. Measuring needs a real browser, so the server carries a headless Chromium; without it, slides are marked "fit unknown" rather than checked. The 8 block types cannot express every layout a human designer would draw. And the local model is slow: minutes per deck, not seconds.
Try it yourself
From the project root, look at the size of the hand-made carousel and open it:
cd research/reference
wc -l CLAUDE.md build_ai_finops_carousel.py ai_finops_carousel.html
open ai_finops_carousel.htmlWhat you should see
220, 594 and 470 lines, and a browser tab with 16 carousel slides in the ledger style. Pick a card and imagine changing its text, its brand colour, or the whole deck to 16:9.
Then open the prototype from the project root:
open prototype/index.htmlWhat you should see
The studio's three-column layout. Click Generate: the scripted agents fill the log and the slides appear. Click Check layout: each slide gets a badge, "fits" or "overflows N px". Click into any title and type: the slide changes in place. Switch the template between ledger, classic and midnight, and the format between carousel and 16:9.
Finally, run the tests that pin two of the fixes in this module:
cd source-code/backend
uv run pytest -q tests/test_deck.py
uv run pytest -q tests/test_ai_pipeline.py -k unsourcedWhat you should see
8 passed for the deck spec tests (sanitising, icons, coercion, length issues), then 1 passed, 20 deselected for the honesty guard. No model, network or Docker is needed.
Check yourself
- Why can a model that writes perfect HTML still produce a broken carousel?
Because overflow only exists after layout. The model sees characters, not pixels, so it cannot know that a card's text runs under the footer at 540 px wide with the real font. Only a browser that draws the slide can measure it.
- What changes in the studio when Northwind Labs switches from the ledger template to midnight?
Only the template field of the deck spec. The same text, blocks and brand record are drawn again by the same renderer with different CSS, instantly and without calling the model.
- Name two things in the reference build script that would have to change for a second brand.
The FOOT string with the fixed name, photo path and logo path, and the colour variables in the stylesheet string. In the studio both come from the user's brand record instead.
- Why was a prototype with scripted agents worth building before the backend?
It made the risky, non-model parts real (the look, editing, measuring, export) at almost no cost. Its overflow check found the top-spill bug, which was fixed with auto margins and two-sided measuring before any backend code existed.
- A card text is 300 characters long. Does saving the deck fail?
No. Length limits are soft: normalise_deck keeps the text (after sanitising) and the layout check judges whether it fits. Only the hard caps, 2,000 characters per field and 30 slides per deck, cut anything.
Key terms
| Term | Meaning |
|---|---|
| Carousel | a short deck of 540 by 675 px slides posted as a PDF that readers swipe through |
| Agentic app | an app where a model works in several steps and code checks results and decides what runs next |
| Deck spec | the structured JSON a deck is stored as: slides, blocks, text fields, sources and claims |
| Separating content from presentation | storing words and choices as data and drawing them with one piece of code |
| Brand consistency | every slide showing the same colours, font, name and call to action, changed in one place |
| Overflow | content that leaves its area on the slide, measured in pixels after layout |
| Soft limit | a target length the prompts ask for and the layout check judges, never a reason to reject text |
| Hard limit | a cap enforced in code against abuse, such as 2,000 characters per field |
| Prototype | a one-page mock-up that makes the risky parts real before the full build |