# PR #22997 / GitCafe #23000: fixture matrix (proof audit pass 1, code-derived)

- **Before** = merge-base `8d3b061121`. **After** = `9096191f30` (merge of main into `fix/issue-22989`).
- **Change** = `listNormalizationPlugin` in `apps/web/src/composer-rich-text-doc.ts`. It is an `appendTransaction` plugin, registered through `BulletList.addProseMirrorPlugins`, so it exists only in the **rich** composer.
- Every "expected" cell below comes from reading the code. Headless probes back up each one (`/tmp/ev-22997/probe/probe{2,3}.test.ts`), using the same Editor setup as `composer-block-shortcuts.test.ts`. The probes include no input rules; their behaviour comes from reading the code.

## Code paths the plugin touches → fixture that exercises it

| Path (code) | Fixture |
|---|---|
| `isHistoryTransaction` → clear pending, no-op | F6, F13 |
| pending set remapped through `combineTransactionSteps` mapping; deleted positions dropped; changed top-level paragraphs added | F8 (deferred line moved by a split), F1 (two lines) |
| composition deferral (`view.composing` or `composition` meta) | F15 |
| `depth !== 0` skip (list item, blockquote) | F12 |
| non-paragraph skip (code block, heading) | F12 |
| `parseListPrefix` fails / `prefix.indent` skip | F10 |
| marker range carries any mark (bold, code, …) → skip | F11 |
| active-line deferral: `-` with empty content or `[`, `[x`, `[x]` partial; task without `contentSpace` | F7, F8, F5b |
| no separator (`prefix.space === ""`) → skip | F7 (`-`), F2 (`3` alone) |
| bullet `-*+`; ordered `N.`/`N)` with `start`; task `[ ]`/`[x]` with `checked` | F1, F2, F3, F5 |
| marker and spelling kept (`marker`, `space`, `markerSpace`, `contentSpace` attrs), marks after the prefix preserved, caret mapped | F9c, F3, F11 |
| one undo step (appended tr joins the triggering history event) | F6 |

## Facts that decide BEFORE behaviour (they explain the old contradictions)

1. **Input rules already convert some "completed later" edits on both revisions.** Tiptap's `run$1` matches `textBefore = text from paragraph start to caret + typed char`, whatever follows the caret. So typing `*`/`+`/`1.`/`1)`/`- [ ]` and then **Space** in front of existing text converts on Before too, through `listMarkerInputRule(/^[*+]\s$/)`, `/^\d+[.)]\s$/` and `taskInputRule /^- \[[ xX]\] $/`. Only `-` differs: its rule is `/^-[ \t]+[^\s[]$/`, which needs one content character typed *after* the space. **Old L2–L5 therefore had no Before/After difference.** F2 and F3 drive the plugin-only path instead: the marker character (or a deletion) completes the prefix last. Input rules never run on deletions or pastes.
2. **Shift+Enter is not a plain newline on a list-marker line.** `onComposerCommandKey` → `listContinuationForEnter(markdown, cursor)`. On a line whose Markdown is `- ` with empty content it deletes the marker (`replacement: ""`), and the whole doc is rebuilt by `setContent`. Plain Enter submits (`sendShortcut` default `"enter"`). Mod+Enter is bound to `composer.sendBackground` and `composer.sendAlternate`. **Never press plain Enter or Mod+Enter in captures.** For extra safety, set Settings → send shortcut to `mod-enter` and still avoid Mod+Enter.
3. **Real clipboard paste never leaves a literal marker in the paragraph.** `handlePaste` → `insertMarkdownParagraphs` → `buildTiptapContent(text)`. `"- "`, `"* "`, `"1. "`, `"- [ ] "` and even `"-"` parse as a **block list**, so pasting at Home of `existing text` gives an empty list item **above** a separate `existing text` paragraph, on **both** revisions (probe: `"- \nexisting text"`). Only text that parses as an inline paragraph reaches the plugin, such as a single space.
4. **Tab on a non-list line is not handled by the composer.** `listIndentForTab` needs a list marker on the current Markdown line; code-block Tab needs a code block. The keydown falls through to the browser, which **moves focus out of the editor**. Later keystrokes don't reach the composer.
5. **Indented literal lines keep a view≠Markdown mismatch on both revisions** (residual, out of scope by design). `"  - x"` typed stays a paragraph and serializes as `"  - x"`. A rebuild (reload, thread switch) makes it an indented `bulletList`. The plugin deliberately mirrors the input rules' "unindented only" boundary.
6. Undo: `COMPOSER_UNDO_GROUP_DELAY = 1000 ms`, and edits that aren't adjacent start a new group. Redo is `Mod-Shift-z` or `Mod-y`.
7. Surfaces: the plugin loads only when `richTextEnabled && !literalText` (`ChatComposer.tsx:3774-3775`). The only other `ComposerPromptEditor` is `SettingsFontPreviews.tsx:34` (no `richTextEnabled` → plain). Pending-question custom answers use the **same** ChatComposer editor (literal if `isLiteralPendingAnswer`). The rich-text toggle exists **only** in Settings ("Show formatted Markdown as you type."). There is no command-palette or keybinding entry, so old R1's "palette/keybinding missing" is not a real entry point.

## How to drive (all rows)

- Two worktrees: After = `~/wt/pr-22997`. Before = a detached worktree at `8d3b061121`. Run `vp i`, then `vp run migrate-dev-db` with the server stopped (copies real data into `<wt>/.t3`), then `vp run dev` in the background. Read the port from the `[dev-runner]` line. Never point at `~/.t3/userdata`, and stop the server only by the PID you captured.
- Open a project thread. The composer is `[data-testid="composer-editor"]` (aria-label `Message`). Click it to focus. Reset between rows with Ctrl+A, Backspace.
- Keys: Playwright `page.keyboard` (or agent-browser `press`/`type`). Use `keyboard.type` with a delay of at least 60 ms. Add explicit 1.1 s pauses where a row says so (undo grouping).
- Paste: grant clipboard permissions and call `navigator.clipboard.writeText(t)`, then press Ctrl+V. Fallback: dispatch a `ClipboardEvent('paste', {clipboardData: dt})` with `dt.setData('text/plain', t)` on the editor (`handlePaste` reads `event.clipboardData`).
- Assert structure, not just pixels: `document.querySelector('[data-testid=composer-editor]').innerHTML` (look for `ul`/`ol[start]`/`ul[data-type=taskList]`/`li[data-marker]`), plus the draft Markdown in localStorage (`composerDraftStore`).

## Fixtures

All rows are `recording` unless stated otherwise. "Lit" means a literal paragraph showing the marker text. "MD" means the serialized draft Markdown, which is the same on both revisions unless stated.

| ID | Name | Exact keystrokes (rich composer, empty, focused) | Expected BEFORE | Expected AFTER | Kind | Unit tests covering it |
|---|---|---|---|---|---|---|
| F1 | `-` completed later, two lines (core bug; old L1) | type `-existing text`; Shift+Enter; type `second line`; Ctrl+Home; ArrowRight; pause 1.1 s; Space; Ctrl+End | Line 1 Lit `- existing text`; MD `- existing text\nsecond line` (view≠MD, the bug) | On Space, line 1 → bullet `existing text` (marker `-`), caret at item start; line 2 stays a paragraph; Ctrl+End reaches line 2 end; view = rebuild of MD | recording | "converts a dash edited on an earlier line…", "converts a dash and space typed before existing text" |
| F2 | `*`, `+`, `1)` where the marker char comes last (plugin-only path) | for each M in `*`, `+`: type ` existing` (leading space), Home, type M. For `1)`: ` existing`, Home, type `1`, then `)` | Lit `* existing` / `+ existing` / `1) existing` (no input rule: textBefore lacks the trailing space) | bullet marker `*` / `+`; ordered `start=1`, `data-marker="1)"`; converts on the completing keystroke; `1` alone stays Lit (`1 existing` is not a marker) | recording | "converts %j inserted in front of existing text" (`* `, `+ `, `3) `) as one insert only |
| F2-ctrl | Control: old L2–L5 flow | type `*existing`, Home, ArrowRight, Space (repeat `+`, `1.`, `1)`) | **Already a list** (input rule) | same list; no difference | recording | n/a |
| F3 | Ordered start attribute | ` existing`, Home, `3`, `.`; separately ` existing`, Home, `1`, `.` | Lit `3. existing` | `ol start=3`, renders `3.`, MD `3. existing`; `1.` → `start=1` | recording | each `4. ` |
| F4 | Deletion completes marker | type `*x existing`; Home; ArrowRight; Delete | Lit `* existing` (input rules ignore deletions) | bullet `*` `existing` | recording | none (probe `DELETE-completes-star` only) |
| F5 | Task completed later | (a) `-[ ] existing`, Home, ArrowRight, Space; (a′) same with `-[x] existing`; (b) Shift+Enter-free two-line: type `other`, Home, Shift+Enter, ArrowUp, type `- [ ]` (no trailing space), ArrowDown | (a) Lit `- [ ] existing`; (b) Lit `- [ ]` | (a) taskList unchecked `existing`; (a′) checked; (b) stays Lit while caret is on the line (task with no `contentSpace` is deferred), then becomes an empty task item (`contentSpace=""`) on ArrowDown; MD `- [ ]\nother` either way | recording | each `- [ ] `, `- [x] `; "keeps the dash gesture available until a task marker is complete" |
| F6 | Undo and redo grouping | run F1 to the converted state; pause 1.1 s; Ctrl+Z; pause; Ctrl+Shift+Z; pause; Ctrl+Z | Ctrl+Z removes the space → Lit `-existing text`; redo → Lit `- existing text` | **One** Ctrl+Z → paragraph `-existing text`, caret after `-`, no list; redo → bullet again; no reconversion after undo | recording | "undoes the insertion and conversion together and can redo them" |
| F7 | Fresh line-start marker with active-line deferral | (a) type `- ` → observe → type `x`; (b) reset, type `- [`, `x`, `]` one at a time, then Space; (c) reset, type `- [link](url)`; (d) reset, type `- [ ]x`; (e) reset, type `-` then ArrowRight | (a) Lit `- `, then bullet `x` (input rule); (b) Lit through `- [x]`, task on Space (input rule); (c) Lit `- [link](url)` (the rule refuses `[`); (d) Lit `- [ ]x`; (e) Lit `-` | (a) **`- ` stays Lit** (deferred) until `x`, then bullet (input rule, same as Before); (b) same as Before: deferral keeps `- [`/`- [x`/`- [x]` Lit; (c) **converts at `- [l`** to bullet, final item `[link](url)`, caret inside the item; (d) bullet with item text `[ ]x` (MD `- [ ]x` rebuilds identically); (e) Lit (no separator). Edge: Ctrl+A on an active `- ` line counts as leaving the line (`$from.depth` 0), so the line converts | recording | "keeps the dash gesture…", "converts a dash before existing bracket-prefixed text %j", "leaves %j as a paragraph" (`-`) |
| F8 | Inactive pending `- `, and pending survives a move (re-derived E1; C2 in a real UI) | type `other`; Home; Shift+Enter; ArrowUp; type `- `; ArrowDown. Then reset and repeat up to `- `; then Home; Shift+Enter (splits above; the `- ` line moves down with the caret); ArrowUp | Lit `- ` throughout; MD `- \nother` rebuilds as an empty bullet (mismatch) | First run: on ArrowDown, line 1 → **empty bullet item**; `other` untouched. Second run: the moved `- ` line stays Lit while focused; on ArrowUp it (not the new empty line, not `other`) becomes the empty bullet | recording | none direct (probe `dash-space-after-leave`) |
| F8-E1 | **Old E1 corrected** | type `- `; Shift+Enter | Marker deleted, composer **empty**, no second paragraph (Markdown list continuation exits the empty item) | **same** | recording | n/a (ChatComposer path) |
| F9 | Paste paths (re-derived P1 and H1) | (a) type `existing text`; Home; paste `- ` (repeat `* `, `1. `, `1) `, `- [ ] `); Ctrl+Z. (b) type `-existing`; Home; ArrowRight; paste ` ` (one space); Ctrl+Z; Ctrl+Shift+Z. (c) same as (b) but paste two spaces | (a) **empty list item of that type above, `existing text` stays a separate paragraph**; MD `- \nexisting text`; one Ctrl+Z → `existing text`. (b) Lit `- existing`; undo removes the space. (c) Lit `-  existing` | (a) **identical to Before** (plugin not reached). (b) bullet `existing` **immediately**, no extra keystroke; one Ctrl+Z → `-existing`; redo → bullet. (c) bullet with `space="  "`, MD `-  existing` preserved | recording | "normalizes pasted markers without waiting…" uses a synthetic tr; it does **not** exercise `handlePaste`; no test covers (a) |
| F10 | Mid-line markers and indentation (old W1) | (a) type `text more`; ArrowLeft×4; type `- `. (b) type `a - b`. (c) type two spaces then `- x`. (d) CDP `Input.insertText {text:"\t"}`, then type `1. x`. (e) **Do not** use the Tab key on an empty line | (a) Lit `text - more`; (b) Lit; (c) Lit `  - x`; (d) Lit `\t1. x` | **same as Before** for (a)–(d): `parseListPrefix` fails, or `prefix.indent` → skip. (c)/(d) MD rebuilds as an indented list on both revisions (residual, fact 5). **W1 explanation:** pressing Tab on a non-list line moves browser focus out of the composer (fact 4). The old leading-Tab segments typed outside the editor or after a refocus, so their before/after divergence is a capture artifact. Correct expectation: identical | recording | "leaves %j as a paragraph" (`a - b`) |
| F11 | Styled marker stays literal | (a) type `**-**existing` (bold input rule makes `-` bold); Home; ArrowRight; Space. (b) type `` `-`existing ``; caret right after the `-` inside the code; Space. (c) reload a draft whose MD is `**- x**` | Lit for (a) and (b); MD `**-** existing` / `` `- `existing `` (or equivalent) | **same**: a mark inside `[0, markerLength)` → skip; (c) restored paragraph untouched, and typing on it does not convert | recording | "does not turn an inline code marker into a list", "preserves styled markers when restoring %j" |
| F12 | Already a list / blockquote / code / heading untouched | (a) type `- outer` (→ bullet); Home; type `- `. (b) type `> quoted` (→ quote); Home; `- `. (c) type ```` ``` ````; Shift+Enter (→ code block); type `- x`. (d) type `# Title`; Home; `- ` | Lit marker text inside the item, quote, code or heading | **same** (depth≠0 / non-paragraph skip); node tree unchanged | recording | "does not convert paragraphs inside %j" (code, nested list, quote) |
| F13 | Two paragraphs: only the changed one converts; undo-restored literal is left alone | type `second`; Home; Shift+Enter; ArrowUp; type `- `; pause 1.1 s; type `x` (→ bullet via input rule); pause 1.1 s; Ctrl+Z (→ Lit `- `); ArrowDown; End; type ` edit` | Same as After | Line 1 stays **Lit `- `** after leaving it (the history tr cleared pending, and line 1 is unchanged); line 2 `second edit` paragraph; editing line 1 again later re-arms it | recording | "does not immediately reconvert a literal marker restored by undo", "only normalizes changed blocks" |
| F14 | Draft persistence and remount (old R1) | run F1; (a) switch to another thread and back; (b) reload the page; (c) Settings → turn off "Show formatted Markdown as you type.", return, then turn it back on | (a)/(b) line 1 **jumps from Lit to bullet** (the reported bug); (c) plain mode shows `- existing text` / `second line` | Bullet before and after (a)/(b), so no visual jump; (c) plain mode shows the same Markdown; rich mode shows the bullet again | recording (+ still of the plain view) | first test's `buildDocJson` round-trip equality |
| F15 | IME composition (C1) | Not via `keyboard.type`. Drive through a Playwright CDPSession on Chromium: `Input.imeSetComposition {text:"- にほんご", selectionStart:6, selectionEnd:6}` → `{text:"- 日本語",…}` → commit with `Input.insertText {text:"- 日本語"}` → pause → End → Ctrl+Z. Commit **≥2 characters** after `- `: with one (`- 日`), Tiptap's `compositionend` input rule converts on both revisions. First check with a `compositionstart`/`compositionend` listener that the events fire; if they don't, mark the row **unverifiable in headless Chrome** (no OS IME) | Lit `- 日本語`, never converts | Stays Lit during composition **and right after commit** (the commit tr carries `composition` meta); converts on the next non-composition tr (End) → bullet `日本語`; Ctrl+Z → back to empty (one step, matching the unit test) | recording | "waits for a non-composition transaction before changing paragraph structure" (synthetic meta only) |
| F16 | Surfaces and controls (old V2–V9 collapsed) | (a) Settings rich text off → F1 keys; (b) Settings → typography font preview editor → F1 keys; (c) if a pending question with a custom answer is reachable, F1 keys in it; (d) disabled composer (no project / connecting) → type | (a)/(b) Lit; (c) Lit; (d) no input | (a)/(b) **Lit, same as Before** (plain extensions, no plugin); (c) converts if the answer is non-literal (same editor instance as F1), Lit if `isLiteralPendingAnswer`; (d) no input. V2–V4 are code-equivalent to F1, so capture is optional | recording (a,b) / still (d) | none |

**Row count: 16 fixtures** (F1–F16). F2-ctrl and F8-E1 are control sub-rows.

## Corrected expectations for previously contradicted rows

- **P1** (paste a marker at Home): the old expectation "immediate list with the text kept in it" is wrong on both revisions. Paste parses Markdown blocks, so the result is an empty list item **plus** a separate paragraph, identical Before and After. The plugin paste path is F9b (paste a space after `-`).
- **H1** (paste `- `, then undo and redo): the undo/redo itself is fine (one step), but the pasted state is the empty item + paragraph from P1 on both revisions. Use F6 or F9b for the PR's grouping claim.
- **E1** (`- ` then Shift+Enter): Shift+Enter deletes the empty marker through Markdown list continuation on both revisions. The inactive-pending path is F8, which leaves the line with an arrow key.
- **W1** leading Tab: the composer doesn't handle Tab on a non-list line, so focus leaves the editor (fact 4). The divergence is a capture artifact. Expected identical, with indented lines literal on both. Use CDP `Input.insertText("\t")` for a literal tab.
- **L2–L5**: Before already converts through input rules. Use F2/F3/F4 for a real difference.
- **R1**: no palette or keybinding entry exists for rich text, so the toggle is Settings-only.
- **C2**: pending-map behaviour is reachable in a real UI only through F8's second run. Composition interleaved with a pending line elsewhere can't happen: the deferred line must hold the caret, and composing elsewhere moves it.

## Residual risks found (not regressions)

1. Indented literal list lines still change on rebuild (fact 5), on both revisions.
2. After an undo restores a literal `- ` (F13), the view and Markdown disagree until that line is edited again. This is intentional ("does not immediately reconvert").
3. Pasting a bare marker at Home splits the line (F9a). This is pre-existing paste behaviour, untouched by the PR.
