# Headline Card

> Use this skill when the user asks to add news headline cards, 'Last Week Tonight'-style cards, headline slides, news quote slides, or media quote cards to a Typst presentation. Also use when the user wants to modify existing headline cards (add/remove cards, change quotes, swap logos, fix layout issues). Trigger on: 'add a headline', 'news card', 'LWT card', 'headline slide', 'quote card', 'media quote', 'add a quote from [publication]'.

- Skill: `edwinhu/headline-card` (Agent Skill)
- Install (CLI): `npx skillmds@latest add edwinhu/headline-card`
- Raw SKILL.md: https://api.skillmd.com/api/skills/edwinhu/headline-card/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: edwinhu (https://skillmd.com/u/edwinhu)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/edwinhu/headline-card

---


# Headline Cards for Typst Presentations

An editorial recreation of a newspaper clipping: **light** paper stock, the real
masthead logo, a top rule carrying a category kicker and dateline, a serif
headline with a Last-Week-Tonight yellow highlighter swipe over one phrase, and a
short standfirst.

**Read `templates/theme.typ` before writing a card.** The implementation is the
authority; if it disagrees with this file, believe it and fix this file.

## Architecture

```
presentation/
├── templates/theme.typ        ← headline-card() lives here; read it first
├── data/headlines-NN.json     ← card data, NN = the lecture number
├── assets/logos/*.svg         ← DARK / full-colour logos (see below)
└── slides/XX-topic/NN.typ     ← loops over the JSON, one slide per card
```

## Step 1: the JSON entry

```json
{
  "venue": "WSJ",
  "date": "June 24, 2026",
  "kicker": "Tech",
  "headline": "The headline exactly as published",
  "highlight": "phrase to swipe in yellow",
  "quote": "The article's own standfirst/dek.",
  "logo": "../assets/logos/wsj.svg"
}
```

`highlight` must be a **substring of `headline`, matching character for
character** — the function splits on it, so a straight quote where the headline
has a curly one silently produces no swipe. `kicker` and `quote` are optional.

**Quote the article, do not summarise it.** The standfirst should be the
publication's own dek or a vivid line from the piece. "Discusses the impact of
proxy advisors" is not a card; "Anyone who gives them money — shame on you" is.

**Nothing on the card may be composed.** Headline, dek, outlet and date are the
article's own or they do not go on the slide — a card asserts that a publication
printed this. Pull them from the record, and if a field cannot be sourced, omit
it rather than inventing it. Note that Reader's
`reader-get-document-details` returns null for `published_date`/`source_url`
while `reader-list-documents --id <id>` returns them populated — check both
before concluding a date is unavailable.

## Step 2: paper stock is per publication

`headline-card` picks the stock from `venue`. FT is famously salmon and the
broadsheets are white; one generic cream for everything reads as wrong to anyone
who knows the paper.

| venue | stock |
|---|---|
| Financial Times, FT | `#FFF1E5` salmon |
| WSJ, NY Times, Bloomberg, ABC News | `#FFFFFF` |
| X, Twitter | `#FFFFFF` — light-mode surface; the mark is black |
| Bluesky, Mastodon, Threads, LinkedIn | `#FFFFFF` — same embed layout; accents `#01A5FF` / `#563ACC` / black / `#0A66C2` |
| anything else | `#F7F4EC` cream fallback |

### Microblog cards are embeds, not clippings

A post takes the same function and the same stock table, but an **embed layout**,
because the masthead layout is what made it read as a clipping *of* a tweet:

```json
{
  "venue": "X",
  "name": "Matt Levine",
  "handle": "@mattlevine",
  "avatar": "../assets/avatars/mattlevine.jpg",
  "date": "3:42 PM · Aug 22, 2026",
  "headline": "The post text, verbatim."
}
```

| slot | embed | newspaper |
|---|---|---|
| mark | top **right** | left, as the masthead |
| identity | avatar + **name over @handle**, left | venue wordmark |
| date | **below** the text, over a hairline | top right, with the kicker |
| text | sans regular 21pt | serif bold 28pt |
| border | `#cfd9de` (X's own) | `#ded7c7`, radius 0.3em |

`name` and `avatar` are optional — with no `name` the handle carries the slot
alone rather than leaving a bold gap, and with no `avatar` the row simply starts
at the name. Secondary text is `#536471`, X's own. Supply `logo` to use a real X
mark; otherwise a bold sans `X` stands in.

**Bluesky and Mastodon take the same layout.** All three microblog embeds have
one shape — avatar + name over handle at the left, mark top right, timestamp
below the text — so this is a venue LIST (`X`, `Twitter`, `Bluesky`,
`Mastodon`, `Threads`, `LinkedIn`), not a branch per platform. Only the mark and its accent differ, and the
handle format is data rather than code: `@user` (X, Threads),
`@user.bsky.social`, `@user@instance.social`. **Every microblog ships its real mark inline.** `post-mark-svg` in `theme.typ`
carries the official path for X, Bluesky, Mastodon, Threads and LinkedIn, each
filled with the platform's own colour, so no deck has to ship a logo asset. The
non-X paths are the simple-icons set (MIT); X's is the official 300×271 mark.
A caller-supplied `logo` still wins over all of them. The letter fallbacks
(`b.`, `m`, `@`, `in`) now only fire for a venue with no path at all.

`X`/`Twitter` is a venue, not a second function. The card suppresses the
newspaper-only chrome for it — top rule, kicker, serif headline face, highlighter
swipe — and takes `handle:` (`"@elonmusk"`) beside the mark. Everything else, the
stock table included, is shared.

Add a venue to the `stocks` dictionary in `theme.typ` rather than passing a
one-off, so the next card for that outlet inherits it. `stock: rgb("…")`
overrides per card when one genuinely differs.

Legibility does not constrain this choice — ink, dek and dateline land within
~1:1 of each other across every stock — so it is purely identity.

## Step 3: the logo

**Dark or full-colour, on a light card.** Not the `-white.svg` variant; those are
for dark backgrounds and are invisible here. Use `wsj.svg`, `nyt-dark.svg`,
`ft-dark.svg`, `abc-news-dark.svg`.

### IRON LAW: real vector logos only

If you catch yourself writing an SVG with `<text>` elements spelling out the
publication name — stop. That is a placeholder, not a logo, and it ships with the
wrong font, weight and spacing.

```bash
grep '<text' assets/logos/publication.svg && echo "FAKE — download a real logo"
```

Download from Wikimedia Commons, the brand's press page, or a logo repo.
Wikimedia has SVGs for essentially every major publication; "I can't find one"
means search harder.

Sizing needs no per-logo tuning: the function normalises by aspect ratio, so a
narrow mark and a wide wordmark carry similar visual weight.

## Step 4: render

```typst
#{
  let cards = json("../../data/headlines-NN.json")
  for card in cards {
    slide[
      #headline-card(
        venue: card.venue,
        date: card.date,
        headline: card.headline,
        quote: card.at("quote", default: none),
        logo: card.at("logo", default: none),
        phrase: card.at("highlight", default: none),
        kicker: card.at("kicker", default: none),
        stock: card.at("stock", default: auto),
      )
    ]
  }
}
```

**One card per slide, and no `===` heading on it.** Grid layouts truncate quotes
and shrink logos below recognition; a heading eats the vertical space the card
fills. Put any section header on the preceding slide.

## Step 5: look at it

Compile, get the page from `find-slide-page`, render it, and read it. What
matters: the logo renders and is dark enough; the swipe covers the intended
phrase; nothing is clipped; no single orphaned word ends the headline or
standfirst.

Fix widows in the **JSON text**, never in Typst — tighten the wording, or join
the last two words with ` `. Never pad with filler to fix a widow.

## Red flags — STOP

| Symptom | Cause | Fix |
|---|---|---|
| Logo invisible | a `-white.svg` on the light card | use the dark/full-colour variant |
| Logo missing entirely | JSON path taken as relative to `slides/` | paths resolve from **theme.typ**: `../assets/logos/…` |
| No yellow swipe | `highlight` is not an exact substring of `headline` | match curly vs straight quotes character for character |
| Card doesn't read as an object on white stock | border derived from the stock is near-white | the border is a fixed grey; do not re-derive it from `newsprint` |
| An FT card looks like every other card | venue missing from the `stocks` dictionary | add it |
| An X post wears a serif headline, a top rule or a yellow swipe | venue not spelled `X`/`Twitter`, so it took the newspaper branch | fix the venue string — never add a second card function |
| Dek is generic | summarised instead of quoted | use the publication's own standfirst |
| A date is invented | it wasn't in the source | omit the field; never fabricate one |
| `<text>` in the logo SVG | placeholder, not a logo | download the real one |

## Facts

- **The dateline is the smallest text on the card**, so it carries its own darker
  grey rather than the dek's `mute`, which lands ~5.3:1 on newsprint and washes
  out under projector gamma.
- **Black on the yellow swipe measures ~13.7:1** — the highlight helps
  readability rather than hurting it, so spend it on the phrase that carries the
  point, never decoratively.

