---
name: presentation
description: Build a Gemma-branded slide deck as a self-contained HTML file. Use this for ANY presentation, slide deck, pitch deck, sprint-review deck, or "slides" request - it is the Gemma standard for presentations and replaces reaching for PowerPoint, Google Slides, or a hand-rolled HTML page. Covers the dark-theme slide design system, the slide primitives, cover art, deriving figures at build time, verifying that no slide is clipped, and publishing as a shareable Artifact.
---

# Gemma presentations

The house standard for slides. One template, one build script, one verification
step, and the output is a single HTML file that opens anywhere, prints to PDF one
slide per page, and can be published as an Artifact to share.

**Use this whenever someone asks for a presentation or slides.** Do not build a
deck from scratch, and note that `document-branding` does not make slides at
all: its `--plum-cover` flag only recolours a document's cover page. This skill
is the slide surface.

## Why HTML and not PowerPoint

Slides here are text in a git-friendly file, so they diff, review, and rebuild.
The build step is what earns it: figures that would go stale can be computed at
build time instead of typed in, and a rebuild the morning of the presentation
refreshes them.

## The workflow

```bash
# 1. Copy the template and write your slides into it
cp "${CLAUDE_SKILL_DIR}/assets/templates/deck.html" my-deck-template.html

# 2. Build. --cover picks the title art, --pic the panel art, --var fills {{TOKENS}}
uv run "${CLAUDE_SKILL_DIR}/scripts/build_deck.py" my-deck-template.html my-deck.html \
  --cover purple-particle-wave-mesh.png \
  --pic PLANE=purple-particle-plane-horizon.png \
  --pic DOTS=3d-render-modern-background-with-flowing-cyber-dots-design.jpg@90 \
  --pic PARTICLES=3d-render-abstract-background-with-floating-cyber-particles.jpg \
  --pic PORTRAIT=bijan-soltani-portrait.png \
  --var DECK_DATE="6 August 2026" --var STAT_ONE=539 \
  --var MODULE_WEEKS=6 --var MODULE_PRICE="&euro; 00.000" \
  --var CONTACT_EMAIL="first.last@gemmaanalytics.com"

# 3. VERIFY. Not optional - see below
uv run "${CLAUDE_SKILL_DIR}/scripts/audit_deck.py" my-deck.html --shots 1,2,3
```

Delete the example slides you do not need and the `--pic` / `--var` arguments that
fed them; an unused argument is an error, so the build tells you what is stale.

**`--pic NAME=FILE` inlines panel art two ways:** `/*PIC_NAME*/` as a CSS
background, and `<!--BLOB_PIC_NAME-->` as a blob-clipped SVG. Append `@90`, `@180`
or `@270` to turn the picture. Rotate here rather than in CSS - see the print
section below.

**Requires the `document-branding` skill** in the same plugin. Fonts, logos and
pictures are read from there rather than duplicated here, so one set of brand
assets serves both documents and decks. `build_deck.py` checks for it up front and
says so if it is missing.

`build_deck.py` writes two files: `my-deck.html` (standalone) and
`my-deck.artifact.html` (body-only, for the Artifact tool, which supplies its own
document wrapper). Publish the `.artifact.html` one.

## Always verify: slides clip silently

`.slide` is a fixed 1280x720 box with `overflow: hidden`. Content that runs past
the bottom **just disappears** - no scrollbar, no warning, and the authoring loop
looks fine. Two slides shipped overflowing on the first deck built this way, and
the only reason anyone caught it was a person squinting at a projector.

So `audit_deck.py` is part of building a deck, not an optional extra. It forces
each slide to `height: auto` and reports the natural height, then screenshots the
ones you name.

- **690px or less is the target.** A slide at 715 fits on your machine and may
  clip on someone else's, because font rasterisation differs by platform.
- **Look at the screenshots.** Height passing is not the same as reading well.
  Overlaps, collisions with the nav controls, and type that is legible on a laptop
  but vanishes on a projector all pass the height check.
- **`0px` is not a pass.** A full-bleed slide positions its layers absolutely, so
  its natural height collapses to zero and the clipping check has nothing to
  measure. Every `.slide--divider`, `--hero`, `--module` and `--closing` reports
  `0px`. Those are fixed-height by construction, but the number is telling you
  nothing, so read the screenshot for them rather than the figure.

When a slide is over: cut content first. Only then reach for `.slide--roomy`'s
opposite (tighter gaps and row padding on that one slide).

**Do not hand-roll this check, and never substitute `scrollHeight`.** It is the
obvious shortcut and it has a blind spot that will cost you a slide. A body with
`justify-content: center` pushes its overflow in *both* directions, and the part
that runs off the top is invisible to `scrollHeight`, so the check passes on a
slide whose header row is already under the accent bar and whose last row is cut
off. That happened on a live deck: a table grew by one row, a hand-rolled check
reported nothing, and only a second check comparing the body's height against the
sum of its children caught it. `audit_deck.py` had the answer all along, exactly
39px, because forcing `height: auto` makes the slide grow no matter how its
content is aligned.

**A deck built by another script still has to go through this one.** The trap is
not knowing the rule, it is having a build of your own. Where an engagement grows
its own `build-deck.py`, the house verification quietly drops out of the loop for
the whole project unless someone points `audit_deck.py` at the output. It takes a
path and a browser; it does not care who built the file.

**Screenshot from a fresh path.** Chrome caches `file://` URLs, so a screenshot
taken straight after a rebuild can show you the previous build and confirm a
change you have not actually made. Copy the built deck to a uniquely named file
before shooting it, or you will spend a round debugging an edit that already
worked.

## Print is a second renderer, and it fails silently

`Ctrl+P` is a first-class output of this deck, and Chrome's print pipeline is not
the same renderer as the screen. It fails by **omission**: it drops what it cannot
handle without an error, so the slide is right in the browser and wrong in the PDF.
Four cases have been hit for real, all of them invisible on screen:

| Do not | Because | Instead |
|---|---|---|
| `mask-image: url(...)` to shape a picture, with `mask-size` / `mask-position` set | The shape is lost, `background-size` falls back, and the picture tiles as a rectangle | `<!--BLOB_PIC_NAME-->`, an SVG clip |
| `transform` to place or rotate | Dropped, so the element lands unrotated or uncentred | Flex centring; `--pic FILE@90` to turn art |
| `font-display: block` on an inlined face | Text in that face is laid out, measured, and never drawn. It is absent from the PDF's text layer, and which weight is hit changes with the document | `font-display: swap`, which `build_deck.py` sets |
| A grid track or flex basis for the only text in a row | The label was dropped from print while the number beside it survived | Reserve the gutter with padding |

**The font one is worth understanding, because it was first diagnosed wrongly.**
When weight 400 vanished from a printed deck it looked like Kanit-Regular being
unable to paint, and the fix was to stop loading that face. Then weight 600 vanished
the same way from a longer deck while 400 printed fine, which is the tell that the
document decides who loses, not the file. Probing one document: `block` loses the
text, `swap` and no `font-display` both keep it, and a 90-second virtual time budget
does not help, so waiting is not the answer. `block` means "draw nothing until the
face arrives", and in this pipeline it sometimes never arrives. All four Kanit
weights are loaded again and all four print.

**Not every mask is affected.** A `linear-gradient()` mask survives print and the
ladder slide uses one to fade its artwork. Verified by printing the deck with and
without that declaration: the two PDFs differ across 7.6% of the page, over exactly
the masked element's box. So do not read the first row above as "no masks ever" and
rewrite a working fade. The case that failed was the image form carrying its own
sizing, and an SVG clip replaced it because a clip states the intent more directly
anyway.

**So check the print path, not just the screen.** The audit script screenshots the
screen render and will pass all four of these. Print the deck and read the text back:

```bash
chrome --headless --no-pdf-header-footer --print-to-pdf=out.pdf deck.html
pdftotext out.pdf - | less        # anything missing here is missing, full stop
```

`pdftotext` is the sharp tool: if a string is not in the text layer, no screenshot
will tell you, and the projector will.

## Slide grammar

Every slide is `chrome` (section label, slide number, logo), then a title, then an
accent bar, then `.slide__body`. Copy an example from the template rather than
composing from scratch.

| Primitive | Class | For |
|---|---|---|
| Cover | `.cover` + `.cover__art` / `__scrim` / `__credit` | Title slide with full-bleed art |
| Two columns | `.cols--7-5`, `--6-5`, `--5-7`, `--2` | Argument left, evidence right |
| Grouped list | `ul.brand` | 2-3 points, bold lead-in then explanation |
| Statement | `.statement` | The one sentence to remember |
| Figure table | `.rows`, `.rows__total` | Label/value pairs with an optional total |
| Stat tiles | `.stats` + `.stat--plum/teal/coral` | Four headline numbers |
| Before/after | `.ba` | Showing a change of practice |
| Callout | `.callout--teal/coral/yellow` | Context / risk / next step |
| Tag pills | `.pill--on` | Internal / Sales / Delivery tagging |
| Timeline | `.tl__item--now/--gate` | Phases, present, decision point |
| Option cards | `.opt--pick` | A decision with alternatives |
| Audience warning | `.audience` | A slide not for the main room |
| Different audience | `.slide--emt` on the section | Swaps lavender accent for teal |

These six are rebuilt from the house Google Slides deck, so a deck mixing them with
the ones above reads as the same deck. They ground on **black** (`.slide--ink`),
which is what the house deck does, rather than on the `#160329` the primitives above
use. Keep a deck on one or the other.

| Primitive | Class | For |
|---|---|---|
| Section divider | `.slide--divider` | A section break: blob-framed art, big number |
| Hero claim | `.slide--hero` | The one slide saying what Gemma is, plus a client wall |
| Numbered ladder | `.slide--ladder` | An ordered stack where the order is the argument |
| Step flow | `.slide--flow` | Gates in a real sequence, blob badges and arrows |
| Module card | `.slide--module` | One priced work package |
| Closing | `.slide--closing` | Last slide: plum ground, semicolon, contact |

`.slide--ladder--flat` drops the ladder's cycling row fills for one calm treatment.
Reach for it when the row colours would imply a distinction the content does not have.

Six more, also on the black ground:

| Primitive | Class | For |
|---|---|---|
| Table of contents | `.slide--toc` | Sections with leader rules and page numbers |
| Divider, art behind | `.slide--divider-onart` | A section break with the picture full bleed |
| Phase matrix | `.slide--matrix` | A case study read across phases |
| Split with a list | `.slide--splitlist` | Art column, then the argument as weighted bullets |
| Case study | `.slide--casestudy` | Long-form: their situation, our work, artwork |
| Tech stack | `.slide--stack` | The data value chain as labelled stages |
| Takeaway banner | `.banner--yellow` / `--white` | One line under a dense slide, shared by three of them |

**The two dividers are alternatives, not a pair.** `.slide--divider` frames the
picture in a blob on a black slide; `.slide--divider-onart` runs the picture full
bleed and lays a black blob over it. Pick one per deck. The second turns its title
lavender, because white does not hold over the bright part of a particle render.

**Slot primitives.** Three places take marks this repository does not hold: the
hero's client wall, the case study's co-branding lockup, and every tool chip on the
tech stack slide. Each is a styled text slot that an `<img>` or inline SVG replaces
without the layout moving, so a deck reads as deliberate before the logos arrive.
The tech stack slide is the one that most wants them: the house version is largely
a logo collage, and text chips carry its structure but not its look.

Eight more, for a deck that gets **presented in fifteen minutes** rather than read.
They ground on `#160329` like the first set, and every one of them carries a fixed
row or track so that like-named labels land on one line across all columns.

| Primitive | Class | For |
|---|---|---|
| Agenda | `.agenda` + `.slide--center` | Numbered sections, number centred against the item |
| Metric cards | `.metrics` / `.metric` | Four measures, name plus one sentence, labels aligned |
| Under-note | `.undernote` | A qualification below a card row instead of on every card |
| Gap band | `.gapband` | The one thing the slide above does not cover, in yellow |
| Layered comparison | `.archcols` / `.archpanel` | Their architecture beside ours, rows aligned via subgrid |
| Pro and contra | `.procon3` / `.procon2` | Two or three routes with the recurring cost on its own line |
| Task table | `.tasktable` | Tasks first and large, owner quiet |
| Options and advice | `.options` + `.recbox` / `.facts` | Choices on top, framed recommendation below |

`.archbox--new` marks what changes, `--off` what is deferred, `--weak` the one box in
their drawing you doubt. `.stack--fill`, `.stack--spread` and `.stack--wide` control
how a stack uses the height it is given - see the empty-lower-half section below,
because those three are not interchangeable.

## Design rules that came out of building the first one

- **Content sits tight under the title, not vertically centred.** Centring was
  tried and rejected: on a short slide it opens a ~130px gap that orphans the
  title from the content it introduces. Leftover space belongs at the bottom.
- **Fill an empty bottom corner with the brand mark** (`.decor--mark`), not with
  decorative shapes. The negative logo mark keeps its own facet colours and reads
  as intentional; a faded blob reads as a smudge.
- **No footer on every slide.** A repeated "Gemma Analytics GmbH" line costs
  ~43px per slide and tells the audience nothing they do not know.
- **Diagrams are inline SVG, and the connectors carry the argument.** A labelled
  arrow ("only if green", "every change, as a pull request") says why one box
  follows another. A stack of styled boxes is a list wearing a diagram's clothes.
  Keep SVG `font-size` at 10 or above.
- **Colour can encode a distinction.** Where two paths differ, give them
  different accents (teal for one, lavender for the other) rather than relying on
  position alone. Brand accents are otherwise used sparingly.
- **Derive volatile figures.** Anything that could change between authoring and
  presenting goes through `--var`. On the first deck, five figures went stale
  within a day, and two hand-typed ones were simply wrong.

## Ask who is in the room before you write a slide

Density is a briefing question, not a style question, and getting it wrong costs
whole rebuilds. On the deck these sparse primitives came from, three drafts were
rejected for the same reason: they were written at the density of the report they
were derived from. The room was two managing directors and the slot was fifteen
minutes.

Establish three things first, and write them into the deck's header comment so the
next session does not have to ask again:

- **Who is in the room.** A management audience does not want the detail; a working
  session does. The same finding becomes one line or five.
- **How long the slot is.** Fifteen minutes over seven slides is two minutes each,
  which is one idea per slide and no more.
- **What the deck is for.** A decision paper leads with the recommendation. A status
  update leads with what changed. A discussion document leads with the open question.

Where the source material is a long document, the deck is not a condensation of it.
Pick the three or four things that change a decision and leave the rest for the
document you send afterwards.

## An empty lower half is a defect, and there are two different fixes

Slides that end in the upper two-thirds read as unfinished. Reaching for the wrong
remedy breaks something else, so know which one applies.

- **`.slide__body` does not stretch its children.** A `flex: 1` on a grid nested in
  a plain `.stack` never fires, because the stack itself is not growing. Add
  `.stack--fill` to the stack first.
- **`.stack--fill` only works where a child can absorb the height.** On a slide of
  side-by-side comparison columns, granting the extra height made the columns
  overflow onto the statement below them - the content was intrinsically taller than
  the track it was given. Check for clipping straight after adding it.
- **`.stack--spread` is the second fix.** It keeps every block at its content
  height and distributes the slack *between* the blocks. Reach for it as soon as
  growing a card would just open a hole inside the card.
- **The third fix is neither, and it is what was finally accepted.** Equal slack
  between blocks is still too much slack: it pushed the qualification so far below
  the cards it qualified that it stopped reading as belonging to them. Set the gaps
  inside the block deliberately - 14px from the cards to the note, 26px from the
  note to the callout - and let `.slide--center` put the rest above and below. The
  rule of thumb: **a note that explains a row belongs one gap away from it, and only
  the leftover goes to the frame edges.**
- **Vertical centring is sometimes right.** `.slide--center` suits a genuinely
  sparse slide, where centring reads as composed. The rule further up this file -
  content tight under the title - was derived from a dense deck and still holds
  there. Do not centre a slide that has enough content to fill the frame.

## Labels must land on one line across columns

The most repeated review comment on a multi-column slide is that the same label sits
somewhere different in every column. The cause is always the same: a name wraps to
two lines in one column and one in the others, and everything below it shifts.

**Reserve the height.** Give the card a fixed first grid row sized to the tallest
name you expect (`grid-template-rows: 58px auto`). Row two then starts at the same y
in every column whatever the name does.

**Do not fix emptiness with `align-content: space-between` inside the card.** It
distributes the slack between the rows and puts the labels straight back out of
line. This was tried and reverted; the slack belongs between the cards, not inside
them.

## Two panels that must line up: use subgrid

A their-drawing-beside-ours comparison only works if like-named layers sit at the
same height, and the panels almost never have the same number of layers. Give the
outer grid the row tracks and let both panels adopt them with
`grid-template-rows: subgrid`. The shorter panel leaves its rows empty, which is
exactly the argument the slide is making, and a text edit cannot knock the two sides
out of step. A hand-measured spacer does the same job until the first edit.

**Subgrid aligns the track, not the content.** Boxes inside a shared track still
differ in height between panels unless the layer wrapper stretches and the row
inside it takes `flex: 1`. Measure it rather than trusting it: compare the box
heights on both sides, not just the track tops.

## Cite the other side's drawing

Where you are proposing a change to something the client designed, reproduce their
layout, their layer names and their bracketed subtitles first, then mark only what
moves. The author has to recognise their own diagram before they will read the diff.
On the deck this came from, the same content was rejected as "just taken from the
template" and accepted once it quoted the client's own three-layer model.

The corollary: **mark a doubt, do not shout it.** The one box you think will not
carry the load gets `.archbox--weak`, a faint coral - visibly different from the rest
of their drawing without calling it a mistake in front of its author.

## Tool marks

`document-branding/assets/tools/` in the sibling website repository holds Airflow,
dbt, Lightdash, Snowflake, PostgreSQL, Power BI and about twenty more. Two things
about using them on the dark ground:

- **The wordmarks are usually black and vanish.** Recolour by saturation rather than
  by hand: set every low-saturation dark pixel to white and leave saturated pixels
  alone. That keeps the Airflow pinwheel, the dbt mark and the Lightdash bolt while
  turning their black lettering white.
- **Put the name beside the mark, not under it**, because that is how the wordmark
  itself sets it, and where the logo already contains the product name, the label
  next to it carries only the variant ("Cloud", not "Lightdash Cloud").

## A logo's viewBox is not its ink

A left-aligned logo does not optically align with a left-aligned title, because the
SVG usually carries empty space inside its viewBox. On the Gemma wordmark it is
20.65 units of a 517.85-unit box, which at 40px tall puts the ink 11.2px right of
where the title starts.

Measure it, do not eyeball it: `svg.getBBox().x`, scaled by
`rect.width / viewBox.width`, is the offset in CSS pixels. Pull the logo left by
that much.

## Turn every review comment into a measurement

`audit_deck.py` catches clipping. It does not catch "the number is not aligned with
the text" or "the boxes are not the same size", and those comments come back until
something checks them. Add the invariant to a throwaway harness alongside the audit:
load the built deck in headless Chrome, activate each slide, read the geometry you
were challenged on, and print the deltas.

Three that earned their place on one deck:

- logo ink edge minus title edge, expecting 0
- the row-track top of a named layer in panel A minus the same in panel B
- the box height inside that track in A minus B

Each one closed a review comment that had already come back twice. The pattern is
worth more than the specific checks: **if a reviewer can see it, you can measure
it.**

## Slide numbers are not a shared reference

Review feedback arrives as "slide 4", and there are at least three numberings in
play: the position in the file, the number printed in the chrome, and whatever the
reviewer is counting on their screen, which usually includes or excludes the cover
in the way you do not. One round on a live deck was lost putting a cost figure on
the wrong slide because of exactly this.

Confirm by title, not by number. When you report back, name the slide the way the
reviewer will recognise it ("on the architecture slide"), and when you are unsure
which of two slides is meant, ask before building. It is one question against one
wasted round.

## Do not invent the numbers

Prices, dates and effort figures on a slide are commercial statements, not content.
Ask for them. On this deck a price was removed in one round because a funding rule
made it inadvisable, and put back at a different figure in the next once the owner
had decided - the wasted round was caused by guessing rather than asking. Route
every such figure through `--var` so changing it is a rebuild, not an edit.

## Record why a rejected version was rejected

Keep the reason in the CSS comment next to the rule that replaced it. Over five
review rounds on one deck, two earlier attempts were reintroduced by accident
because nothing in the file said why they had been dropped -
`align-content: space-between` on the metric cards, and baseline-aligning an agenda
number against a much smaller title. The comments in these primitives carry those
reasons deliberately.

## Cover art

Pass `--cover` a filename from `document-branding/assets/pictures/` or any path;
the script downscales to 1760px and inlines it as JPEG (roughly 110-250 KB, versus
several megabytes for the raw file).

Pick art whose **dark region falls where the text sits**. The template's cover is
left-aligned, so it wants art that is dark on the left. Two scrim gradients deepen
it further, but they cannot rescue a busy background.

**Licensing is not yours to decide.** The pictures folder is stock, and its
`README.txt` states the rules: Freepik free-tier needs an "Image: Freepik" credit
(the template includes one), and for anything client-facing or published you must
confirm Gemma holds a covering licence. Ask; do not assume, and never invent a
licence reference.

## Theme

Dark, single-theme, deliberately: a deck commits to one visual world. Tokens come
from the brand guide's dark-proposal palette (`#160329` ground, `#e8e1f2` body,
lavender accent, `#4fc2b0` teal variant). Every text colour in the template clears
WCAG AA against the ground. If you add a colour, check it.

Do not add a light variant unless someone asks. Do not restyle the tokens.

## Presenting

Arrow keys, space, PageUp/PageDown, Home/End. `F` or the corner button toggles
full screen; inside an embedding iframe the browser may refuse, in which case the
deck says to press F11. `Ctrl+P` gives one landscape page per slide with the dark
background preserved.
