# Building an MMGY deck

The point of this system: **hand over a brief, get back a deck.** A document, an
RFP, a set of notes — the deck is assembled from patterns that already exist, so
the work is judgement about *which* pattern and *what* to say, never CSS.

---

## The map

```
mmgy/                    ← the system. Change this and you change every deck.
  mmgy.css               tokens: 4 colours, 2 typefaces, the type scale, margins
  mmgy-motion.css        entrance animations + click-through builds
  mmgy-ui.css            components: accordion, tabs, timeline, cards, tables…
  mmgy-charts.css        chart styling + the brand tint ramp
  mmgy-charts.js         10 SVG chart types
  mmgy-icons.js          56 stroke icons
  mmgy-deck.js           the engine: builds, click nav, PDF, share links, gate
  deck-stage.js          third-party slide shell — scaling, rail, print. Don't edit.
  templates.html         the original 19 static patterns
  assets/                logo, photos, illustrations, textures

library/index.html       52 live patterns. The catalogue AND the demo.
decks/<name>/index.html  one folder per deck
docs/AUTHORING.md        this file
index.html               landing page
vercel.json              deploy config
```

---

## Making a new deck

```bash
cp -r "decks/we-are-mmgy" "decks/client-name"
```

Then register it — one object in `decks/decks.json`:

```json
{
  "id": "client-name",
  "path": "decks/client-name/index.html",
  "client": "Client Name",
  "title": "Integrated Brand & Media",
  "subtitle": "One line on what this deck is for.",
  "date": "2026-08-18",
  "status": "draft",
  "passcode": "somethingunguessable",
  "owner": "Name Lastname"
}
```

It appears on the index page on the next load. `status` is one of
`template · draft · sent · won · archived`, and it drives both the tag on the
card and the sort order — live work floats to the top, archived sinks.

That register is the **only** file to touch when a deck is added. Slide lists
are deliberately not stored in it: the share-link builder reads them live out of
each deck, so a link can never offer a slide that was renamed last week.

Then, in the deck itself, in order:

1. **Outline first.** Map the brief onto sections. A proposal that works is
   roughly: open → who we are → what we heard → the insight → our approach →
   proof → team & terms → close. Nine sections is a lot; five is usually right.
2. **Pick patterns**, don't invent layouts. Open `library/index.html`, walk the
   rail, hit **Copy** on the slide you want, paste it in.
3. **Write the copy into the pattern**, not the other way round. If the words
   don't fit the pattern, pick a different pattern.
4. **Replace every placeholder.** Search the file for `Client Name`,
   `Name Lastname`, `000`, `Month Day`, and `media-ph`.
5. **Set the passcode** on `<body data-passcode="…">`, or delete the attribute.

### The slide contract

```html
<section class="slide slide--ink slide--pad"
         data-label="Our Approach"     <!-- names it in the rail; jump target -->
         data-chapter="Approach"       <!-- optional: puts a dot in .chapters -->
         data-steps                    <!-- optional: this slide clicks to build -->
         data-no-click-advance>        <!-- optional: slide owns its own clicks -->
```

- Slides are direct children of `<deck-stage>`. **Nothing else may be** — a stray
  `<script>` or `<div>` in there becomes a blank slide.
- Never set `position`/`inset`/`width`/`height` on the `<section>` itself.
- `data-no-click-advance` is for slides whose whole point is clicking something
  else (accordion, tabs, reveal cards, filters, before/after).

---

## Motion

Add `.fx` to anything that should animate in when the slide arrives. Pick the
motion with a modifier, and the timing with a number.

| | |
|---|---|
| `fx` | rises 48px and fades in — the default |
| `fx--down` `fx--left` `fx--right` | travels from that direction |
| `fx--in` | fade only. Use for photography |
| `fx--scale` | settles from 106%. Use sparingly |
| `fx--wipe` `fx--wipe-up` `fx--wipe-dn` | uncovered rather than faded — the signature move |
| `fx--grow-x` `fx--grow-y` | for rules and bars |
| `fx--draw` | draws an SVG stroke on. The engine measures the path for you |
| `fx--near` `fx--far` | shorter / longer travel |
| `fx-1` … `fx-12` | 120ms apart. Stagger siblings |
| `fx-auto` on a parent | every `.fx` child staggers automatically |

**One rule, and it matters:** the *visible* state is the base style; we animate
*from* hidden. That's why print, reduced-motion and a failed script all still
show finished content. Never write CSS that leaves an element invisible by
default.

## Builds — the click-through

```html
<section class="slide slide--pad" data-steps>
  <h2 class="t-display t-h3">Always on screen</h2>

  <div data-step>Arrives on click 1</div>
  <div data-step class="fx--left">Arrives on click 2</div>
  <div data-step="2">Also click 2 — a shared number groups elements</div>

  <p data-step-until="3">Leaves once click 3 lands</p>
  <div data-step-dim="3">Fades back to 28% once click 3 lands</div>

  <div class="build-ticks"></div>   <!-- optional progress ticks -->
</section>
```

Bare `data-step` auto-numbers in DOM order; an explicit `data-step="N"` pins a
beat and resets the counter, so you can interleave.

Behaviour, which is Keynote's and therefore what a presenter expects:

- → / Space / click advances the build; only when the build is spent does it
  move to the next slide.
- ← rewinds one beat, then goes back a slide.
- Arriving **backwards** lands on a fully-built slide.
- Leaving rewinds, so a second visit replays.
- Print builds every slide fully.

`data-step-dim` is the most underused one here: reveal row 2 and dim row 1, and
the audience follows you down a list instead of reading ahead.

---

## Charts

```html
<figure class="chart" data-chart="column" style="width:1664px;height:560px">
  <script type="application/json">
    {"categories":["Q1","Q2","Q3","Q4"],
     "series":[{"name":"Paid","data":[42,55,61,78]},
               {"name":"Organic","data":[30,34,41,44]}],
     "suffix":"k"}
  </script>
</figure>
```

Set `width`/`height` inline — the chart may be measured while hidden inside a
build step, and inline size is what it falls back to.

**Content inside `<script type="application/json">` is not HTML.** Write `&`,
never `&amp;`.

| type | config |
|---|---|
| `column` | `categories[]`, `series[{name,data[]}]`, `max?`, `axis?:false`, `values?:false` |
| `bar` | `categories[]`, `series:[{data[]}]`, `labelWidth?`, `highlight?:index`, `flat?` |
| `line` / `area` | `categories[]`, `series[{name,data[]}]`, `max?`, `min?`, `dots?:false` |
| `donut` | `data[]`, `categories[]`, `centre?`, `centreLabel?`, `thickness?`, `legend?:false` |
| `progress` | `value`, `max?`, `label?`, `thickness?` |
| `funnel` | `stages:[{name,value}]`, `rates?:false` |
| `waffle` | `value`, `max?`, `cols?`, `rows?` |
| `gantt` | `units`, `rows:[{name,start,end,tone?,milestone?}]`, `tick?`, `unitLabel?` |
| `dumbbell` | `rows:[{name,a,b}]`, `max?`, `labelWidth?` |

All accept `prefix`, `suffix`, `decimals`, `comma:false`, `legend:false`, `alt`.

### Colour — the rule that keeps data on-brand

Four brand colours can't carry five data series, so series colours are **tints
derived from the brand, never new hues**:

| slot | value |
|---|---|
| 1 | red 100% — always the hero. Put the number they should remember here |
| 2 | red 58% |
| 3 | red 28% |
| 4 | `currentColor` — ink on light slides, white on dark |
| 5 | `currentColor` 38% |

`currentColor` in slots 4–5 is what lets one chart sit on white, stone, ink and
red without a single override. On a red slide the ramp flips to ink/white
automatically.

**If you need a sixth series, the chart is doing too much. Split the slide.**

## Icons

```html
<i data-icon="target" class="icon icon--red"></i>
```

56 stroke icons on a 48×48 grid, coloured by `currentColor`. Sizes: `icon--sm`
(48px), default (88px), `icon--lg` (140px). The full sheet is the *Icon
Reference* slide in the library.

Need one that doesn't exist? Add a single path to `PATHS` in `mmgy-icons.js` —
4px inset, 3px stroke, nothing finer than 4px. **Never import an outside icon
library**; it will not match the illustration language and it will show.

---

## Delivery

### PDF

The **PDF** button (or `P`, or ⌘P) prints one slide per page at 1920×1080
landscape. Before printing, the engine builds every step, opens every accordion,
lands every counter on its final value and freezes every animation — so the
export is the finished deck, not a snapshot mid-fade.

Tell the client: *Print → Destination: Save as PDF → Background graphics on.*

**What a PDF can't carry.** A slide is a fixed 1920×1080 box, so anything that
stacks four panels into one frame has to lose three of them:

| Pattern | In the PDF |
|---|---|
| Accordion | every row open — it fits |
| Tabs, interactive timeline | only the selected panel |
| Reveal cards | the answer, not the prompt |
| Hotspots | every card open |
| Filter chips | unfiltered, all rows |

If you know the client will read it on paper, give the deck a print-friendly
companion — a table or a 4-up grid saying the same thing — rather than hoping
the interactive slide survives export.

### Share links

Send a client only the slides they should see.

**From the index page** (the normal way): open `index.html`, find the deck, hit
**Share selected slides…**. It reads the deck's live slide list, you tick what
they get, set an optional expiry, and copy the link.

**From the console** (if you're already in a deck):

```js
MMGYDeck.share(['Title', 'Our Approach', 'Investment', 'Closer'], '2026-09-30')
```

Either way you get a URL with a `?s=…` token. Slides outside the set are skipped, so
they're dropped from navigation, from the rail, from the slide count and from
the PDF. The expiry is optional.

This is convenience, not security — the token is trivially readable. Anything
genuinely confidential should not be in a static deck at all.

### Passcode

```html
<body data-passcode="mmgy2026">
```

A light deterrent, checked client-side and remembered in `localStorage`. Same
caveat: it keeps a link from being casually forwarded, nothing more.

**The gate art-directs itself.** It reads the deck's own cover slide — the
full-bleed image, the MMGY logo, the eyebrow and the `<h1>` — and rebuilds them
beside the form, so the first thing a client sees is the deck's title slide with
a passcode panel cut into it on a diagonal. Nothing to configure: a new deck
gets a matching gate the moment its cover is built.

Unlocking is one move. The panel slides out, the photo settles onto the exact
rect the slide occupies, and the cover replays its entrance underneath. Because
the gate borrowed the cover's own image, the hand-over has no seam — the form
appears to peel off the title slide rather than be replaced by it.

Override any piece on `<body>` when the cover isn't the right first impression:

```html
<body data-passcode="…"
      data-gate-image="assets/gate.jpg"
      data-gate-eyebrow="Client Name"
      data-gate-title="A different headline">
```

A deck whose first slide has no `img.img-cover` falls back to the plain centred
panel — which is also what `index.html` and the library use.

### Deploy

The whole thing is static. Push to Vercel and every deck is a URL:

```bash
vercel deploy --prod
```

`vercel.json` sets long cache lifetimes on `mmgy/assets/` and short ones on CSS
and JS, so a system fix reaches live decks on the next load.

---

## The rules

These are not preferences. A deck that breaks them stops looking like MMGY.

1. **Four colours.** Red `#F8340D`, ink `#141414`, stone `#E5E6E1`, white. One
   flat background per slide. Tints only via the chart ramp and `color-mix` on
   brand tokens.
2. **Two typefaces.** Big Shoulders Display, 700, UPPERCASE for every headline
   and number. Archivo for everything else. No third weight of personality.
3. **Go big.** Headlines are 80–160px; hero stats are 256–500px. A 40px headline
   on a 1920px canvas reads as a document, not a presentation.
4. **128px margins, and content that breathes.** No filler to fill a column.
5. **One idea per slide.** If it needs two, it's two slides — or a build.
6. **Vary the background for rhythm**: white → ink → stone → red. Two adjacent
   red slides is one too many.
7. **End on the closer.**
8. **Never invent a number.** Delete the slide instead.

## Adding to the system

Something genuinely new — a layout, a chart type, an interaction:

1. Build it with existing tokens. If you find yourself writing a hex value,
   stop; the answer is a token or `color-mix` on one.
2. Put the CSS in the right file (`mmgy-ui.css` for components,
   `mmgy-charts.css` for data), with a comment block showing the markup.
3. Add a slide to `library/index.html` demonstrating it, with a `.pat` label.
4. If it animates, gate the animation on `[data-deck-active]` and make sure the
   `@media print` and `prefers-reduced-motion` blocks cover it.

The library is the test suite. If a pattern isn't in there, it doesn't exist.

---

## Deploy gotchas, learned the hard way

**`cleanUrls` must stay off.** It strips `.html` and redirects
`/decks/<name>/index.html` to `/decks/<name>` with no trailing slash. That
moves the base directory up one level, so every relative asset path in a deck
resolves wrong:

```
assets/cover-villa.jpg   →   /decks/assets/cover-villa.jpg   (404)
```

Stylesheets survive it by accident — `../../mmgy/` clamps at the root either
way — so the failure looks like a fully styled deck with no photography and no
console error. `trailingSlash: true` keeps directory URLs intact instead.

**`vercel.json` takes no comment keys.** The schema is strict and rejects
unknown top-level properties, including `_comment`. A deploy with one in it
fails the build and Vercel silently keeps serving the previous deployment — so
the site looks stale rather than broken. Notes about the config go here, not
in the file.

**A failed deploy is invisible from the front end.** If a change doesn't
appear live, check the deployment status before debugging the code:

```bash
vercel ls mmgy-slides-deck
```

**Local dev: use `serve.py`, not `python3 -m http.server`.**

```bash
python3 serve.py          # port 4321
```

The plain module sends no cache headers, so browsers hold on to
`mmgy/*.css` and a system edit silently doesn't apply. The failure is
nasty because it doesn't error — a component whose CSS didn't load just
collapses into unstyled stacked text that reads like a markup bug.
`serve.py` sends `no-store`. If you had a tab open before switching, hard
refresh it once (⌘⇧R) to drop the old cached entry.

**Absolutely-positioned components need a definite width.** `.hub` and
similar have no in-flow content, so as a flex or grid item with
`margin: 0 auto` they shrink-to-fit to **zero** and every `left:%` child
collapses onto one point. Size them with `max-width` + `justify-self`,
never auto margins. `.hub` now carries `width: 100%` to make that
harder to get wrong.
