Conversation
Extend the callout renderer to cover the two remaining items in #465: - Nested callouts: an outer `> [!type]` whose body contains a `> > [!type]` blockquote now renders as a nested `<div data-callout=...>` inside the outer's `.callout-content`. The recursive `pandocSplice` already does the work; the parser change is only that `Callout.body` retains the inner BlockQuote untouched, and CSS drops the trailing margin so the inner callout hugs the parent body. - Foldable callouts: `> [!type]+` (initially expanded) and `> [!type]-` (initially collapsed) per the Obsidian spec. The header parser is split into `parseCalloutHeader` returning `(CalloutType, Maybe FoldState)`; `parseCalloutType` is kept as a back-compat shim. `Callout` gains a `foldState` field, and three conditional Heist splices in `_base.tpl` switch between `<div>` (non-foldable), `<details open>` (expanded), and `<details>` (collapsed) — all with shared icon/body bindings. Tests cover bare/foldable header parsing in both same-token and split-token forms (defensive against pandoc's tokenisation), nested-blockquote body preservation, and rejection of malformed headers. Docs: callout.md gains a Foldable and Nested section; CHANGELOG notes the feature under 1.6.0.0 unreleased.
Expand the comment on `absorbFoldSuffix` to spell out that fold markers must be adjacent to the closing bracket: `[Str "[!tip]", Space, Str "-"]` is intentionally not absorbed, since `> [!tip] -` is a tip callout whose title starts with a hyphen, not a foldable callout. Per Lowy review of #465.
The two-selector rule overspecified the foldable case: `.callout details > div > .callout:last-child` doesn't match the actual DOM (foldable body is `<details> > <div.mt-3> > <div.callout-content> > nested .callout`, not `details > div > .callout`). The first selector already covers both foldable and non-foldable parents since both wrap their body in `.callout-content`.
…ocal helper The upstream `Heist.Splices.ifISplice :: Monad m => Bool -> Splice m` has the same shape and semantics as the local `whenSplice` (run children if condition is True, disappear otherwise). Drop the local helper and import the upstream one — one less inline definition to maintain.
Hickey/Lowy Analysis
Hickey rationaleThe three-branch template — Lowy rationaleThree volatility axes are encapsulated independently: parsing ( |
|
| Step | Status | Duration | Verification |
|---|---|---|---|
| sync | ✓ | 0s | git fetch ok; forge=github |
| research | ✓ | 6m 10s | Mapped callout implementation; identified 4 files to change |
| branch | ✓ | 16s | On feature branch dizzy-king |
| implement | ✓ | 9m 20s | Parser + Callout.foldState + 3-branch template + CSS; nested verified end-to-end via emanote gen |
| check | ✓ | 5s | cabal build all clean |
| docs | ✓ | 27s | callout.md + CHANGELOG.md updated |
| fmt | ✓ | 30s | cabal-fmt, fourmolu, hlint, nixpkgs-fmt all clean |
| commit | ✓ | 31s | 887a62b (6 files, +219/-26) pushed |
| hickey+lowy | ✓ | 6m 15s | Lowy 1 fixed (007c722); Hickey 3+4 deferred; rest no-op |
| police | ✓ | 17m 31s | Fact-check: dead CSS selector fixed (e349761). Elegance: whenSplice → Heist.Splices.ifISplice (0031e5b) |
| test | ✓ | 30s | cabal test: 32 examples 0 failures (incl. 9 new callout parser tests) |
| create-pr | ✓ | 1m 50s | Draft PR #652 + Hickey/Lowy disposition table comment |
| ci | ✓ | 4m 5s | e2e (live) 4m20s, e2e (static) 4m11s, flake-parts-docs 1m49s — all green on HEAD 0031e5b0 |
| done | ✓ | 0s | Workflow complete |
| Total | 48m 3s |
Optimization suggestions
policedominates at 36% of total (17m 31s). The bulk was the/simplifyPhase 2 spawning three reviewer agents in parallel, then code-search inside each — for a diff this small, a single inline pass would have been faster than the multi-agent fan-out.research(6m 10s) andhickey+lowy(6m 15s) ran similar lengths. Combined they're 26%. Pre-readingEmanote/Pandoc/Renderer/Callout.hsand the_base.tplcallout template before invoking/dowould shave the research step substantially.implement(9m 20s) included one fmt-induced rebuild and an end-to-end render verification viaemanote gen. The render check was worth the time (caught zero issues here, but would have caught template-XML errors immediately).- CI (4m 5s) was the unavoidable e2e wait. For follow-up commits to this PR,
--from ci-onlyskips everything else and just re-runs CI against the new HEAD.
Workflow completed at 2026-04-25T00:25:33Z.
Adds Cucumber+Playwright scenarios for each user-facing path the renderer must distinguish: plain (non-foldable `<div>`), foldable+ expanded (`<details open>` with body visible), foldable+collapsed (`<details>` with body hidden), click-to-toggle, and nested. Each top-level callout in the fixture uses a unique type so the steps can target it via `[data-callout="<type>"]` without ambiguity. The visibility check uses `getBoundingClientRect().height` rather than `getComputedStyle(display)` because `<details>` hides its descendants via internal layout — children keep their own `display` value, so a height probe is the only reliable signal. CHANGELOG entry now also links to PR #652.
The remaining two items in #465 are now in.
> [!type]+and> [!type]-give you foldable callouts (initially expanded and collapsed, respectively); nested callouts work by indenting an inner blockquote with> >. Closes the last two unchecked boxes on the issue.Foldable callouts render as native
<details>/<summary>so the toggle works without JavaScript, and the chevron rotates via a CSS[open]selector. Nested callouts mostly came for free — the recursivepandocSplicealready re-applies the block renderer to inner blocks, so the parser only needed to keep the innerBlockQuoteintact inCallout.body. The visible "feature" there is really the docs section plus a CSS rule that drops the trailing margin on the last inner callout so it hugs the parent body.The header parser is split into
parseCalloutHeader :: Text -> Maybe (CalloutType, Maybe FoldState)returning both pieces;parseCalloutTypeis preserved as a back-compat shim for the existing tests. A defensiveabsorbFoldSuffixmerges adjacentStr "+"/Str "-"tokens onto the header in case Pandoc tokenises[!tip]+as twoStrnodes; the comment spells out why we deliberately do not absorb acrossSpace.Reviewed by
/hickeyand/lowy. One comment-clarification fix landed (commit007c722c) and one CSS dead-selector fix from/code-police's fact-check pass (e3497610). Two structural items deferred — see the Hickey/Lowy Analysis PR comment for the disposition table.Try it locally
Then visit
/calloutto see the newFoldable calloutsandNested calloutssections render.