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.
BlockHTMLEmitter now emits class="prism-task" on checkbox items; the stylesheet rules that existed since the T-1542 cutover finally apply.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.--prism-item-gutter task override is removed; the structural per-level rules now cover task items correctly.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.
1d94d240 Fix task list items rendering a bullet and no space before the text 9aa1b1ba Grow the task-list checkbox to 1.2em 91810322 Grow the task-list checkbox to 1.5em cb23f7e4 Pin the task checkbox arithmetic and spell its pull-back as a derivation 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.
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.
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.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.DocumentCSSNoteGutterRulesTests asserts the checkbox arithmetic and that no task-specific gutter override exists.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.
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.
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.
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.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
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
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
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.
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.
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.
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.)| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | document.css checkbox rule | Left 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. |
| minor | document.css comment | Comment 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. |
| minor | Test coverage | No 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. |
| nit | BlockHTMLEmitter renderListMarkup | item.checkbox was tested for nil and then unwrapped separately. | Class emission folded into the existing if-let branch. |
| nit | BlockHTMLEmitterTests | New 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. |
| nit | WebParityFixtureTests | The task-list fixture expectations could include the class string. | Skipped: the synthetic emitter test already pins it. |
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
| Suite | Test | Job or artifact | Message |
|---|---|---|---|
| prismTests.Void-element normalisation — growth and equivalence (T-1951) | G4: the parse entry point itself no longer stalls on half-written tags | — | RawHTMLImageScanGrowthTests.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 Persistence | URL session saves and restores scroll position | — | ScrollPositionStoreTests.swift:249: Expectation failed: (ScrollPositionStore.load(for: docId) → nil) == "code-block-7-10" |
| prismTests.NotesManagerLoadRaceTests | Relocation save from a resuming load does not delete a note created during it | — | NotesManagerLoadRaceTests.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. |
Derived by declaration name, from the diff (no baseline run).
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.
prism/Resources/mermaid.min.js — blob over 1 MBClick to expand.
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)
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
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)])
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))"+ )+ } }
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.
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 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.