diff --git a/content/docs/learn/playground/active-row-column-bands.mdx b/content/docs/learn/playground/active-row-column-bands.mdx
new file mode 100644
index 0000000..40eae6e
--- /dev/null
+++ b/content/docs/learn/playground/active-row-column-bands.mdx
@@ -0,0 +1,39 @@
+---
+title: "Settings toggle — active row/column band tint"
+description: "A Settings-panel checkbox lets the user opt IN to a whole-row/whole-column tint on the active cell's row and column (beyond the always-on header emphasis); the choice previews live and survives a reload. Off by default, matching Google Sheets."
+---
+
+{/* GENERATED — do not edit by hand */}
+
+## What it does
+
+Google Sheets emphasizes only the active cell's row/column **header labels**
+(e.g. selecting C4 highlights the "C" and "4" header cells). Studio's
+active-band render layer can additionally wash the whole active
+row and column across the grid **body** — a Studio-only enhancement, off by
+default so the out-of-the-box experience still matches Sheets. This issue
+surfaces that already-built layer as a **Settings → Selection → "Highlight
+active row & column"** checkbox: toggling it previews live (no reload) and
+persists across a reload, exactly like every other Settings field.
+
+## Try it
+
+
+
+Open **File → Settings…**, scroll to **Selection**, and toggle "Highlight
+active row & column" — the active cell's row and column band tint
+shows/hides live on the grid behind the panel.
+
+## Verified behavior
+
+_Matches Google Sheets:_ Google Sheets' default active-cell emphasis is HEADERS-ONLY: selecting C4 bolds/tints only the "C" column-letter and "4" row-number header labels — the grid body gets no extra tint beyond the selection outline itself. Sheets has no first-party setting to additionally wash the whole active row/column band across the body (this is a Studio-only opt-in enhancement, not a Sheets-parity requirement), so the Sheets-faithful default this toggle must preserve is OFF — the render layer (already built and unit-tested) has carried this default since and this issue only adds a way to flip it at runtime.
+
+#### The toggle is reachable from Settings and starts unchecked (OFF, the Sheets-faithful default)
+
+**Given**
+
+**When** I click menu item **file.settings**
+
+**Then**
+- `[data-testid="settings-panel"]` is visible
+- `[data-testid="settings-field-showActiveBands"]` is visible containing "Highlight active row & column"
diff --git a/content/docs/learn/playground/column-row-resize.mdx b/content/docs/learn/playground/column-row-resize.mdx
new file mode 100644
index 0000000..3b02f7a
--- /dev/null
+++ b/content/docs/learn/playground/column-row-resize.mdx
@@ -0,0 +1,76 @@
+---
+title: "Column and row resize (drag, auto-fit, and multi-select)"
+description: "Dragging a column/row header border resizes it; double-clicking a border auto-fits it to content; resizing/auto-fitting one of several selected columns/rows (or a select-all) resizes them all together. Sizes persist across save/reload."
+---
+
+{/* GENERATED — do not edit by hand */}
+
+## What it does
+
+**Drag to resize.** Hover the border between two column-letter (or row-number) headers — the cursor turns into a resize handle — and drag to set that column's width (or row's height). A dashed guide line tracks the pointer while you drag; the size commits when you release.
+
+**Double-click to auto-fit.** Double-clicking a column-header border sizes the column to fit its widest non-empty cell's content (using the same canvas text-measurement the grid renders with), plus a small margin. Double-clicking a row-header border does the same for height — though rows already auto-fit their content live as you type, so this mainly matters after a manual drag: it reverts the row to that automatic content-fit height.
+
+**Multi-select resize.** Select several columns (or rows), or the whole sheet via the top-left corner box, then drag or double-click any ONE of the selected borders — every selected column/row resizes together. A drag applies the same width/height to all of them; an auto-fit fits each one independently to its own content, so a multi-column auto-fit can land on different widths per column.
+
+**Persists.** A resized column/row is an explicit size override, distinct from the default/auto-fit geometry. It round-trips through Save/Load and Export JSON (`colWidths`/`rowHeights`, keyed by column/row index, schema v5) and Export XLSX (column width in Excel's "characters" unit; row height in points).
+
+## Verified behavior
+
+_Matches Google Sheets:_ In Google Sheets, dragging the border between two column-letter headers (or row-number headers) resizes the column/row to the left (or row above) of the dragged border, with a live vertical/horizontal guide line while dragging. Double-clicking that same border auto-fits the column/row to the widest (or tallest) NON-EMPTY cell's rendered content in it, plus a small padding margin; double-clicking an empty column/row's border is a no-op (it stays/reverts to the default width/height). Selecting multiple columns (via a shift/drag multi-column header selection) or row headers, or selecting the whole sheet (the top-left corner box), then dragging or double-clicking ANY ONE of the selected borders resizes/auto-fits every selected column/row together — a drag applies the SAME width/height to all of them, while an auto-fit sizes EACH one independently to its own widest content (so a multi-column auto-fit can produce different final widths per column). A resized column/row is an explicit override that persists with the file (reload / re-share shows the same widths) and is exported with the sheet. This is long-standing, widely-documented Sheets/Excel behavior, not something ambiguous enough to need live verification against the reference workbook; a follow-up can add the `features/column-row-resize.oracle.md` study-sheets brief if a review finds a discrepancy.
+
+#### Dragging a column border sets an explicit width that persists across save and reload
+
+**Given**
+
+**When** I drag column **B**'s header border by 60px, then save, reload the page, and load the workbook back
+
+**Then**
+- column **B** is exactly 160px wide
+
+#### Double-clicking a column border auto-fits it to its widest content
+
+**Given**
+- **B1** = `A considerably long piece of text that overflows the default column width`
+
+**When** I double-click column **B**'s header border (auto-fit)
+
+**Then**
+- column **B** is at least 300px wide
+
+#### Auto-fit on an empty column is a no-op (stays at the default width)
+
+**Given**
+
+**When** I double-click column **D**'s header border (auto-fit)
+
+**Then**
+- column **D** is exactly 100px wide
+
+#### Dragging a row border sets an explicit height, independent of content auto-fit
+
+**Given**
+
+**When** I drag row **3**'s header border by 40px
+
+**Then**
+- row **3** is exactly 61px tall
+
+#### Double-clicking a row border reverts a prior drag back to the
+
+**Given**
+
+**When** I drag row **2**'s header border by 40px, then double-click row **2**'s header border (auto-fit)
+
+**Then**
+- row **2** is exactly 21px tall
+
+#### Selecting multiple columns, then dragging one selected border, resizes them all together
+
+**Given**
+
+**When** I click the **B** column header, then press `Shift+ArrowRight`, then drag column **C**'s header border by 50px
+
+**Then**
+- column **B** is exactly 150px wide
+- column **C** is exactly 150px wide
diff --git a/content/docs/learn/playground/error-tooltip.mdx b/content/docs/learn/playground/error-tooltip.mdx
new file mode 100644
index 0000000..fc16186
--- /dev/null
+++ b/content/docs/learn/playground/error-tooltip.mdx
@@ -0,0 +1,40 @@
+---
+title: "Error cells show a diagnostic tooltip"
+description: "Hovering or selecting a cell that resolves to an error shows a Sheets-style tooltip with a red \"Error\" header and a per-code diagnostic message."
+---
+
+{/* GENERATED — do not edit by hand */}
+
+## What it does
+
+When a cell resolves to an error — `#REF!`, `#DIV/0!`, `#NAME?`, `#VALUE!`, `#NUM!`, `#N/A` — hovering it or making it the active selection shows a small red-accented tooltip: an **"Error"** header plus a plain-language diagnostic line (e.g. "Division by zero." for `#DIV/0!`). It tracks the cell and disappears when you move the pointer away, select a different cell, start editing, or scroll.
+
+## Try it
+
+
+
+## Verified behavior
+
+_Matches Google Sheets:_ In Google Sheets, a cell that resolves to an error (#REF!, #DIV/0!, #NAME?, #VALUE!, #NUM!, #N/A, …) shows a small red-accented tooltip with an "Error" header and a plain-language diagnostic line — both when you hover the cell and when it's the active selection. The tooltip tracks the cell it's anchored to and disappears when you move the mouse away or scroll; a non-error cell never shows one. TrueCalc's engine (@truecalc/workbook) does not yet emit a rich per-error diagnostic message (core#731, itself blocked upstream on the Google Sheets conformance-fixtures pipeline capturing real error-message strings, which hasn't landed yet) — so this feature ships with a FALLBACK per-code message table (the six codes the engine currently surfaces) rather than the engine's own wording, and is structured to prefer an engine-supplied message the moment one exists (see `domain/error-message.ts`).
+
+#### A
+
+**Given**
+- **A1** = `=1/0`
+
+**When** I click **A1**
+
+**Then**
+- **A1** shows `#DIV/0!`
+- `[data-slot="error-tooltip"]` is visible containing "Division by zero."
+
+#### A non-error cell shows no tooltip when selected
+
+**Given**
+- **A1** = `42`
+
+**When** I click **A1**
+
+**Then**
+- **A1** shows `42`
+- `[data-slot="error-tooltip"]` is hidden
diff --git a/content/docs/learn/playground/explain-result.mdx b/content/docs/learn/playground/explain-result.mdx
new file mode 100644
index 0000000..79592c2
--- /dev/null
+++ b/content/docs/learn/playground/explain-result.mdx
@@ -0,0 +1,136 @@
+---
+title: "Explain a cell's computed result"
+description: "Right-clicking a cell offers \"Explain\", which opens a modal, centered stage playing back an animated node-graph of how the cell's value was computed — leaf inputs at the bottom, operations flowing up, the result at the top, each step lighting its node/edges and narrating what happened."
+---
+
+{/* GENERATED — do not edit by hand */}
+
+## What it does
+
+Right-clicking any grid cell opens a context menu with one item, **Explain**. Choosing it opens a **near-fullscreen modal stage** — "watch how this number was computed" — centered over the grid, which dims and blurs behind it. Dismiss it with Escape, an outside click, or its close button; to explain a different cell, close the dialog first, then right-click that cell.
+
+The stage is an **animated node graph**: the formula's leaf inputs sit on the bottom row, operations flow upward, and the final result sits at the top. **Play** runs the calculation step by step — each step lights its node (the brand-yellow accent) and its incoming edges, narrates what happened in a caption below ("Divide: 0.05 / 12 = 0.0041666…"), and leaves every earlier node fully lit ("seen") while anything not yet reached stays dimmed. **Step** advances one frame at a time; **Reset** returns to the inputs-only frame. A multi-step formula — e.g. `PMT(0.05/12, 30*12, -500000)` — shows each operand's own node (`0.05 / 12`, `30 * 12`, `-500000`) lighting up in turn before the final function-call node combines them.
+
+**The whole tree always fits — no clipping, no scroll — and you can zoom in.** The stage measures itself and scales the graph (never past its natural 1× size for a small formula) so a big, deeply-nested cross-sheet model renders complete on open, padded to the stage, not clipped at the edges. Zoom in/out buttons plus drag-to-pan let you inspect a large model up close; reset snaps back to the fitted view.
+
+**The INPUT frame lists resolved references cleanly.** Rather than one run-on line mixing cell references and bare literals, the INPUT frame shows a short framing line plus a chip per resolved cell/range reference (e.g. `Inputs!B1 = 0.05`, `Inputs!B4:B6 = {100, 200, 300}`) — bare literals are left out of this summary (they're already visible in the formula text and their own leaf node).
+
+**Explain is LIVE — any formula, not just two demos.** Right-clicking ANY formula cell calls the real trace producer (`@truecalc/pro`'s `trace_cell_at`, a client WASM package vendored into this repo — see `packages/core/vendor/truecalc-pro/VENDORED-FROM.md`) against the workbook's CURRENT state, reusing the exact `(timestamp_ms, timezone, rng_seed)` the engine used for its last on-screen recalc (`EngineAdapter.lastRecalcContext`) so a volatile cell explains against what's actually on screen. Three distinct, honest non-graph states replace the old "two demo formulas" placeholder: an empty/non-formula cell shows "nothing to explain" (`explain-no-formula`); a cell caught in a circular reference shows a dedicated circular-reference message (`explain-cycle` — core's own `Workbook::trace_cell` short-circuits a cycle member before ever invoking the trace hook, the same wire signature as "no formula", so Studio disambiguates the two using its OWN knowledge of whether the cell has a stored formula at all); and a genuine trace failure surfaces its raw message (`explain-trace-error`) rather than crashing.
+
+**Formula-token highlighting is wired.** Every `TraceNode` carries a `span` — a byte range into the cell's own formula text, read straight off the real generated trace wire type. As each frame plays, the live node's span slices the formula shown above the stage and highlights that substring (`explain-formula-highlight`), so pressing Play visibly "lights up" the exact token currently being evaluated.
+
+**Respects `prefers-reduced-motion`.** With that OS preference set, the stage skips the animation and opens straight on its end state: every node seen, the result lit. Nothing to wait for, nothing moving unexpectedly.
+
+**Reopening on a different cell always starts fresh.** Explain is modal now, so the flow is close → right-click a different cell → Explain again, not retarget-while-open. Every open always starts that cell's graph from frame 0 (or, under reduced motion, straight to its own end state) — never a stale mid-animation frame left over from a previous cell, and never a crash if the new trace has fewer steps than a previous one was stepped into (the underlying safety mechanism, `openId`-keyed remounting, still exists — see "How it works" — as insurance against any future code path that could otherwise swap cells on a live-mounted instance).
+
+## Try it
+
+
+
+Right-click cell **C1** and choose **Explain**, then press **Play**.
+
+## Verified behavior
+
+_Matches Google Sheets:_ There is no Google Sheets or Excel feature that produces a step-by-step animated computation graph like this one — Sheets has no "Explain" surface, and Excel's closest analogs (Formula Evaluator's single-step-through modal, Trace Precedents' arrows) don't render an ordered, playable node graph. This feature's ground truth is therefore NOT a Google-Sheets/Excel UI oracle (no `study-sheets` brief backs this manifest, per design D5's own scope for TrueCalc-original UX) — it's the real TrueCalc engine's own evaluation, cross-checked via the `truecalc` MCP `evaluate` tool at authoring time: `0.05/12` → `0.004166666666666667`, `30*12` → `360`, `PMT(0.05/12, 30*12, -500000)` → `2684.108115060699`, and `C1*2` with `C1=30` → `60`. ** (Slice C) wired the REAL trace producer** (`@truecalc/pro`'s `trace_cell_at`) — every scenario below now drives a LIVE trace of whatever the clicked cell actually holds, not a lookup against a hand-authored fixture (an earlier slice's canned fixture data still backs Storybook/unit tests, but the live "Explain" path never reads it). Every scenario runs under `prefers-reduced-motion: reduce` (set once for the whole scenario harness), which the graph itself reads to open straight on its deterministic END STATE (every node seen, the result node live) instead of animating — the same accessibility affordance a real user with that OS preference gets, and the harness's only way to assert the graph's outcome without a Play/Step dance.
+
+#### Right-click a formula cell and choose Explain shows the LIVE computed graph, references resolved, result live, formula token highlighted
+
+**Given**
+- **A1** = `10`
+- **B1** = `20`
+- **C1** = `=A1+B1`
+
+**When** I right-click **C1**, then click **grid-context-menu-explain** in the context menu
+
+**Then**
+- **C1** shows `30`
+- `[data-testid="explain-dialog"]` is visible containing "Explain C1"
+- `[data-node-id="n-0"]` is visible containing "A1"
+- `[data-testid="explain-node"][data-state="live"]` is visible containing "30"
+- `[data-testid="explain-formula"]` is visible containing "A1+B1"
+- `[data-testid="explain-formula"][data-formula-highlight="active"]` is visible
+- `[data-testid="explain-formula-highlight"]` is visible containing "A1+B1"
+
+#### Opening Explain from a LOW row still reaches the result — the stage is centered and viewport-capped, not anchored to the click
+
+**Given**
+- **A1** = `10`
+- **B1** = `20`
+- **A23** = `=A1+B1`
+
+**When** I right-click **A23**, then click **grid-context-menu-explain** in the context menu
+
+**Then**
+- `[data-testid="explain-node"][data-state="live"]` is visible containing "30" near the top of the screen (≤700px)
+
+#### A LIVE, non-demo formula explains for real — resolved references, not a fixture lookup
+
+**Given**
+- **A1** = `10`
+- **B1** = `20`
+- **C1** = `=A1+B1`
+- **D5** = `=C1*2`
+
+**When** I right-click **D5**, then click **grid-context-menu-explain** in the context menu
+
+**Then**
+- **D5** shows `60`
+- `[data-testid="explain-dialog"]` is visible containing "Explain D5"
+- `[data-node-id="n-0"]` is visible containing "C1"
+- `[data-testid="explain-node"][data-state="live"]` is visible containing "60"
+- `[data-testid="explain-no-formula"]` is hidden
+- `[data-testid="explain-cycle"]` is hidden
+- `[data-testid="explain-trace-error"]` is hidden
+
+#### An empty, non-formula cell shows an honest "nothing to explain" state, never a blank or fabricated graph
+
+**Given**
+
+**When** I right-click **D9**, then click **grid-context-menu-explain** in the context menu
+
+**Then**
+- `[data-testid="explain-dialog"]` is visible containing "Explain D9"
+- `[data-testid="explain-no-formula"]` is visible containing "nothing to explain"
+- `[data-testid="explain-node"]` is hidden
+
+#### A cell caught in a circular reference explains as a distinct circular-reference state, not a blank/crashed graph
+
+**Given**
+- **A1** = `=B1+1`
+- **B1** = `=A1+1`
+
+**When** I right-click **A1**, then click **grid-context-menu-explain** in the context menu
+
+**Then**
+- **A1** shows `#REF!`
+- `[data-testid="explain-dialog"]` is visible containing "Explain A1"
+- `[data-testid="explain-cycle"]` is visible containing "circular reference"
+- `[data-testid="explain-node"]` is hidden
+
+#### Retargeting an OPEN Explain dialog to a different cell resets it instead of crashing the app (regression guard)
+
+**Given**
+- **A1** = `10`
+- **B1** = `20`
+- **D4** = `=PMT(0.05/12, 30*12, -500000)`
+- **C1** = `=A1+B1`
+
+**When** I right-click **D4**, then click **grid-context-menu-explain** in the context menu, then right-click **C1**, then click **grid-context-menu-explain** in the context menu
+
+**Then**
+- `[data-testid="explain-dialog"]` is visible containing "Explain C1"
+- `[data-testid="explain-node"][data-state="live"]` is visible containing "30"
+- `[data-testid="explain-error-fallback"]` is hidden
+
+#### Zoom-out actually decrements the zoom, including after the toolbar's own pointerdown starts intercepting drag-pan
+
+**Given**
+- **A1** = `10`
+- **B1** = `20`
+- **C1** = `=A1+B1`
+
+**When** I right-click **C1**, then click **grid-context-menu-explain** in the context menu, then click **explain-zoom-in**, then click **explain-zoom-in**, then click **explain-zoom-out**, then click **explain-zoom-out**
+
+**Then**
+- `[data-testid="explain-zoom-out"]:disabled` is visible
+- `[data-testid="explain-zoom-reset"]:disabled` is visible
diff --git a/content/docs/learn/playground/format-font-family.mdx b/content/docs/learn/playground/format-font-family.mdx
index d908a9c..d5cb3b9 100644
--- a/content/docs/learn/playground/format-font-family.mdx
+++ b/content/docs/learn/playground/format-font-family.mdx
@@ -11,7 +11,7 @@ Pick a font family for the selected cells from the **Font** dropdown in the tool
## Testing note
-The manifest scenario runner drives the canvas and keyboard only — it has no verb for clicking a toolbar control — so the family-change interaction is not yet expressible as a runnable scenario. It is verified end-to-end via agent-browser (change the Font `select`, then assert `window.__store.formatAt('A1').fontFamily`) and by colocated unit tests over the control's pure `apply`/`getValue` and the render `styleContributor`. Adding a `select` verb to the runner would let this feature carry a full runnable scenario like font size does.
+The manifest scenario runner drives the canvas and keyboard only — it has no verb for clicking a toolbar control — so the family-change interaction is not yet expressible as a runnable scenario. It is verified end-to-end via agent-browser (change the Font `select`, then assert `window.truecalc.store.formatAt('A1').fontFamily`) and by colocated unit tests over the control's pure `apply`/`getValue` and the render `styleContributor`. Adding a `select` verb to the runner would let this feature carry a full runnable scenario like font size does.
## Verified behavior
diff --git a/content/docs/learn/playground/format-rotation.mdx b/content/docs/learn/playground/format-rotation.mdx
new file mode 100644
index 0000000..b5824da
--- /dev/null
+++ b/content/docs/learn/playground/format-rotation.mdx
@@ -0,0 +1,117 @@
+---
+title: "Text rotation"
+description: "A cell's text can be rotated to a Sheets preset (Tilt up 45°, Tilt down -45°, Rotate up 90°, Rotate down -90°) or a custom angle, from either the toolbar's Text rotation popover or Format → Text rotation; the grid draws the rotated text with a real canvas rotate transform, and an explicit rotation is a per-cell presentation attribute — it survives value/formula edits, undoes/redoes like any edit, and resets with Clear formatting.\n"
+---
+
+{/* GENERATED — do not edit by hand */}
+
+## What it does
+
+Select a cell or range and open the toolbar's **Text rotation** popover (or
+**Format → Text rotation**) to rotate the cell's text to one of Sheets' four
+angle presets — **Tilt up** (45°), **Tilt down** (-45°), **Rotate up** (90°),
+**Rotate down** (-90°) — or type a **custom angle** from -90° to 90°. **None**
+clears the rotation back to the default (unrotated).
+
+A positive angle pivots the text at the cell's bottom-left corner and reads
+counter-clockwise (ascending for a tilt, bottom-to-top for a 90° rotation); a
+negative angle pivots at the top-left and reads clockwise. Rotation is a
+**presentation** attribute, separate from the cell's value: rotating `=A1+B1`
+angles the drawn number without touching the formula. It is per-cell, survives
+value edits, undoes/redoes like any other edit, and resets with **Clear
+formatting**.
+
+**Not built in this slice** (see the oracle brief for the full reasoning):
+
+- **Stack vertically** — a distinct one-character-per-line layout Sheets also
+ offers, not an angle, so it doesn't fit the `rotation: number` field this
+ issue asked for.
+- **Row-height growth for a rotated cell** — Sheets visibly grows the row to
+ fit the rotated bounding box; Studio's rotated text instead clips at the
+ cell's existing rect, the same fallback any other overflowing cell gets.
+
+## Verified behavior
+
+_Matches Google Sheets:_ In Google Sheets, the toolbar's "Text rotation" popover offers six presets (None, Tilt up, Tilt down, Stack vertically, Rotate up, Rotate down) plus a free custom-angle input; Tilt up = 45°, Tilt down = -45°, Rotate up = 90°, Rotate down = -90° (verified live — features/format-rotation.oracle.md — by reading the custom-angle dropdown's highlighted value while each preset was active, never self-confirmed). A positive angle pivots the text at the cell's bottom-left corner and reads counter-clockwise (ascending left-to-right for a tilt, bottom-to-top for a 90° rotation); a negative angle pivots at the top-left and reads clockwise. Sheets grows the row to fit the rotated bounding box. "Stack vertically" is a distinct layout (one character per line, no angled baseline at all) — not a point on the angle continuum — and is out of scope for this slice; so is row-height growth for rotation. Rotation is a per-cell presentation attribute like every other CellFormat field: it never touches the value or formula, and undoes/redoes with the command log.
+
+#### Tilt up sets a 45° rotation (Format menu)
+
+**Given**
+- **A1** = `hello`
+
+**When** I click **A1**, then click menu item **format.rotateTiltUp**
+
+**Then**
+- **A1** is rotation=45
+
+#### Tilt up sets a 45° rotation (toolbar) — the SAME seam as the Format menu, driven end to end
+
+**Given**
+- **A1** = `hello`
+
+**When** I click **A1**, then open the text-rotation dropdown, then pick **tiltUp** from the text-rotation dropdown
+
+**Then**
+- **A1** is rotation=45
+
+#### Rotate down sets a -90° rotation and is presentation only — value/formula untouched
+
+**Given**
+- **A1** = `=2+3`
+
+**When** I click **A1**, then click menu item **format.rotateDown**
+
+**Then**
+- **A1** shows `5`
+- **A1** has formula `=2+3`
+- **A1** is rotation=-90
+
+#### rotation survives a value edit
+
+**Given**
+- **A1** = `x`
+
+**When** I click **A1**, then click menu item **format.rotateTiltDown**, then type `changed` into **A1**
+
+**Then**
+- **A1** shows `changed`
+- **A1** is rotation=-45
+
+#### rotation undoes and redoes with the command log
+
+**Given**
+- **A1** = `x`
+
+**When** I click **A1**, then click menu item **format.rotateUp**, then undo, then redo
+
+**Then**
+- **A1** is rotation=90
+
+#### Clear formatting resets rotation back to none
+
+**Given**
+- **A1** = `x`
+
+**When** I click **A1**, then click menu item **format.rotateTiltUp**, then click menu item **format.clearFormatting**
+
+**Then**
+- **A1** is rotation=false
+
+#### the None preset explicitly clears an existing rotation
+
+**Given**
+- **A1** = `x`
+
+**When** I click **A1**, then click menu item **format.rotateTiltUp**, then click menu item **format.rotateNone**
+
+**Then**
+- **A1** is rotation=false
+
+#### rotating a wide cell's text grows its row, matching Sheets
+
+**Given**
+- **A1** = `a fairly long piece of rotated text`
+
+**When** I click **A1**, then click menu item **format.rotateTiltUp**
+
+**Then**
diff --git a/content/docs/learn/playground/format-underline.mdx b/content/docs/learn/playground/format-underline.mdx
new file mode 100644
index 0000000..fd5440b
--- /dev/null
+++ b/content/docs/learn/playground/format-underline.mdx
@@ -0,0 +1,65 @@
+---
+title: "Underline a cell"
+description: "Ctrl+U (or the toolbar Underline button, or Format > Text > Underline) toggles underline on the selection."
+---
+
+{/* GENERATED — do not edit by hand */}
+
+## What it does
+
+Select cells and press **Ctrl+U** — or click the **Underline** button in the toolbar, or Format > Text > Underline in the menu bar — to toggle underline. Underline is **presentation**, separate from the cell's value: underlining `=A1+B1` draws a line under the number without touching the formula. It undoes and redoes like any other edit, and survives a later value edit.
+
+## Verified behavior
+
+_Matches Google Sheets:_ In Google Sheets, Ctrl+U toggles underline on the selected cells (Cmd+U on Mac). Underline is a presentation attribute independent of the cell's value or formula, and it undoes/redoes like any edit. Toggling again removes it. Format > Text in the Sheets menu bar lists Bold, Italic, Underline, Strikethrough in that order — this codebase shipped Bold/Italic/Strikethrough but never Underline; a live audit against the reference workbook found the gap and this feature closes it.
+
+#### Ctrl+U underlines the selected cell
+
+**Given**
+- **A1** = `hello`
+
+**When** I click **A1**, then press `Control+U`
+
+**Then**
+- **A1** is underline
+
+#### Ctrl+U a second time removes the underline
+
+**Given**
+- **A1** = `hello`
+
+**When** I click **A1**, then press `Control+U`, then press `Control+U`
+
+**Then**
+- **A1** is underline=false
+
+#### Format > Text > Underline toggles the same attribute as the keyboard chord
+
+**Given**
+- **A1** = `hello`
+
+**When** I click **A1**, then click menu item **format.underline**
+
+**Then**
+- **A1** is underline
+
+#### Underline survives undo/redo like any other edit
+
+**Given**
+- **A1** = `hello`
+
+**When** I click **A1**, then press `Control+U`, then press `Control+Z`
+
+**Then**
+- **A1** is underline=false
+
+#### Underline survives a value edit (presentation stays separate from content)
+
+**Given**
+- **A1** = `hello`
+
+**When** I click **A1**, then press `Control+U`, then type `world` into **A1**
+
+**Then**
+- **A1** shows `world`
+- **A1** is underline
diff --git a/content/docs/learn/playground/history-revert.mdx b/content/docs/learn/playground/history-revert.mdx
index 585e1e3..1bb8417 100644
--- a/content/docs/learn/playground/history-revert.mdx
+++ b/content/docs/learn/playground/history-revert.mdx
@@ -9,14 +9,13 @@ description: "Clicking a history entry's Restore button non-destructively restor
The **History** panel is now interactive. Each entry carries an explicit, always-visible **"Restore" button** (Google Sheets Version History's "Restore this version" pattern — discoverable without hovering, keyboard-accessible); pressing it **non-destructively restores the document to that state** — it records a _new_ version at the top of the timeline and keeps every prior version, so nothing is lost (undo the restore to step back out of it). **Hovering** an entry previews it: the cells it touched light up on the grid (a transient amber highlight that clears when you move away), and the entry shows **what changed** — a `before → after` for a single value edit (e.g. `10 → 20`), or a short summary for a formatting change ("fill color") or a multi-cell edit ("3 cells"). Consecutive identical operations on the same cell (the 8× repeated date-format on `C5` seen in dogfooding) **collapse into a single row** with a `×N` badge, so the timeline stays legible. Timestamps are **human-readable** — "just now", "2 min ago", "Today 3:45 PM", "Yesterday", then a date like "Jul 14" for older entries — with the exact absolute time available on hover.
-The chrome now also tells you, at a glance, **where the current document and its history actually live**. Today the playground is standalone: the workbook is held **in your browser only** and is lost if you close the tab. A footer strip states this ("Not saved — this workbook lives in your browser and is lost if you close the tab."), the History panel header says "History is kept in this browser only", and closing the tab with unsaved edits **warns you first**.
+The chrome now also tells you, at a glance, **where the current document and its history actually live**. A footer strip states this in plain words; the History panel header carries the matching note; and while the workbook is in the browser-only `local` mode (the default — see [persistent-history](./persistent-history)), the tab-close warning stays off, since the document and its history are already saved.
-## Deferred to storage persistence
+## Deferred to
+ (persistent history — autosave + restore across reload, in THIS browser) has shipped; see [persistent-history](./persistent-history). What's still deferred is the account half:
-The **persistent** half of this design waits on history persistence and the anonymous→account handoff. The restore semantics here are already the final, non-destructive ones; what's deferred is making the history and its versions _durable_:
-
-- **Persistent, cross-device version history** — the same non-destructive restore, but with versions saved server-side so they survive reload and other devices, rather than living only in this browser session. This PR ships the in-browser non-destructive restore; durability is.
-- **Account-saved history**, the `local` and `account` indicator states going live, and the **"Sign in to save"** upgrade CTA from standalone.
+- **Cross-device version history** — the same non-destructive restore, but with versions saved server-side so they survive not just a reload but a different device too, rather than being scoped to this one browser.
+- **Account-saved history**, the `account` indicator state going live, and the **"Sign in to save"** upgrade CTA from standalone.
## Verified behavior
@@ -32,7 +31,7 @@ _Matches Google Sheets:_ Restoring to a past state matches Google Sheets File
**Then**
- **A1** shows `9`
-#### The chrome states the workbook lives in the browser and is lost on close
+#### The chrome states where the workbook currently lives
**Given**
- **A1** = `1`
@@ -40,4 +39,4 @@ _Matches Google Sheets:_ Restoring to a past state matches Google Sheets File
**When** I click **A1**
**Then**
-- `[aria-label="Storage status"]` is visible containing "lives in your browser"
+- `[aria-label="Storage status"]` is visible containing "Saved in this browser"
diff --git a/content/docs/learn/playground/menu-bar.mdx b/content/docs/learn/playground/menu-bar.mdx
new file mode 100644
index 0000000..cef2a13
--- /dev/null
+++ b/content/docs/learn/playground/menu-bar.mdx
@@ -0,0 +1,123 @@
+---
+title: "Menu bar — File / Edit / View / Insert / Format / Data / Help"
+description: "A Google-Sheets-organized top menu bar sits above the toolbar; active items run real commands, not-yet-built items are visible but disabled with a \"coming soon\" tooltip, and persisted-mode-only items are locked in try mode."
+---
+
+{/* GENERATED — do not edit by hand */}
+
+## What it does
+
+A top menu bar sits above the toolbar, organized exactly like Google Sheets: **File · Edit · View · Insert · Format · Data · Help**. Every item is visible — nothing is hidden — but only items backed by a real, already-shipped command are clickable:
+
+- 🟢 **Active** — wired to the same seam the toolbar/keyboard already use (`run`/`apply`/`applyBorders`) or a thin, purely client-side action (New, Download, Full screen, Show gridlines/formula bar).
+- ◻️ **Disabled ("coming soon")** — the item renders, greyed, with a tooltip explaining it's not built yet. Clicking it does nothing.
+- 🔒 **Locked ("requires a saved account")** — Version history, Share, and Chart are gated on persisted/identified storage, which Studio doesn't have yet; they're disabled the same way, with a different tooltip.
+
+File absorbs the old standalone "Save, load, export" toolbar button and its Import rows — there's exactly one home for those actions now.
+
+## Try it
+
+
+
+## Verified behavior
+
+_Matches Google Sheets:_ Google Sheets' real top menu bar is File · Edit · View · Insert · Format · Data · Tools · Extensions · Help, in that left-to-right order, each opening a dropdown of grouped commands (separators between groups, greyed rows for unavailable commands, a lock/cloud glyph on account-gated rows). Studio's menu bar matches that same relative order and grouping for the SEVEN menus scopes in (Tools and Extensions are out of scope — Studio has no add-ons/macros surface yet). Within each menu, items are grouped exactly as Sheets groups them (e.g. Undo/Redo, then Cut/Copy/Paste, then Delete values/rows/columns, then Clear formatting in Edit), separated by hairlines. A command Sheets shows greyed-out (e.g. "Merge cells" with nothing merge-able selected) still LOOKS like a menu item with a tooltip on hover explaining why it's unavailable — never hidden — which is the same visible-roadmap pattern this feature uses for not-yet-built commands.
+
+#### All 7 menus render in Google-Sheets order and grouping
+
+**Given**
+
+**When** I click **A1**
+
+**Then**
+- `[data-testid="menu-bar"]` is visible containing "FileEditViewInsertFormatDataHelp"
+
+#### Clicking an active item (Edit > Undo) invokes the real existing command
+
+**Given**
+- **A1** = `10`
+
+**When** I type `99` into **A1**, then click menu item **edit.undo**
+
+**Then**
+- **A1** shows `10`
+
+#### Clicking an active item (Insert > Function) starts a real formula edit, and the SAME autocomplete dropdown then opens
+
+**Given**
+
+**When** I click **A1**, then click menu item **insert.function**, then press `s`
+
+**Then**
+- `[data-slot="formula-autocomplete"]` is visible
+
+#### A disabled item (Format > Conditional formatting) is visibly disabled and invokes nothing
+
+**Given**
+- **A1** = `10`
+
+**When** I click menu item **format.conditionalFormatting**
+
+**Then**
+- **A1** shows `10`
+- `[data-menu-item="format.conditionalFormatting"]` is visible
+
+#### Clicking Format > Merge cells merges the selection, through the SAME confirm-gated seam the toolbar's merge control uses
+
+**Given**
+- **A1** = `Anchor`
+
+**When** I click **A1**, then press `Shift+ArrowRight`, then click menu item **format.mergeCells**, then click **B1**
+
+**Then**
+- the active cell is **A1**
+
+#### Clicking Edit > Clear formatting clears formatting without touching the value
+
+**Given**
+- **A1** = `hello`
+
+**When** I click **A1**, then press `Control+b`, then click menu item **edit.clearFormatting**
+
+**Then**
+- **A1** shows `hello`
+- **A1** is bold=false
+
+#### Clicking Help > Keyboard shortcuts opens the shortcuts panel (a chrome-only action, not a registry command)
+
+**Given**
+
+**When** I click menu item **help.shortcuts**
+
+**Then**
+- `[data-testid="shortcuts-panel"]` is visible
+
+#### A submenu leaf (Format > Text > Underline) is reachable and runs its real command
+
+**Given**
+- **A1** = `hello`
+
+**When** I click **A1**, then click menu item **format.underline**
+
+**Then**
+- **A1** is underline
+
+#### A submenu leaf (Format > Number > Currency) is reachable and runs its real command
+
+**Given**
+- **A1** = `1234.5`
+
+**When** I click **A1**, then click menu item **format.numberCurrency**
+
+**Then**
+- **A1** shows `1234.5`
+- **A1** is numberFormat=currency
+
+#### A checkbox item nested inside a submenu (View > Show > Show gridlines) is still reachable and clickable
+
+**Given**
+
+**When** I click menu item **view.gridlines**
+
+**Then**
+- `[data-menu-item="view.gridlines"]` is visible
diff --git a/content/docs/learn/playground/merge-cells.mdx b/content/docs/learn/playground/merge-cells.mdx
new file mode 100644
index 0000000..1dcb88b
--- /dev/null
+++ b/content/docs/learn/playground/merge-cells.mdx
@@ -0,0 +1,188 @@
+---
+title: "Merge cells"
+description: "A merged range renders, selects, and navigates as one spanning cell; merging clears every cell but the top-left, and undoes/redoes as one command."
+---
+
+{/* GENERATED — do not edit by hand */}
+
+## What it does
+
+Select a range and click the toolbar's **Merge cells** split button (`⧉`) to collapse it into one spanning cell — matching Google Sheets' Format ▸ Merge cells. The icon itself merges **all** (one click); its chevron opens a dropdown with **Merge all / Merge vertically / Merge horizontally / Unmerge** — Merge vertically collapses EACH COLUMN of the selection into its own top-to-bottom span (a 3-column selection produces 3 independent merges, not one big rect); Merge horizontally is the row analogue. Unavailable options grey out rather than disappear: Merge vertically for a single-row selection, Merge horizontally for a single-column selection, Unmerge when the active cell isn't part of a merge.
+
+If any cell other than a resulting merge's own top-left carries content, an in-app **"Heads up!"** dialog warns that merging will clear it — _"Merging cells will only preserve the top-left value. Merge anyway?"_ — with a **"Don't show this again for 5 minutes"** checkbox alongside Cancel (aborts) and OK (merges). This is a real, testable DOM element — `[data-testid="merge-confirm-dialog"]` — not a native `window.confirm`; the split-button dropdown, the keyboard/headless command surface, and every merge mode all route through the same dialog. Click **Unmerge** to restore independent cells — the anchor keeps its value, the others stay blank (their prior content was already cleared when the merge was created; unmerge does not resurrect it).
+
+Click **any** cell inside a merged range and the whole merge is selected as one unit — the name box and formula bar read the merge's top-left address and content. Arrow keys move **past** a merge as a unit: arrowing into it from any side selects the whole merge, and arrowing again exits past its far edge rather than stepping cell-by-cell through the interior.
+
+Merging a range that overlaps an existing merge auto-unmerges the conflicting merge first, then merges the new, larger range as one — no error, no refusal.
+
+## Try it
+
+
+
+## Verified behavior
+
+_Matches Google Sheets:_ In Google Sheets, Format ▸ Merge cells is a split control offering Merge all / Merge vertically (one merge per COLUMN of the selection) / Merge horizontally (one merge per ROW) / Unmerge. If any cell OTHER than a resulting merge's own top-left carries content, Sheets warns — an in-app "Heads up!" dialog reading "Merging cells will only preserve the top-left value. Merge anyway?", with a "Don't show this again for 5 minutes" checkbox alongside Cancel/OK — before clearing them; a range with content only in the surviving anchor cell(s) merges silently. Selecting any cell inside a merge selects the WHOLE merge as one unit (the name box / formula bar show the top-left anchor's address); arrow-key navigation moves PAST a merge as a unit rather than cell-by-cell through its interior. Merging a range that overlaps an existing merge auto-unmerges the conflicting merge(s) first, then merges the new (larger) range as one — no error. Unmerging restores independent cells but does NOT resurrect content cleared at merge time — the anchor keeps its current value, every other cell in the old merge stays blank.
+
+#### Merging a 2x2 range shows content spanning the merged rect
+
+**Given**
+- **A1** = `Anchor`
+- **B1** = `b1`
+- **A2** = `a2`
+- **B2** = `b2`
+
+**When** I merge **A1:B2**
+
+**Then**
+- **A1** shows `Anchor`
+- **B1** shows `null`
+- **A2** shows `null`
+- **B2** shows `null`
+
+#### Clicking any cell inside the merge selects the whole merged range
+
+**Given**
+- **A1** = `Anchor`
+
+**When** I merge **A1:B2**, then click **B2**
+
+**Then**
+- the active cell is **A1**
+
+#### Merging then typing into the merged cell then unmerging restores separate (still-empty) cells
+
+**Given**
+- **A1** = `x`
+- **B1** = `y`
+- **A2** = `z`
+- **B2** = `w`
+
+**When** I merge **A1:B2**, then type `42` into **A1**, then unmerge **A1**
+
+**Then**
+- **A1** shows `42`
+- **B1** shows `null`
+- **A2** shows `null`
+- **B2** shows `null`
+
+#### Merging a single cell is a no-op
+
+**Given**
+- **A1** = `solo`
+
+**When** I merge **A1:A1**
+
+**Then**
+- **A1** shows `solo`
+
+#### Merging then undoing restores the separate cells' content in ONE step
+
+**Given**
+- **A1** = `x`
+- **B1** = `y`
+
+**When** I merge **A1:B1**, then click **A1**, then undo
+
+**Then**
+- **A1** shows `x`
+- **B1** shows `y`
+
+#### Merging vertically produces one merge PER COLUMN, not one big merge
+
+**Given**
+- **A1** = `colA`
+- **B1** = `colB`
+- **A2** = `a2`
+- **B2** = `b2`
+
+**When** I merge **A1:B2** vertically
+
+**Then**
+- **A1** shows `colA`
+- **B1** shows `colB`
+- **A2** shows `null`
+- **B2** shows `null`
+
+#### Merging vertically then undoing restores every column's content in ONE step
+
+**Given**
+- **A1** = `x`
+- **B1** = `y`
+- **A2** = `p`
+- **B2** = `q`
+
+**When** I merge **A1:B2** vertically, then click **A1**, then undo
+
+**Then**
+- **A1** shows `x`
+- **B1** shows `y`
+- **A2** shows `p`
+- **B2** shows `q`
+
+#### Merging horizontally produces one merge PER ROW, not one big merge
+
+**Given**
+- **A1** = `row1`
+- **A2** = `row2`
+- **B1** = `b1`
+- **B2** = `b2`
+
+**When** I merge **A1:B2** horizontally
+
+**Then**
+- **A1** shows `row1`
+- **A2** shows `row2`
+- **B1** shows `null`
+- **B2** shows `null`
+
+#### Merging horizontally then undoing restores every row's content in ONE step
+
+**Given**
+- **A1** = `x`
+- **A2** = `y`
+- **B1** = `p`
+- **B2** = `q`
+
+**When** I merge **A1:B2** horizontally, then click **A1**, then undo
+
+**Then**
+- **A1** shows `x`
+- **A2** shows `y`
+- **B1** shows `p`
+- **B2** shows `q`
+
+#### The merge confirm dialog is a real, testable in-app element — not native browser chrome
+
+**Given**
+- **A1** = `anchor`
+- **B1** = `other`
+
+**When** I select **A1:B1**, then open the merge dropdown, then pick **all** from the merge dropdown
+
+**Then**
+- `[data-testid="merge-confirm-dialog"]` is visible containing "Heads up!"
+
+#### Clicking OK on the merge confirm dialog applies the merge
+
+**Given**
+- **A1** = `anchor`
+- **B1** = `other`
+
+**When** I select **A1:B1**, then open the merge dropdown, then pick **all** from the merge dropdown, then click OK on the merge confirm dialog
+
+**Then**
+- **A1** shows `anchor`
+- **B1** shows `null`
+
+#### Clicking Cancel on the merge confirm dialog aborts the merge — nothing changes
+
+**Given**
+- **A1** = `anchor`
+- **B1** = `other`
+
+**When** I select **A1:B1**, then open the merge dropdown, then pick **all** from the merge dropdown, then click Cancel on the merge confirm dialog
+
+**Then**
+- **A1** shows `anchor`
+- **B1** shows `other`
+- `[data-testid="merge-confirm-dialog"]` is hidden
diff --git a/content/docs/learn/playground/meta.json b/content/docs/learn/playground/meta.json
index dbc53c2..ccea166 100644
--- a/content/docs/learn/playground/meta.json
+++ b/content/docs/learn/playground/meta.json
@@ -34,6 +34,15 @@
"reference-translation",
"shortcuts",
"spill-rendering",
- "view-workbook"
+ "view-workbook",
+ "active-row-column-bands",
+ "column-row-resize",
+ "error-tooltip",
+ "explain-result",
+ "format-rotation",
+ "format-underline",
+ "menu-bar",
+ "merge-cells",
+ "persistent-history"
]
}
diff --git a/content/docs/learn/playground/persistent-history.mdx b/content/docs/learn/playground/persistent-history.mdx
new file mode 100644
index 0000000..b88a0f7
--- /dev/null
+++ b/content/docs/learn/playground/persistent-history.mdx
@@ -0,0 +1,36 @@
+---
+title: "Persistent history — autosave + restore across reload"
+description: "The document AND its command log (History panel entries, undo/redo) are continuously autosaved to browser storage and silently restored on reopen — no explicit save needed — while importing a foreign document still starts fresh, never inheriting a previous session's log."
+---
+
+{/* GENERATED — do not edit by hand */}
+
+## What it does
+
+Studio now autosaves **continuously** — the document and its full command log (every entry the [History panel](./command-history) shows, plus enough to keep undo/redo working) are written to browser storage as you work, with no explicit "Save" step. Reopen the tab and everything comes back: the grid's cells/formats, the History panel's timeline, and Ctrl+Z/Ctrl+Y both still walk the exact same steps they would have if the tab had never closed. The storage-indicator footer's trailing **ⓘ** button opens a small panel showing what's actually stored (a document? how many history entries?) and a **Clear stored session** action, so nothing about what's saved is hidden.
+
+**Importing a different document still starts fresh.** Loading a _foreign_ file — Import JSON, or the pre-existing Save/Load-to-browser slot from [persist](./persist) — has never been undoable (it drops history so a bad import can't leave partial undo steps); this feature doesn't change that. Only the NEW "restore session" path (silently run on boot) reattaches a log, and only when it's the SAME app's own previously-autosaved session.
+
+## Manifest coverage — and its honest gap
+
+The manifest scenario DSL drives the REAL running app through Playwright, but has **no reload-equivalent verb** — there is no way to script "close and reopen the tab" — and its one `import` verb is [data-import](./data-import), not the full-workbook JSON replace ("Import a document") this feature's fresh-history guarantee is about. Extending the verb vocabulary for a one-off reload action seemed like exactly the kind of large, single-purpose schema addition the methodology asks NOT to force — so those two acceptance criteria are unit/integration-tested instead, at the layer that actually matters (headless, no browser needed):
+
+- **edit → reload → document/History-panel/undo-redo restored**: a dedicated persistence test (`saveSession` from one `Store`, `restoreSession` into a second, independent `Store` standing in for "after a reload" — proving the document, `history`, `canUndo`, and actual `undo`/`redo` all come back correct) plus a command-log test (the `Store.commandLog`/`restoreCommandLog` contract in isolation).
+- **a version-mismatched/corrupt log safely degrades**: a dedicated schema-version test plus a matching case in the persistence test.
+- **importing a foreign document starts fresh**: the persistence test proves a compatible log sitting in storage is NOT reattached when the load goes through the existing `replaceWorkbook` path (Import/Load-from-browser's path) instead of the new `restoreSession` — only the latter ever calls `restoreCommandLog`.
+
+The one thing above the manifest CAN reach the real running app for — the storage-indicator now reading `local` from first paint — is the scenario in this manifest's frontmatter.
+
+## Verified behavior
+
+_Matches Google Sheets:_ Google Sheets autosaves continuously to Drive and its Version History survives reload/reopen by design — there is no "lost history" mode to replicate. Studio has no server, so the equivalent here is the local, browser-only version of the same guarantee: reopening the tab restores the document, its History panel timeline, and undo/redo exactly as Sheets reopening a Drive file restores its version history. Opening a genuinely DIFFERENT file (Sheets' File → Open, or here, Import) starting a fresh version history rather than continuing the previous file's is also standard behavior both products share.
+
+#### Once the app boots, the storage indicator reads "saved in this browser" — autosave is live from the first paint, not an opt-in step
+
+**Given**
+- **A1** = `1`
+
+**When** I click **A1**
+
+**Then**
+- `[aria-label="Storage status"]` is visible containing "Saved in this browser"