prism branch worktree-task-list-checkbox commits 3 + working tree files 5 touched lines +90 / -9

Pre-push review: prism

Task list items on the WebKit document path: emit the prism-task class the stylesheet already targeted, and seat the checkbox in the marker gutter without shifting the item. Base is the merge base with origin/main; the branch is already on the remote as PR #424.

At a glance

  • BlockHTMLEmitter now emits class="prism-task" on checkbox items; the stylesheet rules that existed since the T-1542 cutover finally apply.
  • The checkbox is pulled back into the marker gutter by calc(-1 * (1.5em + 0.4em)); the item itself is not shifted, so task text aligns with sibling items at every level and in ordered lists.
  • The top-level-only --prism-item-gutter task override is removed; the structural per-level rules now cover task items correctly.
  • Two new tests: emitter class pin, and a CSS rule pin for the checkbox arithmetic and the absence of a task-specific gutter override.

Verdict

Ready to push

3 failing tests in the full macOS unit run (make test-quick: 4,940 run, 4,897 passed, 38 skipped) — all three are load flakes in suites this change does not touch (a growth-ratio ceiling missed at 8.10 vs 8.0, and two timing reads returning nil in scroll-persistence and notes-load-race tests), and all three pass on an immediate quiet re-run of their suites (29/29). Both platform builds passed with zero compiler warnings. The full iOS-simulator matrix (make test, make test-ui) could NOT be completed: the machine was under heavy load (a concurrent simulator run from another project, then fseventsd pegged at 175% CPU), live-WebKit tests hit their deadlines in the hundreds and were retried, and the run wedged after one hour. That matrix should be run on a quiet machine before merge. The change itself is a small, well-bounded regression fix: review agents raised no major findings, and the minor ones are fixed in the working tree.

Review findings

6 raised · 4 fixed · 2 skipped

Jump to findings →

Tests

Pass rate: 100% (5039 of 5042)

New tests: 2

Diff coverage: n/a

Jump to tests →

Commits

Three-level explanation

What Changed

Prism shows markdown files. A markdown "task list" is a bullet list where each line starts with [ ] or [x], and the app draws those as a checkbox that is empty or ticked. Since the app moved to drawing documents with a web engine (WebKit), task lists looked wrong: each line had the ordinary bullet dot and a checkbox, and the checkbox was jammed right up against the text.

This change makes task lists look the way they used to: no bullet, just the checkbox where the bullet would have been, with a gap before the text.

Why It Matters

Task lists are common in READMEs, meeting notes, and planning documents. A double marker and cramped text reads as broken and makes the list harder to scan.

Key Concepts

  • HTML emitter: the code that turns the parsed markdown into the HTML the web engine displays. Think of it as a translator.
  • Stylesheet (CSS): a separate file describing how HTML should look: sizes, colours, spacing. It only affects an element if the element is labelled the way the stylesheet expects.
  • The bug: the stylesheet already had rules for task items, written for a label called prism-task. The translator never attached that label, so the rules never applied. Like a coat check ticket that was printed but never handed out.

Changes Overview

  • BlockHTMLEmitter.renderListMarkup now emits <li class="prism-task"> for items with a checkbox. The class existed in document.css since the T-1542 cutover but was never emitted.
  • document.css: the task rules are reworked. Instead of shifting the whole <li> left by 1.4em and relying on a task-specific --prism-item-gutter override, the item stays in place and only the checkbox is pulled back into the marker gutter: width: 1.5em; margin-right: 0.4em; margin-left: calc(-1 * (1.5em + 0.4em)). The old top-level-only gutter override is deleted.
  • Tests: an emitter test pins the class on task items and its absence on plain items; a rule-pin test in DocumentCSSNoteGutterRulesTests asserts the checkbox arithmetic and that no task-specific gutter override exists.

Implementation Approach

The stylesheet's list gutter column (where the add-note "+" and note-indicator dot sit) is keyed per nesting level on structure, never on the class. The original approach of shifting the item left required a compensating gutter offset for every level and list type, but only the top-level <ul> case was written. Pulling back only the checkbox leaves the item box untouched, so task items inherit exactly the same gutter offset as their sibling bullet items at every level and in ordered lists, and the text column stays aligned with siblings.

The negative margin is spelled as calc(-1 * (box + gap)) rather than -1.9em, matching the sheet's convention of keeping derivations visible, and the pin test reads the two terms back and checks they equal the declared width and gap.

Trade-offs

  • The 1.5em box overhangs the 1.6em marker gutter by 0.3em. Measured clearance from the "+" chip at every enumerated level is fine; at the uncapped base level (7+ nesting) the chip already overlapped the bullet marker and now overlaps the checkbox the same way. Pre-existing, documented in the CSS comment.
  • macOS WebKit draws the native checkbox at its own control size regardless of the box (measured 16.5px at 1.5em), so text alignment there is within 1px rather than exact. iOS scales the control to the box, which is where the size was tuned by eye (1em, 1.2em, then 1.5em over three commits).

Technical Deep Dive

The class is emitted inside the existing if let checkbox branch, so the open tag and the input are written together and attribute order (class before data-prism-sub) is fixed by construction. Nested lists reached via renderInnerBlock (no section, subIDPrefix == nil) get the class too, since it is keyed on the item's checkbox rather than on anchorability.

Cascade after the change: --prism-item-gutter is declared on li (base -1.5em) and per level on structural selectors (section > ul/ol > li, nested :is(ul, ol) > li chains). The deleted section > ul > li.prism-task rule had higher specificity than the level rules but only matched top-level unordered task items; nested and ordered task items were previously shifted -1.4em with no matching correction, so their "+" was off column. Removing the rule, together with not shifting the item, makes the column correct for every case the level rules enumerate.

Layout cost is unchanged: the child combinator selector is cheaper than the old descendant one and only tests <input> elements; the 1.5em box at vertical-align: middle fits inside the 1.6 line box, so item height does not inflate. prism-notes.js inserts the "+" as firstChild of the <li>, which does not affect the > input match, and all its queries key on data-prism-sub, never on class.

Architecture Impact

Minimal. The emitter/stylesheet contract gains one more literal (prism-task) shared between the two, consistent with how every other class in the emitter is handled (no named constants). The two new tests bind the halves: the emitter test pins the class, and the CSS pin requires a rule with that exact selector, so a rename on either side fails a test.

Potential Issues

  • The pin test's ems() helper extracts every em term from margin-left; rewriting the calc with a unitless factor or a custom property would fail it, which is the intended guard but worth knowing when editing.
  • WebParityFixtureTests' task-list expectations were not extended with the class string; the synthetic emitter test covers it.
  • No rendered-geometry test exists (the live WebPage harness does not apply the stylesheet). Alignment was measured in a headless WKWebView with the real stylesheet during this session; on-device look was checked by the author on iPhone.

Important changes — detailed

BlockHTMLEmitter: emit the prism-task class on checkbox items

prism/Services/WebRendering/BlockHTMLEmitter.swift

Why it matters. This is the whole bug: the stylesheet targeted a class the emitter never wrote, so task items kept their bullet and the checkbox had no margin.

What to look at. BlockHTMLEmitter.swift renderListMarkup, the if-let checkbox branch

Takeaway. When a stylesheet and an emitter share a literal, a test on only one side cannot see a mismatch. Pin both: the emitter output and the existence of the selector.
Rationale. Class emitted inside the existing if-let so the open tag and input are written together; no named constant because the emitter keeps every class as an inline literal.

document.css: pull back the checkbox, not the item

prism/Resources/WebRenderer/document.css

Why it matters. Shifting the whole item required a compensating gutter offset per level and list type, and only one was ever written. Keeping the item in place makes the structural gutter rules correct for task items for free and aligns text with siblings.

What to look at. document.css li.prism-task rules near line 426; deleted override near line 1100

Takeaway. Prefer moving the replaced element into the gutter over moving the container; the container's other children (absolutely positioned chrome) then need no correction.
Rationale. Stated in the commit body and the CSS comment. Box size tuned on device over three commits; margin spelled as a calc derivation per the sheet's convention.

DocumentCSSNoteGutterRulesTests: pin the checkbox arithmetic

prismTests/WebRendering/DocumentCSSNoteGutterRulesTests.swift

Why it matters. A plausible edit to the box, gap, or margin silently misaligns task text; the rendered geometry cannot be tested in the live harness, so the sheet's own values are checked.

What to look at. taskCheckboxIsPulledBackByItsOwnBox at the end of the suite

Takeaway. Rule-pin tests over a stylesheet can enforce arithmetic relations when no rendered-geometry harness exists.
Rationale. Requested by the code-reuse and quality reviews; reuses the suite's existing parser and column derivation.

Key decisions

Seat the checkbox in the marker gutter rather than shift the item.

The pre-existing CSS shifted the <li> left by 1.4em and corrected the gutter token only for top-level unordered lists. Moving only the checkbox keeps the item box where its siblings are, so the per-level gutter rules apply unchanged and text aligns. Stated in the first commit's body.

Box size 1.5em.

iOS scales the native checkbox to its CSS box; 1em and 1.2em were judged too small on device by the author, 1.5em accepted. macOS draws the control at its native size regardless. The 0.3em overhang past the 1.6em marker gutter is documented and measured clear of the "+" column at every enumerated level.

Margin spelled as calc(-1 * (box + gap)).

Matches the sheet's convention of checkable derivations (the note-dot centring rule does the same) and lets the pin test read the terms back. Applied during this review.

No named constant for the class name.

The emitter holds every class and attribute name as an inline literal; a constant for this one would be the inconsistency. The two tests bind the literal on both sides instead.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minordocument.css checkbox ruleLeft margin was a pre-computed -1.9em while the sheet's convention is a checkable derivation; a box resize could silently desync it.margin-left is now calc(-1 * (1.5em + 0.4em)) with margin-right 0.4em as a longhand; pinned by the new test.
minordocument.css commentComment claimed clearance from the "+" column at any nesting level; at the uncapped base level (7+) the chip overlaps the checkbox (it already overlapped the bullet).Comment scoped to the enumerated levels and notes the pre-existing base-level overlap.
minorTest coverageNo test pinned the CSS side: the checkbox arithmetic, the selector's existence, or the removal of the task-specific gutter override.Added taskCheckboxIsPulledBackByItsOwnBox to DocumentCSSNoteGutterRulesTests.
nitBlockHTMLEmitter renderListMarkupitem.checkbox was tested for nil and then unwrapped separately.Class emission folded into the existing if-let branch.
nitBlockHTMLEmitterTestsNew test pins the full open tag including attribute order, unlike sibling fragment-style assertions.Kept: adjacency is what proves the plain item carries no class, and the order is now fixed by construction.
nitWebParityFixtureTestsThe task-list fixture expectations could include the class string.Skipped: the synthetic emitter test already pins it.

Tests

Source: local run at 2026-09-08T15:10:02+10:00 · snapshot cb23f7e4c8275f5448b4045cb0be4991705b3ace

Baseline: none

Execution: failed · JUnit: 6 files · Coverage: none · Baseline: absent

Coverage scope: as the project configures it

Totals: 5039 passed · 3 failed · 38 skipped · 0 errored · 0 flaky

Failed tests

SuiteTestJob or artifactMessage
prismTests.Void-element normalisation — growth and equivalence (T-1951)G4: the parse entry point itself no longer stalls on half-written tagsRawHTMLImageScanGrowthTests.swift:106: Expectation failed: (ratio → 8.097731176634808) < (ceiling → 8.0): multiplying HTMLImageParser.parse of half-written tags by 4 should cost roughly 4x, not ~16x — HTMLImageParser.parse of half-written tags — 400: 8.55ms, 1600: 69.24ms, ratio 8.10x
prismTests.DocumentSession Scroll PersistenceURL session saves and restores scroll positionScrollPositionStoreTests.swift:249: Expectation failed: (ScrollPositionStore.load(for: docId) → nil) == "code-block-7-10"
prismTests.NotesManagerLoadRaceTestsRelocation save from a resuming load does not delete a note created during itNotesManagerLoadRaceTests.swift:532: Expectation failed: (manager.anchoredNotes[block.id]?.count → nil) == 1: // Guard against a vacuous pass: if the stored note stopped relocating, the // save tail would never run and the assertion below would hold regardless.

New and removed tests

Derived by declaration name, from the diff (no baseline run).

Blast radius

Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base a10f8af78424d95ba00584013606f80922e371f8.

addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Skipped files

Per-file diffs

Click to expand.

prism/Services/WebRendering/BlockHTMLEmitter.swift Modified +5 / -1
diff --git a/prism/Services/WebRendering/BlockHTMLEmitter.swift b/prism/Services/WebRendering/BlockHTMLEmitter.swiftindex d0677129..74e86d2f 100644--- a/prism/Services/WebRendering/BlockHTMLEmitter.swift+++ b/prism/Services/WebRendering/BlockHTMLEmitter.swift@@ -586,10 +586,14 @@ nonisolated enum BlockHTMLEmitter {             // its items are not anchorable and propagate nil down their own nested lists.             let sub = subIDPrefix.map { "\($0)-\(index)" }             let subAttr = sub.map { " data-prism-sub=\"\($0)\"" } ?? ""-            html += "<li\(subAttr)>"             if let checkbox = item.checkbox {+                // A task item carries `prism-task`: document.css drops its marker and seats+                // the checkbox in the marker gutter in its place.                 let checked = checkbox == .checked ? " checked" : ""+                html += "<li class=\"prism-task\"\(subAttr)>"                 html += "<input type=\"checkbox\" disabled\(checked) data-prism-chrome>"+            } else {+                html += "<li\(subAttr)>"             }             let itemSpan = itemOffsets.map { InlineSpan.subspan(offset: $0[index]) } ?? .unmapped             html += renderInline(item.content, in: itemSpan, context: context)
prism/Resources/WebRenderer/document.css Modified +22 / -8
diff --git a/prism/Resources/WebRenderer/document.css b/prism/Resources/WebRenderer/document.cssindex c6b1715c..eec89444 100644--- a/prism/Resources/WebRenderer/document.css+++ b/prism/Resources/WebRenderer/document.css@@ -423,9 +423,28 @@ ul, ol { margin: 0 0 var(--prism-spacing); padding-left: 1.6em; }  * line). Each li is a positioning context so its gutter "+" anchors to it. */ li { margin: 0.1em 0; position: relative; } -/* Task list items (Req 1.1). */-li.prism-task { list-style: none; margin-left: -1.4em; }-li.prism-task input[type="checkbox"] { margin-right: 0.4em; }+/* Task list items (Req 1.1): the checkbox replaces the marker. It is pulled back into the+ * marker gutter with its box sized so the item's text starts where a sibling bullet item's+ * text does, and the item itself is not shifted — so the "+" / note-dot gutter column+ * (`--prism-item-gutter` below) needs no task-specific correction at any nesting level. */+li.prism-task { list-style: none; }++li.prism-task > input[type="checkbox"] {+    /* The pull-back is spelled as box + gap rather than as a constant so the derivation is+     * checkable (pinned by DocumentCSSNoteGutterRulesTests): the text then lands at the+     * item's content edge. 1.5em rather than 1em because iOS scales the checkbox to its box+     * and 1em read as small next to the text (macOS draws it at the native control size+     * regardless); the box then overhangs the 1.6em marker gutter by 0.3em, which is still+     * clear of the "+" / note-dot column at every enumerated `--prism-item-gutter` level.+     * (Past those levels the base -1.5em gutter already overlapped the bullet marker, and+     * overlaps the checkbox the same way.) */+    width: 1.5em;+    height: 1.5em;+    margin: 0;+    margin-right: 0.4em;+    margin-left: calc(-1 * (1.5em + 0.4em));+    vertical-align: middle;+}  /* Inline + block code. */ code, kbd, samp {@@ -1085,11 +1104,6 @@ section[data-prism-block-id] > :is(ul, ol) > li > :is(ul, ol) > li > :is(ul, ol)     --prism-item-gutter: -9.775em; } -/* Task-list items carry no bullet and are shifted left 1.4em; centre their chrome on the dot. */-section[data-prism-block-id] > ul > li.prism-task {-    --prism-item-gutter: -1.975em;-}- /* List-item affordance: positioned out of flow so it reads as beside the item and does  * not inflate the line height (which spaced items too far apart). It sits in the SAME  * leading gutter column as the block-level note dot, so a list's "+" and its note
prismTests/WebRendering/BlockHTMLEmitterTests.swift Modified +12 / -0
diff --git a/prismTests/WebRendering/BlockHTMLEmitterTests.swift b/prismTests/WebRendering/BlockHTMLEmitterTests.swiftindex ff2e9794..a9f54111 100644--- a/prismTests/WebRendering/BlockHTMLEmitterTests.swift+++ b/prismTests/WebRendering/BlockHTMLEmitterTests.swift@@ -162,6 +162,18 @@ struct BlockHTMLEmitterStructureTests {         #expect(doc.html.contains("checked"))     } +    @Test("Task list item carries the class document.css styles, so its marker is dropped")+    func taskListItemClass() {+        // document.css targets `li.prism-task` to hide the bullet and seat the checkbox in+        // the marker gutter; without the class the item rendered a bullet AND a checkbox,+        // with no space after it.+        let items = [ListItem(content: "todo", checkbox: .unchecked),+                     ListItem(content: "plain", checkbox: nil)]+        let doc = BlockHTMLEmitterTestSupport.emit([.list(ordered: false, start: 1, items: items)])+        #expect(doc.html.contains("<li class=\"prism-task\" data-prism-sub=\"item-0\">"))+        #expect(doc.html.contains("<li data-prism-sub=\"item-1\">"))+    }+     @Test("Nested list emits nested <ul>/<ol> with nested sub-IDs")     func nestedList() {         let nested = ListItem.NestedList(ordered: false, start: 1, items: [ListItem(content: "child", checkbox: nil)])
prismTests/WebRendering/DocumentCSSNoteGutterRulesTests.swift Modified +49 / -0
diff --git a/prismTests/WebRendering/DocumentCSSNoteGutterRulesTests.swift b/prismTests/WebRendering/DocumentCSSNoteGutterRulesTests.swiftindex 0a071a46..6389796b 100644--- a/prismTests/WebRendering/DocumentCSSNoteGutterRulesTests.swift+++ b/prismTests/WebRendering/DocumentCSSNoteGutterRulesTests.swift@@ -400,4 +400,53 @@ struct DocumentCSSNoteGutterRulesTests {             """         )     }++    // MARK: - 4. A task item's checkbox sits in the marker gutter, not on the + column++    /// The checkbox replaces the marker WITHOUT shifting the item: it is pulled back into+    /// the marker gutter by exactly its own box plus its gap, so the text lands at the+    /// item's content edge and lines up with sibling bullet items. That is also why no+    /// task-specific `--prism-item-gutter` override may exist — one used to (`-1.975em`)+    /// for the old shift-the-item approach, and with the item unshifted it would put a+    /// task item's "+" 1.4em off its siblings' column.+    @Test("A task item's checkbox box plus gap is the gutter it is pulled back by")+    func taskCheckboxIsPulledBackByItsOwnBox() throws {+        let css = Self.flattened(try Self.loadDocumentCSS())+        let rules = Self.rules(in: css)+        let columns = try Self.columns(in: css)++        let checkbox = try #require(+            rules.first { $0.selector == "li.prism-task > input[type=\"checkbox\"]" },+            "document.css must keep the task checkbox rule"+        )+        let width = try #require(Self.em(Self.value(of: "width", in: checkbox.body)))+        let gap = try #require(Self.em(Self.value(of: "margin-right", in: checkbox.body)))+        let pullBack = Self.ems(Self.value(of: "margin-left", in: checkbox.body))++        // The pull-back is spelled `calc(-1 * (box + gap))`, restating the box and the gap+        // it must equal, so the sheet carries the derivation rather than a constant.+        #expect(+            pullBack == [width, gap],+            """+            margin-left must restate the box (\(width)em) and the gap (\(gap)em) it pulls the \+            checkbox back by, or a task item's text no longer starts where its siblings' does. \+            Declared terms: \(pullBack)+            """+        )+        let leadingEdge = -(width + gap)+        #expect(+            leadingEdge >= columns.topLevelItemGutter + columns.chipWidth,+            """+            the checkbox's leading edge (\(leadingEdge)em) overlaps the "+" chip, which ends at \+            \(columns.topLevelItemGutter + columns.chipWidth)em+            """+        )+        let taskOverrides = rules.filter {+            $0.selector.contains("prism-task") && Self.value(of: Self.token, in: $0.body) != nil+        }+        #expect(+            taskOverrides.isEmpty,+            "a task item shares its siblings' gutter column; found override: \(taskOverrides.map(\.selector))"+        )+    } }
CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex b8c80bbb..0caefefb 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -25,6 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Task list items render the way they did before the WebKit rendering cutover: the checkbox replaces the list marker, with a space before the text (T-1542 regression). `document.css` has carried rules for a `prism-task` item class since the cutover, but `BlockHTMLEmitter` never put that class on the `<li>`, so every task item drew its bullet or number AND the checkbox, with the text jammed against the box. The class is now emitted, and the stylesheet seats the checkbox in the marker gutter rather than shifting the whole item left, so a task item's text lines up with its sibling bullet items at every nesting level and in ordered lists, and the item's add-note "+" and note-indicator column needs no task-specific offset.+ - A document's YAML frontmatter is shown again as a collapsed "Metadata" card at the top of the document, expandable in place to reveal the raw YAML. The WebKit rendering cutover (T-1542) dropped this: the parser still produced the frontmatter block, but the emitter wrote it out as an inert hidden section, so a document that opened with `---` metadata simply started at its first heading with no way to see what the frontmatter said. The card is a native `<details>` disclosure — no script of its own — whose title comes from the localisation catalog, whose body is the YAML as escaped monospaced text, and whose open state is recorded natively like every other disclosure, so it survives a WebContent recovery. Because the block now lays out, the native raw-to-rendered position picker no longer has to skip it to agree with the page about which sections exist, and the pre-cutover SwiftUI `MetadataView` that nothing had rendered since the cutover is removed.  - A note anchored to a table's header row now draws its own indicator dot on the header row, instead of none at all (T-2044). T-1725 had removed the header row's indicator deliberately rather than leave it wrong: with no ordinal to address it by, a header-row note used to fold into the table's block-level dot, which opens a popover scoped to the block and so could never contain the header row's own note — and once that dot started announcing its count out loud, it was announcing a note the tap could not show. The header row now gets an indicator of its own, addressed by `subID: "row-header"` — the same channel T-1745 opened for list items — rather than a second one; `WebDocumentMessageRouter` resolves that exact string back to the header row's anchor, and `prism-notes.js` places the dot inside the header row's own cell, since a `<tr>` may only contain `<td>`/`<th>` and the list-item placement path it reuses cannot be used verbatim. Header-row notes were always visible through the inline note bubble and the notes panel; only the row's own indicator dot was missing. This is the READING half only: a header row still cannot be given a note by long-press or right-click (that gesture falls back to a note on the whole table), so a header-row note continues to arrive by import or through the text-selection path. That is T-2318.

Things to double-check

On-device look at deep nesting.

Seven or more levels of nested task items fall back to the base gutter where the "+" chip overlaps the checkbox. Pre-existing for bullets; confirm it is acceptable or cap the enumerated levels.

macOS checkbox size.

macOS WebKit clamps the native control to its own size (16.5px measured at 1.5em, 17px font). Text alignment there is within 1px. Worth a glance on a Mac, since the size was tuned on iPhone.