diff --git a/apps/web/src/components/PageView.tsx b/apps/web/src/components/PageView.tsx
index 21a461366..a53a17e9c 100644
--- a/apps/web/src/components/PageView.tsx
+++ b/apps/web/src/components/PageView.tsx
@@ -882,7 +882,7 @@ export function PageView({ docId }: { docId: string }) {
ref={titleInputRef}
type="text"
aria-label="Page title"
- className="w-full shrink-0 border-none bg-transparent pl-8 pr-1 text-4xl font-bold leading-tight tracking-tight text-ink-1 outline-none placeholder:text-ink-3"
+ className="w-full shrink-0 border-none bg-transparent px-8 text-[2.5rem] font-bold leading-tight tracking-tight text-ink-1 outline-none placeholder:text-ink-3"
value={page!.title || ''}
onChange={(e) => update({ title: e.target.value })}
onKeyDown={handleTitleKeyDown}
diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css
index d09b7339c..9df9ca421 100644
--- a/apps/web/src/styles/globals.css
+++ b/apps/web/src/styles/globals.css
@@ -44,27 +44,102 @@
}
/* Prose role: the page document reads at 16px (UI chrome stays 13px).
+
+ One rhythm system drives all document typography (exploration 0198):
+ a single flow unit between blocks, "space precedes" headings, one
+ list-indent step, and to-dos whose checkbox + gap equals that step so
+ their text shares the bullet text edge. Horizontal padding is
+ symmetric (the left half doubles as the drag-handle gutter), so the
+ measure is centred and the title and every body block share one left
+ edge. These `.page-prose .ProseMirror` selectors outspecify the editor
+ package's base `.ProseMirror` rules, so the page is the single source
+ of document typography; canvas/compact surfaces keep the base.
+
The bottom padding keeps breathing room after the last line *inside*
the editable surface, so clicking it places the caret (the editor
stretches to the full page height via flex). */
.page-prose .ProseMirror {
- @apply leading-relaxed pb-[20vh];
+ /* Rhythm tokens — em-based, so spacing scales with the font size. */
+ --prose-flow: 0.75em; /* between sibling blocks */
+ --prose-flow-tight: 0.2em; /* between list items */
+ --prose-h-before: 1.7em; /* heading top — space precedes */
+ --prose-h-after: 0.4em; /* heading bottom */
+ --prose-indent: 1.55em; /* one list-indent step (~25px) */
+ --prose-check: 1.15em; /* checkbox size */
+ --page-pad-x: 2rem; /* symmetric gutter; the drag handle lives in it */
+
font-size: var(--font-prose-size);
+ line-height: 1.65;
+ padding-bottom: 20vh;
}
-.page-prose .ProseMirror p {
- @apply mb-4;
+/* Symmetric padding centres the measure and replaces the old asymmetric
+ pl-8/pr-1; the left half doubles as the drag-handle gutter. The
+ `.tiptap.ProseMirror` compound outspecifies the editor's shared
+ `[&_.ProseMirror]:px-1/pl-8` utility, so this wins only on the page
+ surface (canvas/compact keep their tighter padding). */
+.page-prose .ProseMirror.tiptap {
+ padding-left: var(--page-pad-x);
+ padding-right: var(--page-pad-x);
}
-.page-prose .ProseMirror ul,
-.page-prose .ProseMirror ol {
- @apply pl-6 mb-4;
+/* Flow: clear default block margins, then one unit between siblings. */
+.page-prose .ProseMirror > * {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+.page-prose .ProseMirror > * + * {
+ margin-top: var(--prose-flow);
+}
+/* Headings bind to the text they introduce: more space above than below. */
+.page-prose .ProseMirror > * + :is(h1, h2, h3, h4, h5, h6) {
+ margin-top: var(--prose-h-before);
+}
+.page-prose .ProseMirror :is(h1, h2, h3, h4, h5, h6) + * {
+ margin-top: var(--prose-h-after);
}
-.page-prose .ProseMirror li {
- @apply my-1;
+/* Lists: one indent step per level; markers hang in the indent gutter. */
+.page-prose .ProseMirror :is(ul, ol):not([data-type='taskList']) {
+ padding-inline-start: var(--prose-indent);
+}
+.page-prose .ProseMirror li + li {
+ margin-top: var(--prose-flow-tight);
+}
+.page-prose .ProseMirror li > :is(ul, ol) {
+ margin-top: var(--prose-flow-tight);
+}
+
+/* Blockquote: align its text to the list-content edge for one rhythm. */
+.page-prose .ProseMirror blockquote {
+ padding-inline-start: calc(var(--prose-indent) - 4px);
+}
+
+/* To-dos: reset the inherited list indent (the +24px bug), lay the row
+ out as flex, and size checkbox + gap to one indent step so the to-do
+ text lands on the same left edge as bullet text. The checkbox is
+ centred on the first line with the line-relative `lh` unit, not a
+ magic pixel offset, so it stays centred at any font size. */
+.page-prose .ProseMirror ul[data-type='taskList'] {
+ padding-inline-start: 0;
+ list-style: none;
+}
+.page-prose .ProseMirror ul[data-type='taskList'] li {
+ gap: calc(var(--prose-indent) - var(--prose-check));
+}
+.page-prose .ProseMirror ul[data-type='taskList'] li > label {
+ margin-top: 0.35em; /* fallback for browsers without the lh unit */
+ /* Centre the box on the first line. The small nudge accounts for the
+ checkbox border + the text's optical centre sitting below the line
+ box centre, measured to land the box centre on the text centre. */
+ margin-top: calc((1lh - var(--prose-check)) / 2 + 0.2em);
+}
+.page-prose .ProseMirror ul[data-type='taskList'] li > label input[type='checkbox'] {
+ width: var(--prose-check);
+ height: var(--prose-check);
}
+/* Inline code + code fences keep the monochrome muted background. */
.page-prose .ProseMirror code {
@apply px-1.5 py-0.5 rounded font-mono text-sm bg-muted;
}
diff --git a/docs/explorations/0198_[x]_NOTION_GRADE_PAGE_EDITOR_TYPOGRAPHY_AND_ALIGNMENT.md b/docs/explorations/0198_[x]_NOTION_GRADE_PAGE_EDITOR_TYPOGRAPHY_AND_ALIGNMENT.md
new file mode 100644
index 000000000..2d7439008
--- /dev/null
+++ b/docs/explorations/0198_[x]_NOTION_GRADE_PAGE_EDITOR_TYPOGRAPHY_AND_ALIGNMENT.md
@@ -0,0 +1,622 @@
+# Notion-Grade Page Editor Typography And Alignment
+
+## Problem Statement
+
+The page document editor works, but its typography reads as "developer
+default," not "product." Margins, list indentation, bullets, checklists,
+and heading rhythm are each styled with ad-hoc Tailwind utilities spread
+across three files that fight one another. The visible result: elements
+that *should* share a left edge do not, vertical spacing is uneven, and
+the hierarchy between the page title and an H1 is almost nonexistent.
+
+The brief: make the pages UI **clean and aligned**, on par with Notion
+and Obsidian. Specifically — get the **margins** right, the **tab/list
+indentation** right, the **bullets** right, and the **checklist**
+alignment right. Then *render it, screenshot it, measure whether
+everything lines up, and keep tweaking until it does.*
+
+This exploration does exactly that: it renders the current editor with a
+kitchen-sink document, measures the real left-edge offsets and spacing
+in the browser, traces every number back to the CSS that produced it,
+and proposes a single rhythm system (plus a screenshot-driven iteration
+loop) to replace the current patchwork.
+
+## Executive Summary
+
+The editor's document styling is produced by **three uncoordinated
+layers** stacking on `.ProseMirror`:
+
+1. `packages/editor/src/styles/editor.css` — base spacing for *every*
+ editor surface (canvas cards, compact inline, full page).
+2. `apps/web/src/styles/globals.css` `.page-prose` — app-level overrides
+ for the page surface, at **equal or near-equal specificity** to the
+ base, so the cascade is decided by bundle order, not intent.
+3. `packages/editor/src/components/RichTextEditor.tsx:959` — inline
+ Tailwind arbitrary variants (`[&_.ProseMirror]:px-1`,
+ `[&_.ProseMirror]:pl-8`) that bake the drag-handle gutter into an
+ **asymmetric content padding**.
+
+Rendering a representative page and measuring it (numbers below are real
+`getBoundingClientRect` / `getComputedStyle` values against the 768px
+content column) exposes four concrete defects:
+
+- **Four different left edges at the same nesting level.** Paragraph
+ text starts at **56px**, blockquote text at **76px**, bullet/ordered
+ text at **80px**, and **to-do text at 104px**. To-do text is a full
+ **24px to the right** of bullet text — they should be identical.
+- **The text column is off-center.** Body text sits **56px from the
+ left** of the column but only **28px from the right** — the
+ drag-handle gutter is baked into `pl-8` instead of living in true
+ overflow, so the whole measure is shoved 28px right of center.
+- **Asymmetric vertical rhythm.** Paragraphs have `margin-top: 4px` but
+ `margin-bottom: 16px`; bullet lists `8px` / `16px`; task lists `0` /
+ `16px`. There is no shared spacing unit, so nothing lands on a rhythm.
+- **No title↔H1 hierarchy.** Title is `36px/700`, H1 is `30px/700` —
+ same weight, ~1.2× size. They read as siblings, not parent/child.
+
+The root cause is architectural, not cosmetic: **two stylesheets with
+competing equal-specificity selectors and no shared spacing scale.** The
+fix is to define document typography **once**, as a single
+`:where()`-scoped layer (zero specificity) driven by a small set of
+**em-based rhythm tokens**, applied to the page surface only — and to
+move the drag-handle gutter out of content padding so the measure is
+symmetric and every text block shares one left edge.
+
+The recommendation (Option C) is a **token-driven `.xnet-prose` rhythm
+layer in the editor package**, validated by a **kitchen-sink visual
+fixture wired into the existing `scripts/visuals` CI** so alignment can
+be screenshotted, diffed, and locked against regression — the exact
+loop the brief asks for.
+
+## Current State In The Repository
+
+### The render path
+
+A page renders through this component chain:
+
+```mermaid
+flowchart TD
+ PV["PageView.tsx
max-w-3xl · px-6 · pt-10
title input: pl-8 pr-1 text-4xl"]
+ ED["Editor.tsx
className='page-prose'
toolbarMode='auto'"]
+ RTE["RichTextEditor.tsx:959
div.relative.h-full + page-prose
EditorContent →
[&_.ProseMirror]:px-1 [&_.ProseMirror]:pl-8
+ class 'xnet-editor'"]
+ PM[".ProseMirror (contenteditable)"]
+ PV --> ED --> RTE --> PM
+
+ ECSS["editor.css
BASE: .ProseMirror h1/ul/ol/li/p/taskList
applies to ALL surfaces"]
+ GCSS["globals.css
.page-prose .ProseMirror p/ul/ol/li
+ .xnet-editor color tokens"]
+ ECSS -.cascades onto.-> PM
+ GCSS -.cascades onto.-> PM
+
+ classDef warn fill:#fde68a,stroke:#d97706,color:#111
+ class RTE,GCSS warn
+```
+
+Key files:
+
+- [apps/web/src/components/PageView.tsx:880](apps/web/src/components/PageView.tsx) —
+ the page container: `mx-auto flex w-full max-w-3xl grow flex-col px-6
+ pt-10`, with the title `` at `pl-8 pr-1 text-4xl font-bold
+ leading-tight tracking-tight`.
+- [apps/web/src/components/Editor.tsx:84](apps/web/src/components/Editor.tsx) —
+ passes `className="page-prose"` and `toolbarMode="auto"`.
+- [packages/editor/src/components/RichTextEditor.tsx:959](packages/editor/src/components/RichTextEditor.tsx) —
+ wraps `.ProseMirror` with `[&_.ProseMirror]:px-1 [&_.ProseMirror]:pl-8`
+ (32px left, 4px right) and the `xnet-editor` class.
+- [packages/editor/src/styles/editor.css](packages/editor/src/styles/editor.css) —
+ base typography for the editor primitive.
+- [apps/web/src/styles/globals.css:50](apps/web/src/styles/globals.css) —
+ `.page-prose` overrides + `.xnet-editor` monochrome color tokens.
+- [packages/ui/src/theme/tokens.css](packages/ui/src/theme/tokens.css) —
+ the design system (`--font-prose-size: 16px`, ink ramp).
+- [packages/editor/src/components/DragHandle/DragHandle.tsx](packages/editor/src/components/DragHandle/DragHandle.tsx)
+ + [useDragHandle.ts:30](packages/editor/src/components/DragHandle/useDragHandle.ts) —
+ the drag handle, absolutely positioned at `left: handleOffset` with
+ `handleOffset = -6`.
+- [packages/editor/src/extensions/page-tasks/](packages/editor/src/extensions/page-tasks/) —
+ `PageTaskItemExtension`, whose custom NodeView renders a **round**
+ checkbox, *not* the square one defined in `editor.css` (the editor.css
+ `input[type=checkbox]` rules are partly dead for page tasks).
+
+### The two competing stylesheets
+
+Base, in `editor.css` (low specificity, applies everywhere):
+
+```css
+.ProseMirror p { margin-block: var(--editor-rhythm-1); } /* 4px top+bottom */
+.ProseMirror ul:not([data-type='taskList']) { @apply list-disc pl-6 my-2; }
+.ProseMirror ol { @apply list-decimal pl-6 my-2; }
+.ProseMirror li { @apply mb-1; }
+.ProseMirror ul[data-type='taskList'] { @apply list-none pl-0; }
+.ProseMirror ul[data-type='taskList'] li { @apply flex items-start gap-2; }
+.ProseMirror ul[data-type='taskList'] li > label { @apply flex-shrink-0 mt-1; }
+.ProseMirror h1 { @apply text-3xl font-bold mt-8 mb-3; }
+.ProseMirror h2 { @apply text-2xl font-semibold mt-6 mb-2; }
+.ProseMirror h3 { @apply text-xl font-semibold mt-5 mb-2; }
+```
+
+Overrides, in `globals.css` `.page-prose`:
+
+```css
+.page-prose .ProseMirror { @apply leading-relaxed pb-[20vh]; font-size: var(--font-prose-size); }
+.page-prose .ProseMirror p { @apply mb-4; } /* bottom only */
+.page-prose .ProseMirror ul,
+.page-prose .ProseMirror ol { @apply pl-6 mb-4; } /* matches taskList too! */
+.page-prose .ProseMirror li { @apply my-1; }
+```
+
+The two `ul` rules are the crux. Specificity:
+
+| Selector | Source | Specificity | Sets |
+|---|---|---|---|
+| `.ProseMirror ul[data-type='taskList']` | editor.css | `0,2,1` | `pl-0`, list-none |
+| `.page-prose .ProseMirror ul` | globals.css | `0,2,1` | `pl-6`, `mb-4` |
+
+**Identical specificity.** The winner is whichever stylesheet the
+bundler emits last. In the running app, `.page-prose` wins, so the task
+list gets `padding-inline-start: 24px` it was never meant to have — and
+the to-do text is pushed 24px past the bullet text.
+
+### Measured "before" — rendered and screenshotted
+
+A page was created in the running app and seeded with a kitchen-sink
+document (title, H1–H3, two paragraphs, a 3-level nested bullet list, an
+ordered list, a task list with a checked and an open item, a
+blockquote), then measured in the browser.
+
+Top of the document (title → H1 → paragraph → nested bullets):
+
+> Title "Notion-grade typography test" (36px) sits directly above
+> "Heading one" (30px) — nearly the same size and identical weight. The
+> body paragraph and both headings share a left edge; bullets step in
+> 24px per level (•, ○, ▪).
+
+Mid-document (ordered list → task list → blockquote) reveals the
+misalignment plainly: the ordered-list text, the to-do text, and the
+blockquote text each start at a **different x**.
+
+**Left-edge offsets** (px from the left of the 768px content column):
+
+```mermaid
+flowchart LR
+ subgraph col["content column (768px)"]
+ direction TB
+ P["paragraph / heading text — 56px"]
+ BQ["blockquote text — 76px"]
+ LI["bullet & ordered text — 80px"]
+ TK["to-do text — 104px ← +24px off"]
+ end
+ classDef bad fill:#fecaca,stroke:#dc2626,color:#111
+ classDef ok fill:#dcfce7,stroke:#16a34a,color:#111
+ class TK bad
+ class P,LI ok
+```
+
+| Element | Text left | Right gap | Notes |
+|---|---:|---:|---|
+| Paragraph / H1 / H2 / H3 | **56px** | **28px** | `px-6` (24) + `pl-8` (32); right is `px-6` + `pr-1` (4) → **off-center** |
+| Blockquote text | **76px** | — | 56 + border-l-4 (4) + `pl-4` (16) |
+| Bullet text (L1) | **80px** | — | 56 + `pl-6` (24); nested L2 = 104, L3 = 128 |
+| Ordered text (L1) | **80px** | — | 56 + `pl-6` (24) |
+| **To-do text (L1)** | **104px** | — | 56 + `pl-6` (24, the bug) + checkbox 16 + gap 8 |
+
+**Computed spacing** (`getComputedStyle`):
+
+| Element | margin-top | margin-bottom | padding-inline-start | size / line-height |
+|---|---:|---:|---:|---|
+| Paragraph | **4px** | **16px** | — | 16px / 26px (1.625) |
+| Bullet `ul` | 8px | 16px | 24px | — |
+| Task `ul` | **0px** | 16px | **24px** ← bug | list-none |
+| Task `li` | — | — | 0 | flex, `align-items:flex-start`, gap 8 |
+| H1 | 32px | 12px | — | **30px / 700** |
+| Title `` | — | — | — | **36px / 700** |
+
+Every defect in the Executive Summary is reproduced and quantified by
+these numbers.
+
+## External Research
+
+Prior art and best-practice values (sources in **References**):
+
+### Notion & Obsidian metrics
+
+- **Content width:** Notion's normal column is ~**720px** (per
+ `react-notion-x`'s `--notion-max-width: 720px`); our `max-w-3xl` is
+ 768px — in the same ballpark. Notion uses **symmetric** horizontal
+ padding and floats block controls (the `⋮⋮` handle) in a left gutter
+ that lives in *overflow*, not in content padding.
+- **Base prose:** 16px, line-height **1.5** (Notion) / 1.5–1.7 common.
+ Ours is 16px / 1.625 — good.
+- **List indent:** Notion uses `padding-inline-start: 1.7em` (~27px) for
+ bullets, `1.6em` for numbers. Obsidian's Minimal theme exposes
+ `--list-indent` (default `2em`) and `--list-bullet-end-padding` (the
+ marker→text gap) as first-class variables. Both indent recursively by
+ the same step per level.
+
+### Checkbox vertical alignment
+
+The canonical fix for "checkbox vs first line of text" is **line-height-
+relative centering**, not a magic `margin-top`:
+
+```css
+/* modern: the lh unit == computed line-height */
+input[type="checkbox"] { margin-block-start: calc((1lh - 1em) / 2); }
+/* fallback formula, font-size-agnostic */
+top: calc((1em * var(--line-height) - var(--checkbox-size)) / 2);
+```
+
+Both scale with font size, unlike our hard-coded `mt-1` (4px). Novel.sh
+(Steven Tey's Notion-style TipTap editor) sizes its checkbox at
+`1.2em` and uses an em gap.
+
+### Vertical rhythm & specificity
+
+- **Lobotomized owl** (`* + *`) flow: "every flow element that follows
+ another gets one line of top margin," in `em` so it scales. Zero
+ specificity, trivially overridable. The modern form:
+ `:where(.prose > * + *) { margin-block-start: var(--flow); }`.
+- **"Space precedes"** for headings: more margin *above* than *below*,
+ so a heading binds to the text it introduces (Better Web Type;
+ Tailwind Typography's `prose` gives H2 `2em` top / `1em` bottom, H3
+ `1.6em` / `0.6em`).
+- **`:where()` for zero-specificity defaults:** wrapping base rules in
+ `:where()` makes any later rule win regardless of source order —
+ exactly the cure for our equal-specificity `ul` collision. Open Props
+ uses this pattern throughout.
+
+### `@tailwindcss/typography`
+
+It's already a dependency in `packages/editor/tailwind.config.js` but
+the `prose` class is **not applied** anywhere (the app hand-rolls
+`.page-prose`). The plugin is excellent for *rendered, read-only*
+markdown but is a poor fit for a `contenteditable` surface: it doesn't
+style GFM task lists at all, its `::marker` rules can fight ProseMirror's
+list rendering, and its selectors (`0,2,1`) are *more* specific than a
+plain `.editor ul`, which would make editor-extension overrides need
+`!important`. Verdict: don't adopt `prose` for the editable surface;
+optionally adopt it for a future read-only/print render.
+
+### Reference editor CSS
+
+`Novel.sh`, `react-notion-x`, `BlockNote`, and Tiptap's official simple-
+editor template are the cleanest open-source references for TipTap/
+ProseMirror typography (see References).
+
+## Key Findings
+
+1. **The cascade is order-dependent, not intentional.** Equal-
+ specificity `ul` rules in two stylesheets mean the task-list indent
+ bug is one bundler reorder away from flipping. This is fragile by
+ construction.
+2. **The drag-handle gutter is baked into asymmetric padding.** `pl-8`
+ on both the title and `.ProseMirror`, with only `pr-1` on the right,
+ makes the measure 28px off-center. The handle should float in
+ overflow (it's already `position:absolute`, `left:-6`), letting
+ content padding stay symmetric.
+3. **There is no shared spacing unit.** Margins are a grab-bag (`mt-8`,
+ `mb-3`, `my-2`, `mb-1`, `my-1`, `mb-4`, `margin-block:0.25rem`).
+ Asymmetric top/bottom margins (4/16, 8/16, 0/16) guarantee nothing
+ lines up on a rhythm.
+4. **To-do text doesn't align with bullet text** (104px vs 80px). The
+ cleanest target: make `checkbox-width + gap == list-indent`, so a
+ to-do's text shares the bullet's text edge while the checkbox sits in
+ the indent gutter where a bullet marker would.
+5. **Weak title↔H1 hierarchy** (36/700 vs 30/700). Either grow the title
+ or lighten/space H1.
+6. **Base typography is global to all surfaces.** Any change to
+ `.ProseMirror …` in `editor.css` also moves canvas-inline cards and
+ compact embeds. Document-grade rhythm must be scoped to the page
+ surface, not applied globally.
+7. **The square checkbox CSS in `editor.css` is partly dead** for page
+ tasks — `PageTaskItemExtension`'s NodeView renders its own round
+ control. Alignment must be fixed on the *actual* rendered element.
+8. **Headings carry a faint left "#" gutter** from the live-preview
+ syntax NodeView (measured ~34px vs 56px text). Acceptable, but worth
+ confirming it doesn't read as a misalignment.
+
+## Options And Tradeoffs
+
+```mermaid
+flowchart TD
+ A["Option A
Patch the values in place"] -->|low effort| A1["Tweak px in editor.css
+ globals.css"]
+ B["Option B
Adopt @tailwindcss/typography 'prose'"] -->|medium| B1["Apply prose to .page-prose"]
+ C["Option C ★
Token-driven :where() rhythm layer"] -->|medium-high| C1["One .xnet-prose layer in editor pkg
+ rhythm tokens + symmetric gutter"]
+
+ A1 --> AR["Fixes symptoms; cascade still fragile;
no rhythm; reverts under churn"]
+ B1 --> BR["Good for read view; wrong for editor;
no task-list styling; specificity wars"]
+ C1 --> CR["Fixes root cause; one source of truth;
overridable; screenshot-locked"]
+
+ classDef rec fill:#dcfce7,stroke:#16a34a,color:#111
+ class C,C1,CR rec
+```
+
+| Option | Effort | Fixes root cause | Risk | Verdict |
+|---|---|---|---|---|
+| **A — patch values** | Low | No | Low | Stopgap; the equal-specificity collision and missing rhythm survive. Rejected as the destination. |
+| **B — adopt `prose`** | Med | Partly | Med | Raises specificity (override wars), no task-list styling, designed for static HTML. Keep in pocket for a future read-only render only. |
+| **C — token rhythm layer** | Med-High | **Yes** | Med | One `:where()`-scoped layer + em rhythm tokens + symmetric gutter. Single source of truth, app overrides win without `!important`, screenshot-validated. **Recommended.** |
+
+## Recommendation
+
+Adopt **Option C**. Concretely:
+
+1. **One rhythm layer, one place.** Define all document typography in the
+ editor package under a single class — extend the existing
+ `.page-prose` (or introduce `.xnet-prose` and apply it where
+ `page-prose` is today) — wrapped in `:where()` so it's zero
+ specificity. Move the page spacing rules *out* of `globals.css`; the
+ app keeps only the **color tokens** (`--editor-foreground`,
+ `--editor-caret`, selection) it already sets. This ends the two-file
+ tug-of-war and keeps base `editor.css` minimal for canvas/compact
+ surfaces.
+
+2. **Rhythm tokens (em-based).** A small scale drives everything:
+
+ | Token | Value | Drives |
+ |---|---|---|
+ | `--prose-line` | `1.65` | line-height |
+ | `--prose-flow` | `0.75em` | space between sibling blocks |
+ | `--prose-flow-tight` | `0.25em` | between list items |
+ | `--prose-h-before` | `1.6em` | heading top margin (space precedes) |
+ | `--prose-h-after` | `0.4em` | heading bottom margin |
+ | `--prose-indent` | `1.5em` (~24px) | list indent per level |
+ | `--prose-marker-gap` | `0.4em` | marker → text gap |
+ | `--prose-check` | `1.15em` | checkbox size |
+
+3. **Symmetric gutter.** Drop `pl-8`/`pr-1` asymmetry. Give the title and
+ `.ProseMirror` the **same** horizontal padding (`--page-pad-x`,
+ responsive), and float the drag handle into the left overflow
+ (`left: calc(-1 * var(--page-gutter))`). Title and every body block
+ then share one left edge, and the measure is centered.
+
+4. **Flow, not per-element margins.** Use the owl for block spacing and
+ override headings for "space precedes":
+
+ ```css
+ :where(.xnet-prose > * + *) { margin-block-start: var(--prose-flow); }
+ :where(.xnet-prose > * + :is(h1,h2,h3)){ margin-block-start: var(--prose-h-before); }
+ :where(.xnet-prose :is(h1,h2,h3) + *) { margin-block-start: var(--prose-h-after); }
+ :where(.xnet-prose li + li) { margin-block-start: var(--prose-flow-tight); }
+ ```
+
+5. **Lists + to-do align to one edge.** Bullets/ordered indent by
+ `--prose-indent`; the task list resets to `padding-inline-start: 0`
+ (now it actually wins, via `:where()`), and **checkbox + gap is sized
+ to equal `--prose-indent`** so to-do text lands on the bullet text
+ edge. Center the checkbox with `calc((1lh - var(--prose-check))/2)`.
+
+6. **Heading hierarchy.** Bump the title to ~`2.5em` (40px) or lighten H1
+ to `600`, so title and H1 read as parent/child. Finalize by eye in
+ the iteration loop.
+
+7. **Screenshot-driven iteration (the brief's core ask).** Build a
+ **kitchen-sink fixture** (Storybook story + a seedable page) covering
+ every block type, render it, overlay alignment gridlines, compare the
+ left edges of paragraph / bullet-text / ordered-text / to-do-text /
+ blockquote-text, tweak tokens, re-screenshot, repeat until they
+ coincide. Wire the fixture into the existing `scripts/visuals` CI
+ (explorations 0185/0191) so a baseline screenshot guards against
+ regression.
+
+```mermaid
+stateDiagram-v2
+ [*] --> Render
+ Render --> Measure: kitchen-sink page
+ Measure --> Compare: left-edges + rhythm
+ Compare --> Tweak: edges/spacing off?
+ Tweak --> Render: adjust tokens
+ Compare --> Baseline: aligned ✓
+ Baseline --> [*]: commit + visuals CI baseline
+```
+
+## Example Code
+
+A sketch of the consolidated `:where()` layer (final values tuned in the
+loop). Lives in the editor package; the app supplies only color tokens.
+
+```css
+/* packages/editor/src/styles/prose.css — document-grade typography,
+ scoped to the page surface, zero specificity so themes win. */
+.xnet-prose {
+ --prose-line: 1.65;
+ --prose-flow: 0.75em;
+ --prose-flow-tight: 0.25em;
+ --prose-h-before: 1.6em;
+ --prose-h-after: 0.4em;
+ --prose-indent: 1.5em; /* 24px @16px */
+ --prose-marker-gap: 0.4em;
+ --prose-check: 1.15em;
+ font-size: var(--font-prose-size, 16px);
+ line-height: var(--prose-line);
+}
+
+/* Flow: one rhythm unit between siblings; space precedes headings. */
+:where(.xnet-prose > * + *) { margin-block-start: var(--prose-flow); }
+:where(.xnet-prose > * + :is(h1,h2,h3,h4)) { margin-block-start: var(--prose-h-before); }
+:where(.xnet-prose :is(h1,h2,h3,h4) + *){ margin-block-start: var(--prose-h-after); }
+
+/* Headings: hierarchy by size + weight, not just spacing. */
+:where(.xnet-prose h1) { font-size: 1.875em; font-weight: 700; line-height: 1.2; }
+:where(.xnet-prose h2) { font-size: 1.5em; font-weight: 650; line-height: 1.25; }
+:where(.xnet-prose h3) { font-size: 1.25em; font-weight: 600; line-height: 1.3; }
+
+/* Lists: one indent step; markers hang in the gutter. */
+:where(.xnet-prose :is(ul,ol):not([data-type='taskList'])) {
+ padding-inline-start: var(--prose-indent);
+}
+:where(.xnet-prose li + li) { margin-block-start: var(--prose-flow-tight); }
+
+/* To-do: reset indent, size checkbox+gap to equal the list indent so
+ to-do text shares the bullet text edge; center the box on line 1. */
+:where(.xnet-prose ul[data-type='taskList']) { padding-inline-start: 0; list-style: none; }
+:where(.xnet-prose ul[data-type='taskList'] > li) {
+ display: flex;
+ gap: calc(var(--prose-indent) - var(--prose-check));
+ align-items: flex-start;
+}
+:where(.xnet-prose ul[data-type='taskList'] [role='checkbox'],
+ .xnet-prose ul[data-type='taskList'] input[type='checkbox']) {
+ inline-size: var(--prose-check);
+ block-size: var(--prose-check);
+ margin-block-start: calc((1lh - var(--prose-check)) / 2); /* line-relative centering */
+}
+```
+
+And the gutter, symmetric, with the handle in overflow:
+
+```tsx
+// PageView.tsx — title and editor share one padding token; no pl-8/pr-1.
+