|
| 1 | +# Block-level attributes on list items (`<li class>`) |
| 2 | + |
| 3 | +**Strand:** bd-aeyss6p5 (discovered-from bd-itqcfxc3; related bd-38ioql41) |
| 4 | +**Date:** 2026-06-18 |
| 5 | +**Status:** planning |
| 6 | +**Parent plan:** [`2026-06-17-block-level-attrs-inline-attr.md`](./2026-06-17-block-level-attrs-inline-attr.md) |
| 7 | +(the `Para` capability, merged in PR #310; see its "List-item JSON |
| 8 | +representation — study" section, which this plan implements) |
| 9 | + |
| 10 | +## Goal |
| 11 | + |
| 12 | +Let a block attribute land on a list item's `<li>` — e.g. `<li class="…">` |
| 13 | +for per-item styling. `- item {.foo}` should render `<li class="foo">item</li>` |
| 14 | +in **all three renderers**: `q2 render` (html.rs), `q2 preview` (json.rs → |
| 15 | +React, including the `q2-slides`/revealjs incremental path), and AST→JSON→AST |
| 16 | +round-trips. |
| 17 | + |
| 18 | +## Confirmed facts (verified 2026-06-18) |
| 19 | + |
| 20 | +- **The parser already emits the carrier.** `- item one {.foo}` parses to a |
| 21 | + list item whose **last block** ends with a trailing `Inline::Attr({.foo})` |
| 22 | + (a `Plain` for tight items, a `Para` for loose items). No filter/transform is |
| 23 | + needed — the capability is reachable by plain authoring, just like `Para`. |
| 24 | +- **Current behavior is broken/dropping:** |
| 25 | + - `-t native` and `-t json` both **error `Q-3-32`** (standalone attr) on the |
| 26 | + item's trailing `Inline::Attr`. |
| 27 | + - `-t html` **drops** the attr (the inline writer skips `Inline::Attr`), and |
| 28 | + leaves a stray trailing space: `<li>item one </li>`. |
| 29 | +- **Why list items differ from `Para`** (recap of the study section): a Pandoc |
| 30 | + list item is `Vec<Block>` — a bare `[Block,…]` **array** in JSON with no |
| 31 | + object to hang an extra `attr` key on. So the attr must be **hoisted** out of |
| 32 | + the item's last block up to the `<li>`, and carried in JSON via a **parallel |
| 33 | + sibling key on the enclosing list node** (the `rowsS` table precedent), not an |
| 34 | + object key on the item. |
| 35 | + |
| 36 | +## JSON wire form (resolved) |
| 37 | + |
| 38 | +Mirror the table `rowsS` precedent: a sibling key on the list node, an array |
| 39 | +**parallel-indexed** to the items, each entry an `Attr` triple or `null`. |
| 40 | + |
| 41 | +- **Key name:** `itemAttr` (decided; `rowsS`-style suffix doesn't fit since this |
| 42 | + is an attr, not source info). |
| 43 | +- **Emitted only when at least one item carries a non-empty attr** — so ordinary |
| 44 | + lists are byte-for-byte unchanged (keeps the ~3900 existing tests inert). |
| 45 | +- **Key order:** alphabetical, matching the house convention |
| 46 | + (`stream_write_simple_node` emits `c`, then `l?`, `s`, `t`). `itemAttr` sorts |
| 47 | + between `c` and `l`. |
| 48 | + |
| 49 | +BulletList: |
| 50 | +```json |
| 51 | +{"t":"BulletList","c":[[…item0…],[…item1…]],"itemAttr":[["",["foo"],[]],null],"s":…} |
| 52 | +``` |
| 53 | +OrderedList (items live in `c[1]`; `itemAttr` is parallel to `c[1]`): |
| 54 | +```json |
| 55 | +{"t":"OrderedList","c":[[1,{"t":"Decimal"},{"t":"Period"}],[[…i0…],[…i1…]]],"itemAttr":[null,["",["foo"],[]]],"s":…} |
| 56 | +``` |
| 57 | + |
| 58 | +Pandoc ignores the unknown `itemAttr` key, so `-t json | pandoc -f json` still |
| 59 | +degrades to a plain list (verified-by-analogy with `Para.attr`; will re-verify |
| 60 | +empirically in the end-to-end step). |
| 61 | + |
| 62 | +## AST representation (no type change) |
| 63 | + |
| 64 | +The item attr **stays** a trailing `Inline::Attr` in the item's last block — |
| 65 | +the canonical shape the parser already produces and `qmd`/`incremental` already |
| 66 | +round-trip. No new field on `BulletList`/`OrderedList`. The hoist happens only |
| 67 | +at **write** time (and the inverse fold at **read** time). |
| 68 | + |
| 69 | +## Shared helper (the keystone) |
| 70 | + |
| 71 | +Add to `crates/pampa/src/writers/block_attr.rs`, reusing |
| 72 | +`split_trailing_block_attr` for the merge semantics: |
| 73 | + |
| 74 | +```rust |
| 75 | +/// For a list item (`&[Block]`), collect the trailing block attr from its LAST |
| 76 | +/// block — but only when that block is a `Paragraph`/`Plain` (the blocks that |
| 77 | +/// carry inline content). Returns the merged `Attr` and, when the attr is |
| 78 | +/// non-empty, a *stripped clone* of the last block (trailing attr run removed) |
| 79 | +/// for the caller to render in place of the original. Returns `(empty_attr, |
| 80 | +/// None)` when there is no hoistable attr, so the caller renders the item as-is. |
| 81 | +pub(crate) fn split_list_item_attr(item: &[Block]) -> (Attr, Option<Block>); |
| 82 | +``` |
| 83 | + |
| 84 | +- Clones **only** the last block, and **only** when an attr is present (rare). |
| 85 | + This is what kills the **precedence trap**: the stripped clone means the inner |
| 86 | + `Para`/`Plain` writer never sees the trailing attr, so the class lands on |
| 87 | + `<li>`, never on an inner `<p>`. |
| 88 | +- **Limitation (decided 2026-06-18: acceptable for v1):** if the item's last |
| 89 | + block is not a `Para`/`Plain` (e.g. it ends with a nested list or code block), |
| 90 | + no item attr is hoisted — matches where the parser puts authored attrs. |
| 91 | + Revisit only if a consumer needs broader scanning. |
| 92 | + |
| 93 | +All three writers and the reader go through this one helper, so the merge rule |
| 94 | +stays single-sourced (no Rust/TS duplication — the cross-language divergence the |
| 95 | +project fears). |
| 96 | + |
| 97 | +## Implementation plan (TDD — failing tests first) |
| 98 | + |
| 99 | +### Phase 0 — helper + unit tests ✅ |
| 100 | +- [x] `split_list_item_attr` in `block_attr.rs` with unit tests: tight item |
| 101 | + (last block `Plain`) hoists; loose item (last block `Para`) hoists; |
| 102 | + multi-block item hoists from the **last** block; item with no trailing |
| 103 | + attr → `(empty, None)`; last block not `Para`/`Plain` → `(empty, None)`; |
| 104 | + empty trailing attr → `(empty, None)`; empty item → `(empty, None)`; |
| 105 | + stripped clone has the trailing attr-run removed. **7 new tests, all green.** |
| 106 | + |
| 107 | +### Phase 1 — html.rs (`q2 render`) ✅ |
| 108 | +- [x] **Failing tests** (mirror `mod tests`): `- item {.foo}` → `<li class="foo">`; |
| 109 | + inner `<p>` does **not** also get the class (loose item); composes with the |
| 110 | + incremental `fragment` class (`<li class="fragment foo">`); id + kvs |
| 111 | + applied; no stray trailing space; ordinary list unchanged. **6 tests, were |
| 112 | + red, now green.** |
| 113 | +- [x] Replaced `list_item_open` with `composed_list_item_attr` + `write_list_item` |
| 114 | + (per-item open merging `classes = ["fragment"?] ++ item_classes` plus id/kvs |
| 115 | + via `write_attr`). Bullet + Ordered loops both call `write_list_item`. |
| 116 | +- [x] Renders the item via the helper's stripped clone (`item[..last] + stripped`) |
| 117 | + so the trailing attr is gone before `write_blocks_inline` runs. |
| 118 | +- [x] **End-to-end:** `pampa -i list.qmd -t html` → `<li class="foo">item one</li>`. |
| 119 | + |
| 120 | +### Phase 2 — json.rs writer (`q2 preview` transport) ✅ |
| 121 | +- [x] **Failing tests** (3): `BulletList`/`OrderedList` wire form carries |
| 122 | + `itemAttr` parallel to items, `null` for plain items; inner block emitted |
| 123 | + **without** the trailing attr; ordinary list emits **no** `itemAttr` key. |
| 124 | +- [x] Custom inlined emitter for Bullet/Ordered (fast path = byte-identical |
| 125 | + `stream_write_simple_node` when no item attr): helpers `list_item_attrs`, |
| 126 | + `stream_write_list_items` (emits stripped items), `stream_write_item_attr_key` |
| 127 | + (alphabetical position c→itemAttr→l/s/t). |
| 128 | +- [x] **End-to-end:** `pampa -t json` emits `itemAttr`; `pandoc -f json` ignores |
| 129 | + it and degrades to a plain `<li>` (no error). |
| 130 | + |
| 131 | +### Phase 3 — readers/json.rs (round-trip) ✅ |
| 132 | +- [x] **Failing test**: AST→JSON→AST round-trip preserves the item attr (item |
| 133 | + with attr regains trailing `Inline::Attr`; item without one stays clean). |
| 134 | +- [x] `fold_item_attrs` helper threaded into both `BulletList`/`OrderedList` |
| 135 | + arms (mirrors the `Para` `attr` fold-back + table `rowsS`). |
| 136 | +- [x] **End-to-end:** `pampa -t json | pampa -f json -t html` → |
| 137 | + `<li class="foo">item one</li>`. |
| 138 | + |
| 139 | +### Phase 4 — React preview-renderer (both code paths) ✅ |
| 140 | +- [x] **Failing vitest** (4): attributed item → `<li class="foo">` in registry |
| 141 | + path + deck path; incremental composes `class="fragment foo"`; id/`data-*` |
| 142 | + applied; null item stays plain. Were red, now green. |
| 143 | +- [x] `framework/types.ts`: added optional `itemAttr?: (Attr | null)[]` to |
| 144 | + `BulletListBlock`/`OrderedListBlock`. |
| 145 | +- [x] Shared helper `framework/listItemAttr.ts` `liItemAttrProps(attr, fragment)` |
| 146 | + (single-sources the compose rule, mirrors Rust `composed_list_item_attr`), |
| 147 | + exported from `framework/index.ts`. |
| 148 | +- [x] Incremental path — `blocks/BulletList.tsx` & `blocks/OrderedList.tsx` use |
| 149 | + `liItemAttrProps(node.itemAttr?.[i], incremental)`. |
| 150 | +- [x] Non-incremental path — `framework/dispatch.tsx` |
| 151 | + `renderChildrenRegistry.BulletList`/`OrderedList` use |
| 152 | + `liItemAttrProps(node.itemAttr?.[i], false)` (edit-drop documented inline). |
| 153 | +- [x] preview-renderer suites green (219 unit + 233 integration); `tsc` clean. |
| 154 | + - **Edit edge case (decided 2026-06-18: accept the drop for v1).** These |
| 155 | + registry renderers' `setLocalAst` rebuilds `{t:'BulletList', c:newItems}` |
| 156 | + **without** `itemAttr`, so an in-preview text edit of an attributed item |
| 157 | + drops its class. We apply `itemAttr` on render but do **not** thread it |
| 158 | + through edits in v1 (matches how other sidecar data behaves on edit). |
| 159 | + Document the limitation; file a follow-up strand if preservation is wanted. |
| 160 | + |
| 161 | +### Phase 5 — end-to-end + full verify |
| 162 | +- [x] `pampa -t html` on real fixtures: bullet `<li class="foo">`, ordered |
| 163 | + `<li id="one" class="alpha" data-key="val">` — inspected. |
| 164 | +- [x] `-t json | pandoc -f json` degrades gracefully (plain `<li>`, no error). |
| 165 | +- [x] `-t json | pampa -f json -t html` roundtrips to `<li class="foo">`. |
| 166 | +- [x] `-t json | pampa -f json -t qmd` roundtrips to `{#one .alpha key="val"}` / |
| 167 | + `{.beta}` on the items (reader fold-back + qmd writer). |
| 168 | +- [x] React render path covered by vitest **integration** tests driving the real |
| 169 | + `Ast`/registry component tree, both the registry (non-incremental) and the |
| 170 | + `mountInDeck` incremental paths. |
| 171 | +- [~] **Live browser** `q2 preview` (WASM-served) revealjs session: NOT yet run. |
| 172 | + The JSON contract (Rust tests) + React reads (vitest) cover the chain |
| 173 | + piecewise; a live check is the final seal — see status note. |
| 174 | +- [x] Full `cargo xtask verify` — **all 14 steps passed (exit 0):** lints+clippy |
| 175 | + clean, fmt clean, workspace build (`-D warnings`) clean, Rust tests, WASM |
| 176 | + rebuilt via hub-build, hub-client tests (600+72+111), preview-renderer |
| 177 | + (219+233+74), q2-preview-spa built. Step 14 Playwright E2E skipped (no |
| 178 | + `--e2e`). |
| 179 | + |
| 180 | +## Out of scope / deferred |
| 181 | + |
| 182 | +- **`native.rs`** keeps erroring `Q-3-32` on the item's trailing attr (consistent |
| 183 | + with the `Para` decision; native's faithfulness contract is a separate call). |
| 184 | +- **Strict-pandoc-JSON vs Quarto-2-JSON split.** The current "JSON/native must be |
| 185 | + Pandoc-compatible, else `Q-3-32`" decision makes AST inspection during this kind |
| 186 | + of work awkward (you can't dump an AST that contains a standalone attr without |
| 187 | + first teaching a writer to encode it). Not needed for this feature, but a good |
| 188 | + candidate **interstitial strand** if AST inspectability keeps biting. Flagged |
| 189 | + per the user's 2026-06-18 note. → file as discovered-from bd-aeyss6p5 if we want it. |
| 190 | +- `BlockQuote`/other block-attr carriers (parent plan, separate increments). |
| 191 | +- Wiring the bd-38ioql41 reveal-figure-caption consumer (parent plan item). |
| 192 | + |
| 193 | +## Bookkeeping |
| 194 | + |
| 195 | +- `braid update bd-aeyss6p5 --status in_progress` when implementation starts. |
| 196 | +- Leave a trail with `braid comment` per phase. |
| 197 | +- Snapshot changes (if any list `.snap` files move) get reported per CLAUDE.md. |
0 commit comments