diff --git a/claude-notes/plans/2026-05-22-callout-class-vocabulary-fix.md b/claude-notes/plans/2026-05-22-callout-class-vocabulary-fix.md
new file mode 100644
index 000000000..376af3e12
--- /dev/null
+++ b/claude-notes/plans/2026-05-22-callout-class-vocabulary-fix.md
@@ -0,0 +1,225 @@
+# Callout class-vocabulary fix — align q2 with TS Quarto / Bootstrap
+
+## Overview
+
+q2's `CalloutResolveTransform` emits a non-canonical class vocabulary that
+does not match the Bootstrap-based SCSS we vendored from TS Quarto. The
+result: callouts in `format: html` output (both `quarto render` and the
+hub-client preview) render as unstyled `
`s, even though every other
+piece of the pipeline is wired up correctly.
+
+This plan rewrites `callout_resolve.rs` to emit the canonical class set,
+mirrors the change in the q2-preview React component, retires the
+standalone `styles.css` callout rules, and adds an end-to-end smoke
+fixture covering the full callout matrix.
+
+### Root cause (recap, for the reader)
+
+The current resolver was carried over verbatim from a pre-refactor
+`html_writer.rs::write_callout` introduced in commit `fef66bc2`
+("step 7, fancier html writer") and ported into `callout_resolve.rs` by
+commit `6f21c557` ("refactor callout into rust transform"). Its class
+scheme (`callout-appearance-{x}` only when non-default; no
+`callout-titled`; no `no-icon`; collapse as a single class on the outer)
+does not match what TS Quarto's
+`src/resources/filters/modules/callouts.lua` emits, and the SCSS in
+`resources/scss/bootstrap/_bootstrap-rules.scss` keys off the TS Quarto
+vocabulary.
+
+A standalone `crates/quarto-core/resources/styles.css` (lines 166–217)
+was written to match q2's wrong scheme as a stopgap, but it only ships
+under `theme: none`. The default `format: html` path compiles Bootstrap
+and gets nothing applicable.
+
+### Canonical class vocabulary (per TS Quarto)
+
+Authoritative source: `~/src/quarto-cli/src/resources/filters/modules/callouts.lua`
+(`render_to_bootstrap_div`, around line 224–340), confirmed by deepwiki.
+
+For a titled callout with icon:
+
+```html
+
+```
+
+For an untitled callout with icon:
+
+```html
+
+```
+
+Class-emission rules (always vs conditional):
+
+| Class | Where | When |
+|---|---|---|
+| `callout` | outer | always |
+| `callout-style-{appearance}` | outer | always (default → `callout-style-default`) |
+| `callout-{type}` | outer | always |
+| `callout-titled` | outer | when title slot is non-empty |
+| `no-icon` | outer | when `icon=false` OR type is unknown |
+| `callout-empty-content` | outer | when body has no content blocks |
+| `d-flex align-content-center` | header | always (titled path) |
+| `callout-header` | header | titled path only |
+| `callout-icon-container` | icon wrapper | when icon is rendered |
+| `callout-title-container flex-fill` | title wrapper | titled path |
+| `callout-body-container` | body wrapper | always |
+| `callout-body` | body div | always (combined with `-container` when titled; separate when untitled) |
+| `d-flex` | body | untitled path only |
+| `callout-collapse collapse [show]` | collapse wrapper | when `collapse` attr present |
+| `collapsed` | header | when starts collapsed |
+
+Appearance normalization (per `callout.lua::nameForCalloutStyle`):
+
+- `appearance="minimal"` is rewritten to `appearance="simple"` AND `icon=false`.
+ Today this normalization is missing in q2; both transforms see "minimal" as a raw string.
+
+Default-title injection rule (per `callouts.lua:224-227`, `render_to_bootstrap_div`):
+
+- When `appearance="default"` AND user supplied no title, TS Quarto injects
+ the type's display name as the title (`"Note"`, `"Warning"`, `"Tip"`,
+ `"Important"`, `"Caution"`) — the callout is then rendered through the
+ titled path with a header bar.
+- For `appearance="simple"` (or `minimal`, post-normalization), no default
+ title is injected — the callout goes through the untitled path with the
+ icon nested in the body and no header bar.
+- q2 today unconditionally injects a default title regardless of appearance
+ (`callout_resolve.rs:264-268`). The new resolver must mirror TS Quarto's
+ appearance-conditional rule.
+
+### Scope decisions
+
+1. **Collapse markup is in scope** for the HTML pipeline (it's just attribute emission — Bootstrap's JS handles the toggle behaviour once loaded). It is **out of scope** for the q2-preview React component in this plan — the React component will accept and render collapse-bearing custom nodes but will not implement collapse interaction. A follow-up beads issue captures that.
+2. **`callout-empty-content`** is in scope (one extra class).
+3. **`callout-{calloutidx}` unique IDs** (TS Quarto generates `callout-1`, `callout-2`, …) are only needed for the collapse path. Generate them as part of the collapse work; otherwise the outer div uses the user-supplied `id` (or none).
+4. **Standalone `styles.css` callout rules** will be **rewritten** to match the new vocabulary, not deleted. `theme: none` documents still need basic callout styling.
+5. **Latex/typst/revealjs callout output** — out of scope. Today only the HTML path is wired up; other formats either don't exist or use the writer directly.
+
+## Phase 1 — Test specifications (TDD; write first, expect failures)
+
+- [x] Add unit tests to `crates/quarto-core/src/transforms/callout_resolve.rs` (extend the existing `mod tests`) asserting the **canonical** class set for each of these inputs. Each test must FAIL with the current code before any resolver change is made. **Done in commit 8366ae99** — 13 new tests under the `test_canonical_*` prefix, 11 failed against the unmodified resolver, 2 passed (id-preserved + all-types-emit, both regression-guards).
+ - [x] `test_canonical_default_with_user_title`
+ - [x] `test_canonical_default_no_title_injects_default`
+ - [x] `test_canonical_simple_no_title_stays_untitled`
+ - [x] `test_canonical_simple_with_user_title`
+ - [x] `test_canonical_no_legacy_appearance_class`
+ - [x] `test_canonical_minimal_normalizes_to_simple_no_icon`
+ - [x] `test_canonical_icon_false_emits_no_icon`
+ - [x] `test_canonical_empty_content_class`
+ - [x] `test_canonical_titled_header_has_utility_classes`
+ - [x] `test_canonical_collapse_true_emits_wrapper`
+ - [x] `test_canonical_collapse_false_emits_show_class`
+ - [x] `test_canonical_user_id_preserved`
+ - [x] `test_canonical_all_types_emit_type_class` (extra regression guard)
+- [x] *Deferred:* insta snapshot test. The 13 explicit-assertion tests + the smoke fixture provide better-localized failure messages than an insta snapshot would; closed without filing a separate beads issue. If a future regression motivates one, add it then.
+- [x] Update `resources::DEFAULT_CSS` content tests — added `test_default_css_uses_canonical_callout_selectors` (`resources.rs`). Fails until Phase 4.
+- [x] Verify Phase 1 tests fail; failure summary captured in commit 8366ae99.
+
+## Phase 2 — Resolver rewrite
+
+- [x] Add the `minimal` normalization at the CalloutTransform layer (`crates/quarto-core/src/transforms/callout.rs:205-207`): when `appearance == "minimal"`, store `appearance="simple"` and `icon=false` in `plain_data`.
+- [x] Also added a `collapse_starts_collapsed` boolean to `plain_data` so the resolver can distinguish "collapsible-starts-open" (`collapse="false"`) from "collapsible-starts-collapsed" (`collapse="true"`), without overloading the existing `collapse` boolean.
+- [x] Rewrite `resolve_callout` to emit the canonical structure:
+ - [x] Always push `callout-style-{appearance}`.
+ - [x] Detect title presence; push `callout-titled` (after appearance-conditional default-title injection: default-appearance + empty title → inject display name).
+ - [x] Push `no-icon` when `icon == false`.
+ - [x] Push `callout-empty-content` when content empty.
+ - [x] Titled path: `