From 9a4dad6e50d800c41dbc931a60e8de2ef986cb2e Mon Sep 17 00:00:00 2001 From: hhimanshu <6589036+hhimanshu@users.noreply.github.com> Date: Wed, 5 Aug 2026 04:16:29 +0000 Subject: [PATCH] docs(learn): sync generated Playground pages --- .../playground/active-row-column-bands.mdx | 2 +- .../learn/playground/chart-axis-labels.mdx | 135 ++++++ .../docs/learn/playground/chart-insert.mdx | 451 ++++++++++++++++++ .../learn/playground/clear-paint-format.mdx | 2 +- .../learn/playground/column-row-resize.mdx | 2 +- .../docs/learn/playground/command-palette.mdx | 107 +++++ .../playground/conditional-formatting.mdx | 148 ++++++ .../docs/learn/playground/date-parsing.mdx | 4 +- content/docs/learn/playground/date-picker.mdx | 5 +- .../learn/playground/display-formatting.mdx | 14 +- .../docs/learn/playground/error-tooltip.mdx | 2 +- .../docs/learn/playground/explain-result.mdx | 8 +- content/docs/learn/playground/file-open.mdx | 41 ++ .../docs/learn/playground/format-rotation.mdx | 2 +- .../playground/formula-range-preview.mdx | 194 ++++++++ .../learn/playground/grid-context-menu.mdx | 401 ++++++++++++++++ .../docs/learn/playground/history-revert.mdx | 5 +- content/docs/learn/playground/make-a-copy.mdx | 48 ++ content/docs/learn/playground/menu-bar.mdx | 58 ++- content/docs/learn/playground/merge-cells.mdx | 2 +- content/docs/learn/playground/meta.json | 14 +- .../playground/navigate-range-select.mdx | 31 +- .../learn/playground/persistent-history.mdx | 2 +- .../docs/learn/playground/share-config.mdx | 69 +++ content/docs/learn/playground/shortcuts.mdx | 4 +- content/docs/learn/playground/sort-range.mdx | 291 +++++++++++ .../docs/learn/playground/spill-rendering.mdx | 77 ++- .../playground/structure-insert-delete.mdx | 175 +++++++ content/docs/learn/playground/undo.mdx | 2 +- .../docs/learn/playground/view-workbook.mdx | 4 +- content/docs/learn/playground/views.mdx | 147 ++++++ 31 files changed, 2409 insertions(+), 38 deletions(-) create mode 100644 content/docs/learn/playground/chart-axis-labels.mdx create mode 100644 content/docs/learn/playground/chart-insert.mdx create mode 100644 content/docs/learn/playground/command-palette.mdx create mode 100644 content/docs/learn/playground/conditional-formatting.mdx create mode 100644 content/docs/learn/playground/file-open.mdx create mode 100644 content/docs/learn/playground/formula-range-preview.mdx create mode 100644 content/docs/learn/playground/grid-context-menu.mdx create mode 100644 content/docs/learn/playground/make-a-copy.mdx create mode 100644 content/docs/learn/playground/share-config.mdx create mode 100644 content/docs/learn/playground/sort-range.mdx create mode 100644 content/docs/learn/playground/structure-insert-delete.mdx create mode 100644 content/docs/learn/playground/views.mdx diff --git a/content/docs/learn/playground/active-row-column-bands.mdx b/content/docs/learn/playground/active-row-column-bands.mdx index 40eae6e..eace2ae 100644 --- a/content/docs/learn/playground/active-row-column-bands.mdx +++ b/content/docs/learn/playground/active-row-column-bands.mdx @@ -26,7 +26,7 @@ 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. +_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 from the start, 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) diff --git a/content/docs/learn/playground/chart-axis-labels.mdx b/content/docs/learn/playground/chart-axis-labels.mdx new file mode 100644 index 0000000..6f0f900 --- /dev/null +++ b/content/docs/learn/playground/chart-axis-labels.mdx @@ -0,0 +1,135 @@ +--- +title: "A dense category axis stays readable" +description: "A chart whose categories are packed tighter than their labels are wide does not draw them on top of each other. The category axis climbs a ladder measured against the room it actually has — slant the labels to 45°, then stand them up at 90°, then drop to every Nth label — and cuts a label with an ellipsis rather than let the label band eat the plot. A band axis on the vertical side thins the same way but is never rotated; its gutter grows instead.\n" +--- + +{/* GENERATED — do not edit by hand */} + +## What it does + +A column, line, area, combo or box-plot chart puts its **categories** on the horizontal axis. +When there are more categories than the plot is wide enough to label, the axis does not draw +every label anyway — it climbs a ladder, in this order, and stops at the first rung that fits: + +1. **Horizontal**, one label per category — whenever each label is narrower than the slice of + axis its category owns. +2. **Slanted 45°**, still one label per category. +3. **Vertical 90°**, still one label per category. +4. **Vertical 90° plus thinning** — every *N*th label, where *N* is the smallest stride that + keeps neighbouring labels apart. + +The rung is chosen from **measured text**, never from a category count: the labels are measured +against the plot width the chart actually got, so the same chart narrows through the rungs as +its panel narrows, and widens back out again. + +Two details are worth stating because they surprise people, and both come straight from Google +Sheets: + +- **Thinning counts from the FIRST category and does not keep the last.** Over 30 categories at + stride 2 you get Cat 1, 3, 5 … 29 — Cat 30 is dropped. That is what Sheets does, and + "helpfully" pinning the last category would put two labels a half-stride apart. +- **A long label is cut with an ellipsis, not shrunk.** Google Sheets cuts the text + (`Long category na…`) once the tick-label band would grow past a limit, so the labels never + eat the plot. Only one measurement of that limit exists, and it cannot say whether the limit + scales with the chart — so the specific number here is a **choice**, not observed behaviour: + the band may grow to about 22.8% of the chart's height, which is the one measured band read + as a proportion. + +A **band axis on the vertical side** — a horizontal bar chart's categories — thins with the +identical stride but is **never rotated and never truncated**. It has room to do that because +its gutter is already sized to the longest category name in the range, whether or not that +particular label survives the thinning. + +A **numeric axis is deliberately untouched.** Its tick count is already chosen by the value +scale, which picks round numbers and so never packs them tighter than they fit. A dense numeric +axis was never observed in Google Sheets either, so there is no ground truth to replicate and +none is invented. + +## Try it + + + +## Verified behavior + +_Matches Google Sheets:_ In Google Sheets, a category axis NEVER draws overlapping tick labels. At every chart width driven — 1201, 600, 541, 481, 421, 361, 301 and 181 CSS px, all over the same 30 categories — the labels stayed individually readable. The response to density is an escalating ladder: horizontal, then horizontal wrapped onto two lines, then slanted about 45°, then vertical at 90°, then vertical PLUS thinning. Every rung change is reproduced by one number — a minimum separation between adjacent labels of about 12.5 CSS px — measured against the room each category actually gets (plot width divided by the category count): 18.3px per category drew 45°, 16.3px drew 90°, 14.3px still drew every label, and 12.3px thinned to stride 2. The stride is PURELY MODULAR FROM THE FIRST category and does NOT force-keep the last one: over 30 categories, stride 2 drew Cat 1, 3, 5 … 29 and dropped Cat 30, and stride 3 drew Cat 1, 4, 7 … 28 and dropped both Cat 29 and Cat 30. A long label is cut with a single ellipsis character rather than thinned or shrunk, and the cut is driven by how tall the tick-label BAND is allowed to grow rather than by the axis width — the chart was wide enough to show every label in full and it still cut them. Only ONE measurement of that limit was taken (84.5px of band on a 371px-tall chart), so the rule behind it is not established: a fraction of the chart's height, a fixed maximum, and a fraction of the plot area all fit that single number equally well. A BAND Y-AXIS thins with the identical modular stride but is neither rotated nor truncated — Sheets widened the y-axis gutter to about 125px and drew each surviving label horizontally and in full. A LINEAR/NUMERIC axis was never driven into a dense state at all: the value axes seen carried 2–5 nice-number ticks chosen by the scale, so this ground truth says nothing about them and Studio deliberately leaves them alone. (Live-observed in the reference Google Sheets workbook on 2026-08-05, across eight chart widths over the same 30 categories, with every number above measured off the rendered pixels.) + +#### A dense category axis stands its labels up and thins them instead of colliding + +**Given** +- **A1** = `Cat 1` +- **B1** = `17` +- **A2** = `Cat 2` +- **B2** = `24` +- **A3** = `Cat 3` +- **B3** = `31` +- **A4** = `Cat 4` +- **B4** = `38` +- **A5** = `Cat 5` +- **B5** = `45` +- **A6** = `Cat 6` +- **B6** = `52` +- **A7** = `Cat 7` +- **B7** = `59` +- **A8** = `Cat 8` +- **B8** = `16` +- **A9** = `Cat 9` +- **B9** = `23` +- **A10** = `Cat 10` +- **B10** = `30` +- **A11** = `Cat 11` +- **B11** = `37` +- **A12** = `Cat 12` +- **B12** = `44` +- **A13** = `Cat 13` +- **B13** = `51` +- **A14** = `Cat 14` +- **B14** = `58` +- **A15** = `Cat 15` +- **B15** = `15` +- **A16** = `Cat 16` +- **B16** = `22` +- **A17** = `Cat 17` +- **B17** = `29` +- **A18** = `Cat 18` +- **B18** = `36` +- **A19** = `Cat 19` +- **B19** = `43` +- **A20** = `Cat 20` +- **B20** = `50` +- **A21** = `Cat 21` +- **B21** = `57` +- **A22** = `Cat 22` +- **B22** = `14` +- **A23** = `Cat 23` +- **B23** = `21` +- **A24** = `Cat 24` +- **B24** = `28` +- **A25** = `Cat 25` +- **B25** = `35` +- **A26** = `Cat 26` +- **B26** = `42` + +**When** I select **A1:B26**, then choose Insert ▸ Chart + +**Then** +- the chart is seriesCount=`1`, axisLabels=`{"painted":true,"axes":[{"x":{"rotationDeg":90,"stride":2,"drawn":["Cat 1","Cat 3","Cat 5","Cat 7","Cat 9","Cat 11","Cat 13","Cat 15","Cat 17","Cat 19","Cat 21","Cat 23","Cat 25"]},"y":null}]}` + +#### A sparse category axis is left alone — no rotation, no thinning + +**Given** +- **A1** = `Q1` +- **B1** = `10` +- **A2** = `Q2` +- **B2** = `20` +- **A3** = `Q3` +- **B3** = `30` +- **A4** = `Q4` +- **B4** = `40` + +**When** I select **A1:B4**, then choose Insert ▸ Chart + +**Then** +- the chart is axisLabels=`{"painted":true,"axes":[{"x":{"rotationDeg":0,"stride":1,"drawn":["Q1","Q2","Q3","Q4"]},"y":null}]}` diff --git a/content/docs/learn/playground/chart-insert.mdx b/content/docs/learn/playground/chart-insert.mdx new file mode 100644 index 0000000..19cdaaf --- /dev/null +++ b/content/docs/learn/playground/chart-insert.mdx @@ -0,0 +1,451 @@ +--- +title: "Insert a chart from a range" +description: "Selecting a range and choosing Insert ▸ Chart charts it into a docked chart panel bound to that range — a header row becomes the series names, a blank cell plots as a gap rather than a zero, a cell's number format carries onto the value axis, and the whole insert undoes in one step. The panel shows whichever chart the active sheet holds, however it got there, so a chart stays on screen across a reload and View ▸ Show ▸ Show chart brings back one you closed." +--- + +{/* GENERATED — do not edit by hand */} + +## What it does + +Select a range and choose **Insert ▸ Chart**. A chart appears bound to that range, in a +**docked panel** beside the grid — pick a cell/range, open **Insert**, click **Chart**. There +is no type-picker dialog first: the chart draws immediately. + +The binding is the part that matters. A chart is not a snapshot of some numbers; it holds a +**reference to a range**, resolved fresh every time the panel redraws — edit a charted cell +and the chart updates with it. + +Two rules the renderer enforces, both visible in the scenarios above: + +- **A blank cell is a gap, not a zero.** A bar is skipped, a line breaks. Plotting a missing + value at the baseline is a lie about the data, and it is the single most common way charts + mislead. +- **The cell's number format is the axis format.** A currency column charts with a currency + axis, because the format belongs to the data, not to the chart. + +A single unnamed series renders with **no legend** — matching Google Sheets (oracle behaviour +7). A header row becomes the series names and brings a legend with it. An empty or single-cell +selection creates nothing — there is no data to chart. + +## Try it + + + +## When the chart is on screen — the panel binds to the document, not to the click + +Studio **docks the chart itself** (see the ADR note above), so this panel is not an editor you +opt into the way Sheets' "Chart editor" is: it is the chart's only rendering surface. A chart in +the document that draws nowhere is a chart you have no reason to believe was ever saved. So the +rule is: + +**The active sheet's chart is shown whenever there is one, unless you turned it off and nothing +has changed since — a choice that now survives a reload.** + +Concretely, the app shell subscribes to `Store.chartsOnActive()` and derives the panel's bound chart +from it. Every route that can produce a chart therefore ends with the chart on screen — the menu, +the command palette, an agent's `chart:insert`, a reload, a restored document, or redo of an +undone insert — and every route that can remove one unbinds the panel, including switching to a +sheet the chart isn't on. Whenever a chart **arrives**, the panel binds to *that* chart, so +inserting a second one shows the second one rather than snapping back to the sheet's first — +through the wire API exactly as through the menu. + +**Closing it is a real choice, not a one-way door.** The ✕ (or Escape) hides the panel; the chart +record is untouched. **View ▸ Show ▸ Show chart** brings it back — a checkbox row beside `Show +gridlines` and `Show formula bar`, because it is the same kind of thing: one chrome surface's +visibility, not a change to the document. It is disabled ("No chart on this sheet.") when the +active sheet has none, and — unlike `Insert ▸ Chart` — it is **not** gated on the charts Preview +flag: that flag stages *creating* a chart, and a chart already in your document is document +content. `Insert ▸ Chart` is never overloaded into "open the existing one"; it always creates. + +**Unchecking it sticks across a reload**. All three `View ▸ Show` rows are stored +together as local UI preferences (their own versioned storage key — *not* the +preview-features one, which means "opt-in staging flag"), so the box you unchecked is still +unchecked when you come back. They used to be session-only, which meant unchecking `Show chart` +and reloading brought both the chart *and* its tick back; that was consistent with its two +neighbours, since all three were session-only, so all three were fixed rather than adding a +third instance of the divergence. + +**Two things override a dismissal**, because each is new information that your earlier "not now" +could not have been about: + +- **A chart arrives.** You closed a panel showing some other chart, or none at all; that cannot + sensibly suppress a chart which did not exist yet. Without this rule an agent's `chart:insert` + reproduced the original defect exactly — a chart in the document, nothing on screen. Note the *first* + read after boot is a baseline, not an arrival: a document restoring its own charts is the very + set you dismissed, so a reload honours your choice instead of undoing it. +- **The document is replaced** (File ▸ Load from browser, Import JSON backup, Import data). The + new document's charts were never dismissed; the previous document's were. + +This is what keeps an agent-created chart visible while the preference persists: the guarantee was never "a +reload always re-opens the panel", it is "a chart the user has not turned off is on screen, and +there is always a way back to one they did". + +> **Possible divergence — REPORTED, NOT VERIFIED.** Google Sheets is *said* to store `Show +> gridlines` as a sheet property inside the document (travelling with the file, so everyone +> opening a shared workbook sees the author's choice) while `Show formula bar` is a per-user app +> preference. **This repo has not observed either claim** — the oracle brief §7 still lists +> reload/persistence behaviour as unobserved, and moving an unverified claim into this file would +> not make it true, it would only move it out of the file that tracks what is unverified. Treat it +> as a lead for a future oracle pass, not as ground truth, and do not build on it. +> +> What IS settled is our own behaviour: **all three toggles are per-browser here.** Whether +> gridlines should instead live in the document is a schema decision this PR deliberately does not +> take. + +**Closing another docked panel returns the slot to the chart.** History, Settings and Conditional +formatting borrow the one docked slot, so the chart is that slot's resting state +whenever there is a chart you haven't closed. Opening History over a chart and closing it again +leaves the chart showing, rather than silently emptying the slot. This is done at each panel's own +close, *not* by keying the auto-open effect off `activePanel` — that would re-open the chart the +instant you opened another panel, fighting you for the slot you just asked for. + +> **Open question — needs a live oracle observation.** *Which* chart the panel shows when a sheet +> holds several is not decided here: it shows the first, and there is no chart picker. The +> reopen interaction that would inform it — Sheets' own chart ⋮ menu and its "Edit chart" route +> back into the editor for an *existing* chart — is listed as NOT OBSERVED in +> the oracle brief (gaps 3 and 4), and nothing here guesses at it. Note that the oracle cannot +> settle the question this manifest *does* answer: Sheets floats the chart as a grid object and +> docks only the editor, so it has no surface corresponding to Studio's docked panel. The +> "a chart in the document is on screen" rule above follows from the docked-placement ADR, and +> is recorded as a product decision, not as observed Sheets behaviour. +> +> One consequence worth naming: the command palette's catalogue is built from the *static* menu +> model (`PALETTE_ENTRIES`), so both `Chart` and `Show chart` appear +> there permanently greyed regardless of live state — the same pre-existing staleness +> `Insert ▸ Chart` already had in the palette, not something new to this row. The menu bar +> itself is live. `CommandPaletteActions.onToggleChartPanel` is therefore **dead on arrival**: +> wired so the switch stays a complete mirror of the menu bar's own `runItem`, and so the row works +> the day the catalogue goes live, but unreachable until then. + +> **Known coverage gap.** The scenario harness's `insertChart` verb always drives +> `CanvasGrid.insertChart()` — the `chart.insert` operation — never App's React `insertChart()` +> handler, deliberately, because the real `Insert ▸ Chart` menu row is gated on a +> `localStorage` preview flag the harness reuses one browser page across (see the harness's own +> reasoning). So **no scenario exercises the React handler**: its notice-on-degenerate-selection, +> its `setActivePanel('chart')` and its preference write are covered only by the manual +> browser pass recorded on this feature's issue, not by the suite. Everything the scenarios above assert +> about a chart appearing is therefore asserted about the *operation* path — which is precisely +> the path an agent, the wire API and MCP take, so the gap is on the human route, not the +> machine one. + +## Charts and the Preview-features toggle — what "Enable charts" actually gates + +**"Enable charts" gates the `Insert ▸ Chart` MENU ITEM only.** It is a *UI preference*, stored +in this browser's `localStorage` (`truecalc.studio.previewFeatures.v1`), not a property of the +document. + +The consequence, stated plainly because it is otherwise surprising enough to be filed as a +bug: **with the toggle off, an agent can still create a chart in your document.** The command +succeeds, the `ChartRecord` lands, and it survives save/load like any other document content. +That is intended, not a defect. + +The reason is that the alternative is worse. Promoting a browser preference into something a +headless caller could read would make your document's capabilities depend on one browser's +settings — a workbook would behave differently through the API depending on which machine last +opened it. So the flag stays what it is: a local staging control for an in-progress menu item. + +**An agent-created chart IS visible in the app — with the toggle in either position.** The +panel's binding is derived from the document, not from the insert event: the app shell +subscribes to `Store.chartsOnActive()` and shows the active sheet's chart whichever way it +arrived — the menu, `chart:insert`, a reload, or a restored document — and an arriving chart +binds the panel to itself and overrides an earlier dismissal, so this holds even in a session +where the user has closed the panel by hand. The toggle stays a +staging control for the *menu row*; it has never gated the insert path, and it does not gate +looking at a chart the document already holds either. Showing document content must not depend +on a browser-local preference — that combination is exactly what made an agent's chart +invisible before this feature landed. + +## Charts from the API, an agent, or MCP + +Charting is reachable from all three of Studio's agent-facing surfaces, not just the menu: + +| Surface | Entry point | +|---|---| +| Operation registry (keyboard, command palette, headless) | `chart.insert` | +| Wire commands | `chart:insert`, `chart:update`, `chart:remove` | +| MCP tools | `studio__chart__insert`, `studio__chart__update`, `studio__chart__remove` | + +`chart:insert` takes an **A1 range** (`{ from: "A1", to: "B3" }` — the same way every other +range-taking command addresses cells, since an API caller has no selection to infer one from), +an explicit `chartType`, and optional renderer `options`; it returns the new chart's +**`chartId`**, which `chart:update` and `chart:remove` address the chart by. The whole insert +is **one undoable step**, exactly like the menu path. + +Two things it will refuse, rather than create something that says nothing: + +- **A single-cell range.** There is no data to chart — the same v1 rule the menu path applies. +- **A chart form that cannot draw the selected data.** `chart:insert` runs the *same* + applicability rule the panel's type picker does — one shared rule, not a second + implementation — and returns the *same sentence* the + picker shows on hover — e.g. asking for a scatter over a single numeric column returns + "Scatter needs at least two numeric series (an x column and a y column)." The rule and its + wording live in one place and are shared by both surfaces, so an agent gets a correctable + error where the picker would have shown a tooltip. Note this covers a **form/shape + mismatch** only, and four forms are exempt from it entirely — see the next section before + relying on it. + +`chart:update` applies the same check before switching an existing chart's form, and validates +against the options being set in that same command — so fixing a Sankey by re-assigning its +`columnRoles` in one call works, rather than being rejected against the stale ones. Neither +command can re-point a chart's `range`: a new range means a new chart. + +### What the applicability guard does and does not promise + +The guard checks **type against shape** — whether *this chart form* can draw *the data +currently in the range*. It is **not** an emptiness check, and it is not a promise that every +chart in your document draws something. Three consequences follow; none is a bug, and the third +is the one most likely to surprise. + +**Four forms are never gated at all.** `column`, `bar`, `line` and `area` degrade gracefully to +any `ChartData`, so there is no shape they can misrepresent and nothing to reject them on. +Every other form — `pie`, `scatter`, `box-plot`, `slope`, `dumbbell`, `small-multiples`, +`calendar-heatmap`, `sankey` — has a rule. + +**So whether you can chart first and fill the cells afterwards depends on the form.** With the +four ungated forms it works: `chart:insert` with `chartType: "column"` over an empty range +succeeds, returns a `chartId`, and the chart fills in as data arrives. With any gated form it +does not — `chartType: "scatter"` over an empty range is rejected with `chart:insert rejected +chartType "scatter": Scatter needs at least two numeric series (an x column and a y column).`, +because at that instant the range genuinely cannot be scattered. For those, write the cells +first (`edit:setCell`), then insert. + +**`column` is the default, and it will happily persist a chart that draws nothing.** That is +the honest reading of the two points above: `Insert ▸ Chart`, the `chart.insert` operation and +`chart:insert` with `chartType: "column"` all accept a real-but-empty range and store a chart +with no data in it. That is the known empty-chart defect (a chart that renders happily and says nothing) +still reachable at insert time through the ungated forms — the guard narrowed that hole, it did +not close it. The empty chart at least *appears* now — the panel opens on the +agent's insert and draws nothing — so the outcome is visible rather than silent. Visible is not +the same as refused: nothing rejects the insert or warns about it. + +**And the check is one-shot, not an invariant.** Nothing re-validates a chart after it exists, +even for a gated form. Insert a `pie` over `1/2/3`, then clear those cells with `edit:clear`, +and the chart stays in the document while applicability now reports *"Pie needs one numeric +series with at least one value."* — persisted, inapplicable, unnoticed. + +Whether an empty range should be refused, and whether emptying a charted range should re-check, +warn, or do nothing, are separate decisions — deliberately not taken here. + +### Known gap — a chart's range does not yet re-point through a structural edit + +Google Sheets re-points a chart's data range through insert/delete the way a formula reference +does (oracle behaviour 12) — insert 3 rows above `B6:B8` and Sheets moves the source to +`B9:B11` with no broken reference. **Studio's `insertRows`/`insertColumns`/`deleteRows`/ +`deleteColumns` do not adjust `model.charts` at all** (`Store.applyStructuralShift` rewrites +`cells`/`formats`/`colWidths`/`rowHeights`/`merges` for the active sheet but never touches +`charts`) — a chart's bound range is a static rectangle today. This is a real, confirmed gap +against Sheets ground truth, not a design choice; wiring the chart range through the same +`ReferenceAdjuster` formulas already use is future work, tracked against this issue. No +scenario in this manifest asserts range re-pointing, since the shipped behaviour does not have +it yet. + +### Not built, not claimed + +The full chart-type list, the Customize tab, and the chart's own ⋮ menu are marked NOT +OBSERVED in the oracle brief — nothing here asserts them. Dual-axis and 3D chart types are +deliberate, permanent divergences from Sheets (the oracle brief's own "Accepted divergences" +section). + +## Verified behavior + +_Matches Google Sheets:_ In Google Sheets, Insert ▸ Chart is a plain menu item — no submenu, no keyboard shortcut, and no chart-type picker before insertion — sitting in the insert-object group beside Pivot table, Image and Drawing. Choosing it inserts a chart IMMEDIATELY with a type chosen from the shape of the selected data (a single numeric column, B6:B8 holding 1/2/3, drew a Column chart); a spilled array range charts as an ordinary numeric range, with no special handling. A single unnamed series renders with NO legend — the default column chart drew bars and a value axis with no legend and no visible title. The chart is inserted as a FLOATING object over the grid, CELL-ANCHORED to the selection (inserting a row above the anchor moves the chart down with it; inserting rows below the anchor leaves it in place), while a separate "Chart editor" side panel docks on the right with Setup and Customize tabs. The chart's own data range re-points through a structural edit exactly like a formula reference — inserting 3 rows above B6:B8 moved its source to B9:B11 with no broken reference and no user action. A single ⌘Z undoes the whole insert — chart, anchored position and all — in one step, closing the editor with it. (Live-observed in the reference workbook on 2026-07-27 — see this feature's oracle brief, behaviours 1, 2, 3 as corrected by 11, 7, 8, 9, 11, 12, 13.) + +#### Inserting a chart from a single numeric column binds it to that range + +**Given** +- **B6** = `1` +- **B7** = `2` +- **B8** = `3` + +**When** I select **B6:B8**, then choose Insert ▸ Chart + +**Then** +- the chart is sourceRange=`"B6:B8"`, type=`"column"` + +#### A single unnamed series renders without a legend + +**Given** +- **B6** = `1` +- **B7** = `2` +- **B8** = `3` + +**When** I select **B6:B8**, then choose Insert ▸ Chart + +**Then** +- the chart is seriesCount=`1`, hasLegend=`false` + +#### A header row becomes the series names, and two series get a legend + +**Given** +- **A1** = `Quarter` +- **B1** = `North` +- **C1** = `East` +- **A2** = `Q1` +- **B2** = `10` +- **C2** = `20` +- **A3** = `Q2` +- **B3** = `12` +- **C3** = `22` + +**When** I select **A1:C3**, then choose Insert ▸ Chart + +**Then** +- the chart is seriesNames=`["North","East"]`, categories=`["Q1","Q2"]`, hasLegend=`true` + +#### A blank cell inside the range is a gap, never plotted as zero + +**Given** +- **A1** = `Q` +- **B1** = `North` +- **A2** = `Q1` +- **B2** = `10` +- **A3** = `Q2` +- **A4** = `Q3` +- **B4** = `30` + +**When** I select **A1:B4**, then choose Insert ▸ Chart + +**Then** +- the chart is values=`[10,null,30]` + +#### A cell's number format carries onto the value axis + +**Given** +- **A1** = `Q` +- **B1** = `Revenue` +- **A2** = `Q1` +- **B2** = `128400` +- **A3** = `Q2` +- **B3** = `141200` + +**When** I click **B2**, then press `Control+Shift+4`, then click **B3**, then press `Control+Shift+4`, then select **A1:B3**, then choose Insert ▸ Chart + +**Then** +- the chart is valueAxisFormat=`"currency"` + +#### Inserting a chart then undoing removes it in ONE step + +**Given** +- **B6** = `1` +- **B7** = `2` +- **B8** = `3` + +**When** I select **B6:B8**, then choose Insert ▸ Chart, then click **B6**, then undo + +**Then** +- no chart exists + +#### A chart is still on screen after a save and reload, not just still in the document + +**Given** +- **B6** = `1` +- **B7** = `2` +- **B8** = `3` + +**When** I select **B6:B8**, then choose Insert ▸ Chart, then save, reload the page, and load the workbook back + +**Then** +- `[data-testid="chart-canvas"]` is visible +- `[data-testid="chart-panel-range"]` is visible containing "B6:B8" +- the chart is sourceRange=`"B6:B8"`, type=`"column"` + +#### An insert through the chart.insert operation puts the chart on screen, not just in the document + +**Given** +- **B6** = `1` +- **B7** = `2` +- **B8** = `3` + +**When** I select **B6:B8**, then choose Insert ▸ Chart + +**Then** +- `[data-testid="chart-canvas"]` is visible + +#### A chart arriving after the panel was closed re-opens it — a dismissal cannot suppress a chart that did not exist yet + +**Given** +- **B6** = `1` +- **B7** = `2` +- **B8** = `3` +- **D2** = `5` +- **D3** = `9` +- **D4** = `4` + +**When** I select **B6:B8**, then choose Insert ▸ Chart, then click **chart-panel-close**, then select **D2:D4**, then choose Insert ▸ Chart + +**Then** +- `[data-testid="chart-canvas"]` is visible +- `[data-testid="chart-panel-range"]` is visible containing "D2:D4" + +#### Closing another docked panel returns the slot to the chart, not to nothing + +**Given** +- **B6** = `1` +- **B7** = `2` +- **B8** = `3` + +**When** I select **B6:B8**, then choose Insert ▸ Chart, then click **toolbar-history**, then click **history-panel-close** + +**Then** +- `[data-testid="chart-canvas"]` is visible + +#### Closing the chart panel hides the chart without removing it from the document + +**Given** +- **B6** = `1` +- **B7** = `2` +- **B8** = `3` + +**When** I select **B6:B8**, then choose Insert ▸ Chart, then click **chart-panel-close** + +**Then** +- `[data-testid="chart-canvas"]` is hidden +- the chart is sourceRange=`"B6:B8"` + +#### Unchecking Show chart survives a reload — the chart stays hidden, it does not silently come back + +**Given** +- **B6** = `1` +- **B7** = `2` +- **B8** = `3` + +**When** I select **B6:B8**, then choose Insert ▸ Chart, then click menu item **view.chart**, then wait 1200ms, then reload the page + +**Then** +- `[data-testid="chart-canvas"]` is hidden +- the chart is sourceRange=`"B6:B8"` + +#### Escape that cancels a cell edit leaves the chart panel open — one Escape belongs to one handler + +**Given** +- **B6** = `1` +- **B7** = `2` +- **B8** = `3` + +**When** I select **B6:B8**, then choose Insert ▸ Chart, then double-click **E10**, then press `h`, then press `Escape`, then wait 600ms + +**Then** +- **E10** shows `null` +- `[data-testid="chart-canvas"]` is visible + +#### View ▸ Show ▸ Show chart brings a closed chart back + +**Given** +- **B6** = `1` +- **B7** = `2` +- **B8** = `3` + +**When** I select **B6:B8**, then choose Insert ▸ Chart, then click **chart-panel-close**, then click menu item **view.chart** + +**Then** +- `[data-testid="chart-canvas"]` is visible + +#### Insert with an empty (single-cell) selection does not create a chart + +**Given** +- **B6** = `1` + +**When** I select **E20:E20**, then choose Insert ▸ Chart + +**Then** +- no chart exists diff --git a/content/docs/learn/playground/clear-paint-format.mdx b/content/docs/learn/playground/clear-paint-format.mdx index 1ac1c26..5d34a6f 100644 --- a/content/docs/learn/playground/clear-paint-format.mdx +++ b/content/docs/learn/playground/clear-paint-format.mdx @@ -18,7 +18,7 @@ Applying **replaces** the target's format wholesale, the same way Sheets does: i ## Verification -The scenario runner can drive Ctrl+\\ end-to-end (a real keyboard chord), so clear-formatting's scenarios above are fully automated. Paint format's arm/apply gesture is a toolbar click followed by a click on a DIFFERENT target cell — the runner has no verb for driving a toolbar action button yet (the same gap `format-fill-color.mdx` documents for its color swatch), so it's browser-verified instead: arming via a single click on the roller shows the pressed toolbar state and the source range gets a dashed "copying" outline; clicking a target cell applies the source's format, replaces the target's own prior formatting, and the roller un-presses itself (one-shot); double-clicking the roller instead keeps it pressed across repeated target clicks until Escape. +The scenario runner can drive Ctrl+\\ end-to-end (a real keyboard chord), so clear-formatting's scenarios above are fully automated. Paint format's arm/apply gesture is a toolbar click followed by a click on a DIFFERENT target cell — the runner has no verb for driving a toolbar action button yet (the same gap the fill-color feature documents for its color swatch), so it's browser-verified instead: arming via a single click on the roller shows the pressed toolbar state and the source range gets a dashed "copying" outline; clicking a target cell applies the source's format, replaces the target's own prior formatting, and the roller un-presses itself (one-shot); double-clicking the roller instead keeps it pressed across repeated target clicks until Escape. ## Verified behavior diff --git a/content/docs/learn/playground/column-row-resize.mdx b/content/docs/learn/playground/column-row-resize.mdx index 3b02f7a..bf547e5 100644 --- a/content/docs/learn/playground/column-row-resize.mdx +++ b/content/docs/learn/playground/column-row-resize.mdx @@ -17,7 +17,7 @@ description: "Dragging a column/row header border resizes it; double-clicking a ## 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. +_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 this feature's own oracle brief if a review finds a discrepancy. #### Dragging a column border sets an explicit width that persists across save and reload diff --git a/content/docs/learn/playground/command-palette.mdx b/content/docs/learn/playground/command-palette.mdx new file mode 100644 index 0000000..4bf4e1c --- /dev/null +++ b/content/docs/learn/playground/command-palette.mdx @@ -0,0 +1,107 @@ +--- +title: "Command palette — search the menus" +description: "Alt+/ (Option+/ on Mac) opens a fuzzy-find search box over every active menu command; typing filters live, and picking a result runs it through the exact same seam the menu bar itself uses." +--- + +{/* GENERATED — do not edit by hand */} + +## What it does + +Press **Alt+/** (**Option+/** on Mac) anywhere in the grid to open a search +box over every active menu command — Google Sheets' own "Search the menus" +chord. Type to filter live (a plain, case-insensitive substring match on the +command's label or its menu breadcrumb, e.g. "Format > Text"); the top match +is pre-highlighted, so **Enter** runs it immediately. Arrow keys move the +highlight, clicking a row runs that one instead, and **Escape** closes the +box and returns focus to the grid. + +A not-yet-built or account-gated command still appears in results — Studio's +established visible-roadmap convention, matching how the menu bar itself +never hides a command, only greys it — but picking one does nothing (same +`aria-disabled` pattern the menu bar's own rows use). + +## Try it + + + +## Verified behavior + +_Matches Google Sheets:_ Google Sheets binds Option+/ (Alt+/) to "Search the menus": an inline box docked at the toolbar's top-left, with live substring filtering (typing "bold" matches "Bold" instantly), a pre-highlighted top result that Enter runs immediately (verified live: running "Bold" through the box flipped the toolbar's own Bold button to active for the selected cell), and Escape to close. Its real match surface is broader than menus alone (it also finds spreadsheet FUNCTION names whose text contains the query, e.g. "insert row" surfaces the GROWTH function) and shows a personalized empty-query suggestion plus a pinned "Find in document" action — see this feature's oracle brief for the full observed behavior and the specific, documented divergences Studio's implementation makes from it (centered overlay instead of an inline toolbar-docked box; menu commands only, not spreadsheet functions; no personalized suggestions or pinned find action, since Studio has neither a usage-history store nor a built find-in-document feature yet). + +#### Alt+/ opens the palette, focused and empty + +**Given** + +**When** I click **A1**, then press `Alt+/` + +**Then** +- `[data-testid="command-palette"]` is visible + +#### Typing filters live to matching commands, showing their menu breadcrumb + +**Given** + +**When** I click **A1**, then press `Alt+/`, then type `bold` + +**Then** +- `[data-testid="command-palette-item-format.bold"]` is visible containing "Format > Text" + +#### Enter on the pre-highlighted top result runs the SAME command the toolbar/menu use, and closes the palette + +**Given** +- **A1** = `hello` + +**When** I click **A1**, then press `Alt+/`, then type `bold`, then press `Enter` + +**Then** +- **A1** is bold +- `[data-testid="command-palette"]` is hidden + +#### Clicking a result runs it too, not just Enter + +**Given** +- **A1** = `hello` + +**When** I click **A1**, then press `Alt+/`, then type `italic`, then click **command-palette-item-format.italic** + +**Then** +- **A1** is italic +- `[data-testid="command-palette"]` is hidden + +#### A disabled item (Insert > Chart, locked in try mode) still appears — visible roadmap — but Enter on it does nothing + +**Given** + +**When** I click **A1**, then press `Alt+/`, then type `chart`, then press `Enter` + +**Then** +- `[data-testid="command-palette-item-insert.chart"]` is visible +- `[data-testid="command-palette"]` is visible + +#### No match shows a plain "no results" message, not a broken empty list + +**Given** + +**When** I click **A1**, then press `Alt+/`, then type `zzzxyq123nomatch` + +**Then** +- `[data-testid="command-palette"]` is visible containing "No matching commands" + +#### Escape closes the palette and returns focus to the grid + +**Given** + +**When** I click **A1**, then press `Alt+/`, then see `[data-testid="command-palette"]` on screen, then press `Escape`, then press `b`, then press `Enter` + +**Then** +- **A1** shows `b` +- `[data-testid="command-palette"]` is hidden + +#### File's actions are out of scope for this first pass — searching "import" finds nothing + +**Given** + +**When** I click **A1**, then press `Alt+/`, then type `import` + +**Then** +- `[data-testid="command-palette"]` is visible containing "No matching commands" diff --git a/content/docs/learn/playground/conditional-formatting.mdx b/content/docs/learn/playground/conditional-formatting.mdx new file mode 100644 index 0000000..97a7f2e --- /dev/null +++ b/content/docs/learn/playground/conditional-formatting.mdx @@ -0,0 +1,148 @@ +--- +title: "Conditional formatting (daily-driver slice)" +description: "A single-condition value-comparison rule or a 2-stop color scale, applied over a range from the Format ▸ Conditional formatting dialog, colors matching cells live (tracking edits) without touching their value/formula; a rule undoes/redoes like any edit. The dialog ITSELF previews every field edit on the grid immediately, before Done — Done only finalizes it as one undoable command, and Cancel discards the live preview entirely with no trace." +--- + +{/* GENERATED — do not edit by hand */} + +## What it does + +Select a range and open **Format ▸ Conditional formatting** to color-code cells by their own value, the way Google Sheets does — this daily-driver slice covers the two most common shapes: a **single-condition rule** ("format cells if greater than / less than / equal to / between a value, in a chosen fill color") and a **color scale** (a 2-stop gradient from a min color to a max color across the range's own numbers). Pick a condition and a value (and a second value for "Is between"/"Is not between"), or switch to the Color scale tab — **every change colors the range on the sheet immediately**, exactly like real Google Sheets. **Done** doesn't "apply" anything; it just closes the panel, keeping whatever you last saw on the grid. **Cancel** (or Escape, an outside click, or the panel's own close button) discards the live preview entirely — the grid returns to its exact pre-panel state, with no rule left behind. + +A rule is **live**: it re-evaluates every time the sheet recalculates, so a cell that crosses the threshold as a dependent formula changes recolors automatically — nothing is baked in at the moment you click Done. It's **presentation only**: coloring `=A1+B1` tints the cell without touching the formula or its computed value. However many fields you tune before clicking Done, the rule still **undoes/redoes as ONE edit** — Ctrl+Z after Done always fully removes it in a single step, never leaving you partway through your own tuning session. + +## Verified behavior + +_Matches Google Sheets:_ Google Sheets' Format ▸ Conditional formatting opens a "Conditional format rules" panel with two tabs. The "Single color" tab offers a "Format cells if…" condition dropdown (Is empty/not empty, five text conditions, three date conditions, six numeric-comparison operators — Greater than/Greater than or equal to/Less than/Less than or equal to/Is equal to/Is not equal to, plus Is between/Is not between, and "Custom formula is") and a formatting-style picker (bold/italic/underline/strikethrough/text color/ fill color; default a light green ~#b7e1cd). The "Color scale" tab offers a min/mid/maxpoint picker (default a 2-stop White→Green preset, midpoint "None") with a live gradient preview. Multiple rules can stack over the same or overlapping ranges via "+Add another rule". A rule is grid-owned presentation, re-evaluated against each cell's live value on every recalc (never baked into a static per-cell format), and undoes/redoes like any other edit. This daily-driver slice ships all eight numeric operators (Greater than/Greater than or equal to/Less than/Less than or equal to/Is equal to/Is not equal to/Is between/Is not between), restricts their values to numbers (no text/date/formula conditions), narrows the color scale to 2-stop (matching Sheets' own default, no midpoint), and presents it in a right-docked, non-modal panel matching Sheets' own docked side panel (a fixed, non-resizable width is the one residual chrome gap) — see the oracle's "Accepted divergences" for the full list. Full parity (text/date conditions, custom formula, 3-stop scale, alternating colors, themes) is tracked separately. The panel's OWN fields preview live on the grid before Done — real Google Sheets applies conditional- format highlighting live as the panel's own condition/style/color-scale fields are adjusted; Done just closes the panel, it is not an apply gate, and Cancel discards whatever was previewed with no trace left behind. + +#### Opening Format > Conditional formatting shows the Single color tab by default + +**Given** + +**When** I click menu item **format.conditionalFormatting** + +**Then** +- `[data-testid="conditional-format-dialog"]` is visible +- `[data-testid="conditional-format-operator"]` is visible + +#### A single-condition rule (value > 10) colors a matching cell's fill, leaving its value untouched + +**Given** +- **A1** = `15` + +**When** I click **A1**, then click menu item **format.conditionalFormatting**, then fill **conditional-format-value** with `10`, then click **conditional-format-done** + +**Then** +- **A1** shows `15` +- **A1** renders as fill=#b7e1cd + +#### A single-condition rule does NOT color a cell whose value fails the condition + +**Given** +- **A1** = `5` + +**When** I click **A1**, then click menu item **format.conditionalFormatting**, then fill **conditional-format-value** with `10`, then click **conditional-format-done** + +**Then** +- **A1** renders as fill=false + +#### Conditional formatting is presentation only — a formula's value/formula are untouched + +**Given** +- **A1** = `=5+10` + +**When** I click **A1**, then click menu item **format.conditionalFormatting**, then fill **conditional-format-value** with `10`, then click **conditional-format-done** + +**Then** +- **A1** shows `15` +- **A1** has formula `=5+10` +- **A1** renders as fill=#b7e1cd + +#### Undo removes the rule's effect; redo reapplies it + +**Given** +- **A1** = `15` + +**When** I click **A1**, then click menu item **format.conditionalFormatting**, then fill **conditional-format-value** with `10`, then click **conditional-format-done**, then click **A1**, then undo + +**Then** +- **A1** renders as fill=false + +#### Adjusting the value previews the fill LIVE on the grid, BEFORE Done is clicked + +**Given** +- **A1** = `15` + +**When** I click **A1**, then click menu item **format.conditionalFormatting**, then fill **conditional-format-value** with `10` + +**Then** +- **A1** renders as fill=#b7e1cd + +#### Switching to the Color scale tab previews IT live too, before Done + +**Given** +- **A1** = `0` +- **A2** = `10` + +**When** I select **A1:A2**, then click menu item **format.conditionalFormatting**, then click **conditional-format-tab-color-scale** + +**Then** +- **A1** renders as fill=#ffffff +- **A2** renders as fill=#57bb8a + +#### Cancel discards the live-previewed rule entirely — the grid returns to its pre-panel state + +**Given** +- **A1** = `15` + +**When** I click **A1**, then click menu item **format.conditionalFormatting**, then fill **conditional-format-value** with `10`, then click **conditional-format-cancel** + +**Then** +- **A1** renders as fill=false + +#### Done after several live-adjusted values still commits cleanly — undo fully reverts in ONE step, not partway + +**Given** +- **A1** = `15` + +**When** I click **A1**, then click menu item **format.conditionalFormatting**, then fill **conditional-format-value** with `5`, then fill **conditional-format-value** with `20`, then fill **conditional-format-value** with `10`, then click **conditional-format-done**, then click **A1**, then undo + +**Then** +- **A1** renders as fill=false + +#### A color-scale rule paints the range's own min/max values at its two endpoint colors + +**Given** +- **A1** = `0` +- **A2** = `5` +- **A3** = `10` + +**When** I select **A1:A3**, then click menu item **format.conditionalFormatting**, then click **conditional-format-tab-color-scale**, then click **conditional-format-done** + +**Then** +- **A1** renders as fill=#ffffff +- **A3** renders as fill=#57bb8a + +#### A color-scale rule never affects a cell OUTSIDE its range + +**Given** +- **A1** = `0` +- **A2** = `10` +- **B1** = `999` + +**When** I select **A1:A2**, then click menu item **format.conditionalFormatting**, then click **conditional-format-tab-color-scale**, then click **conditional-format-done** + +**Then** +- **B1** renders as fill=false + +#### Is between evaluates inclusive, order-independent bounds via the operator dropdown + +**Given** +- **A1** = `5` +- **A2** = `15` + +**When** I select **A1:A2**, then click menu item **format.conditionalFormatting**, then choose `between` in **conditional-format-operator**, then fill **conditional-format-value** with `10`, then fill **conditional-format-value2** with `1`, then click **conditional-format-done** + +**Then** +- **A1** renders as fill=#b7e1cd +- **A2** renders as fill=false diff --git a/content/docs/learn/playground/date-parsing.mdx b/content/docs/learn/playground/date-parsing.mdx index 871d33c..f1bd74a 100644 --- a/content/docs/learn/playground/date-parsing.mdx +++ b/content/docs/learn/playground/date-parsing.mdx @@ -24,7 +24,7 @@ text too. ## Verified behavior -_Matches Google Sheets:_ In Google Sheets, typing a recognized date literal creates a REAL date, not text: the cell stores a date serial (days since the 1899-12-30 epoch; 2026-07-15 = 46218) and Sheets AUTO-APPLIES a date number format on recognition, so it displays as a date (M/D/YYYY in this US-locale workbook) and is right-aligned. Because the value is a serial, `=+1` gives the next day. Editing the cell (or reading the formula bar) shows the DATE (7/15/2026), never the raw serial — and because that date string is an accepted input, committing it unchanged re-parses to the same serial (round-trip). Only dates are shown formatted while editing; other number formats show the raw number (you edit 1234.5, not $1,234.50). The accepted formats (US M/D/Y locale) include M/D/YYYY, M/D/YY, YYYY-MM-DD, YYYY/MM/DD, Mon D[,] YYYY, Month D[,] YYYY, and D-Mon-YYYY — all mapping 2026-07-15 to serial 46218. Validity is enforced (2/29/2024 is a date; 2/29/2026 and 13/13/2026 are not, and stay text), and the month comes first (15/7/2026 is not a date in this locale). A lone month name (Jan), ordinary text, and a bare number (46218 is already numeric) are NOT auto-converted. This is the input-parsing companion to, which rendered a serial as a date but did nothing to typed text. Separately, a Date-TYPED engine value — =TODAY, =DATE(y,m,d), or date arithmetic that stays a date like =DATE(...)+1 — displays as a date automatically, with no explicit numberFormat, because the engine owns the date-ness (Sheets: arithmetic on a date stays a date). (A known gap: `=+1` currently shows the serial, because Studio stores a typed date as a plain-number serial rather than a Date-typed value — the engine has no date-typed literal input yet; tracked as a core follow-up.) +_Matches Google Sheets:_ In Google Sheets, typing a recognized date literal creates a REAL date, not text: the cell stores a date serial (days since the 1899-12-30 epoch; 2026-07-15 = 46218) and Sheets AUTO-APPLIES a date number format on recognition, so it displays as a date (M/D/YYYY in this US-locale workbook) and is right-aligned. Because the value is a serial, `=+1` gives the next day. Editing the cell (or reading the formula bar) shows the DATE (7/15/2026), never the raw serial — and because that date string is an accepted input, committing it unchanged re-parses to the same serial (round-trip). Only dates are shown formatted while editing; other number formats show the raw number (you edit 1234.5, not $1,234.50). The accepted formats (US M/D/Y locale) include M/D/YYYY, M/D/YY, YYYY-MM-DD, YYYY/MM/DD, Mon D[,] YYYY, Month D[,] YYYY, and D-Mon-YYYY — all mapping 2026-07-15 to serial 46218. Validity is enforced (2/29/2024 is a date; 2/29/2026 and 13/13/2026 are not, and stay text), and the month comes first (15/7/2026 is not a date in this locale). A lone month name (Jan), ordinary text, and a bare number (46218 is already numeric) are NOT auto-converted. This is the input-parsing companion to date display, which rendered a serial as a date but did nothing to typed text. Separately, a Date-TYPED engine value — =TODAY(), =DATE(y,m,d), or date arithmetic that stays a date like =DATE(...)+1 — displays as a date automatically, with no explicit numberFormat, because the engine owns the date-ness (Sheets: arithmetic on a date stays a date). (A known gap: `=+1` currently shows the serial, because Studio stores a typed date as a plain-number serial rather than a Date-typed value — the engine has no date-typed literal input yet; tracked as a core follow-up.) #### Typing M/D/YYYY stores a serial and auto-applies a date format @@ -106,7 +106,7 @@ _Matches Google Sheets:_ In Google Sheets, typing a recognized date literal crea - **A1** shows `46218` - **A1** is numberFormat=date -#### A Date-typed formula result (=DATE+1) stays a date the ValueFormatter shows automatically +#### A Date-typed formula result (=DATE()+1) stays a date the ValueFormatter shows automatically **Given** diff --git a/content/docs/learn/playground/date-picker.mdx b/content/docs/learn/playground/date-picker.mdx index e779c63..32feb1e 100644 --- a/content/docs/learn/playground/date-picker.mdx +++ b/content/docs/learn/playground/date-picker.mdx @@ -25,7 +25,7 @@ opens the normal in-cell editor, never the picker. ## Verified behavior -_Matches Google Sheets:_ In Google Sheets, double-clicking a DATE-formatted cell opens a small CALENDAR popover anchored at the cell, showing the month of the cell's current date with that day selected (an empty date cell opens on the current month with nothing selected). Picking a day writes that date into the cell: because the cell is a real date, the stored value is the date SERIAL (days since the 1899-12-30 epoch; 2026-07-15 = 46218) and the cell keeps its date number format, so it still displays as M/D/YYYY (US locale) and `=+1` still gives the next day — real calc, not text. The popover has month and year navigation (a header showing the displayed Month Year) and is keyboard-accessible: arrow keys move the highlighted day (left/right by a day, up/down by a week, crossing month boundaries), Enter picks the highlighted day, and Escape closes WITHOUT committing (the cell keeps its prior value). A NON-date cell double-click opens the normal in-cell editor instead — the picker is specific to date cells, and the picker and the inline editor are never open at the same time. The value ground truth is anchored to the same engine DATEVALUE authority as (7/15/2026 = 46218, 7/16 = 46219, 7/20 = 46223, 7/22 = 46225): a picked day is written as a US M/D/YYYY literal, which re-parses to the serial through the existing date-literal coercion. This is the visual, faster companion to (typing a date) and (rendering a date). +_Matches Google Sheets:_ In Google Sheets, double-clicking a DATE-formatted cell opens a small CALENDAR popover anchored at the cell, showing the month of the cell's current date with that day selected (an empty date cell opens on the current month with nothing selected). Picking a day writes that date into the cell: because the cell is a real date, the stored value is the date SERIAL (days since the 1899-12-30 epoch; 2026-07-15 = 46218) and the cell keeps its date number format, so it still displays as M/D/YYYY (US locale) and `=+1` still gives the next day — real calc, not text. The popover has month and year navigation (a header showing the displayed Month Year) and is keyboard-accessible: arrow keys move the highlighted day (left/right by a day, up/down by a week, crossing month boundaries), Enter picks the highlighted day, and Escape closes WITHOUT committing (the cell keeps its prior value). A NON-date cell double-click opens the normal in-cell editor instead — the picker is specific to date cells, and the picker and the inline editor are never open at the same time. The value ground truth is anchored to the same engine DATEVALUE authority as typing a date (7/15/2026 = 46218, 7/16 = 46219, 7/20 = 46223, 7/22 = 46225): a picked day is written as a US M/D/YYYY literal, which re-parses to the serial through the existing date-literal coercion. This is the visual, faster companion to typing a date and to rendering one. #### Double-clicking a date cell opens the calendar popover @@ -101,7 +101,7 @@ _Matches Google Sheets:_ In Google Sheets, double-clicking a DATE-formatted cell - **A1** shows `46218` - `[data-slot="date-picker"]` is hidden -#### A non-date cell double-click does not open the picker +#### A non-date cell double-click opens the plain inline editor, not the picker **Given** - **A1** = `hello` @@ -109,4 +109,5 @@ _Matches Google Sheets:_ In Google Sheets, double-clicking a DATE-formatted cell **When** I double-click **A1** **Then** +- `[data-testid="cell-editor"]` is visible with value "hello" - `[data-slot="date-picker"]` is hidden diff --git a/content/docs/learn/playground/display-formatting.mdx b/content/docs/learn/playground/display-formatting.mdx index db983e0..c4c7078 100644 --- a/content/docs/learn/playground/display-formatting.mdx +++ b/content/docs/learn/playground/display-formatting.mdx @@ -14,8 +14,8 @@ for a date + time (`46218.5` → `7/15/2026 12:00:00`) — or use the toolbar's **$ · % · decrease/increase-decimals · 123 · date · date-time** group. Re-applying an A value that carries a time-of-day reads as a date + time **automatically**, no format -needed: `=NOW` comes back from the engine as a Date-typed fractional serial and shows -as `7/15/2026 0:39:39` (Google Sheets' default), while a whole-day date like `=TODAY` +needed: `=NOW()` comes back from the engine as a Date-typed fractional serial and shows +as `7/15/2026 0:39:39` (Google Sheets' default), while a whole-day date like `=TODAY()` shows as just `7/15/2026`. active category (or clicking its pressed button) reverts to automatic. @@ -26,7 +26,7 @@ untouched — only the display changes. Errors always show their code clearly (` ## Verified behavior -_Matches Google Sheets:_ A cell's number format is grid-owned PRESENTATION: it changes only the displayed string, never the stored value or formula (the formula bar still shows the raw value, and the format undoes/redoes like any edit). Applying currency to 1234.5 shows $1,234.50; to -42 shows -$42.00 (Google Sheets puts the minus before the $, not accounting parentheses). Percent scales by 100 and suffixes % with two decimals and no thousands grouping, so 0.1257 shows 12.57%. The Number format is thousands-grouped with two decimals (1,234.50). A date format renders a serial as M/D/YYYY (serial 45852 → 7/14/2025; Sheets' serial epoch is 1899-12-30). A datetime format renders a serial as M/D/YYYY H:MM:SS (the fraction is the time of day: 46218.5 → 7/15/2026 12:00:00; the whole-day 46218 → 7/15/2026 0:00:00). Google Sheets also DEFAULTS a value that carries a time-of-day to a datetime display: =NOW returns a Date-typed fractional serial and reads as date + time out of the box, while a whole-day date (=TODAY) reads as just the date. Increase/ decrease-decimals add or remove one decimal place from the current number format. Re-applying an already-active category clears it back to automatic. In Google Sheets the shortcuts are Number Ctrl+Shift+1, Date Ctrl+Shift+3, Currency Ctrl+Shift+4, Percent Ctrl+Shift+5, Time Ctrl+Shift+2 (Studio's datetime format takes that slot); decrease/increase-decimals are toolbar-only. A formula error shows its code clearly (#DIV/0!, #REF!, #VALUE!, …), regardless of any number format. +_Matches Google Sheets:_ A cell's number format is grid-owned PRESENTATION: it changes only the displayed string, never the stored value or formula (the formula bar still shows the raw value, and the format undoes/redoes like any edit). Applying currency to 1234.5 shows $1,234.50; to -42 shows -$42.00 (Google Sheets puts the minus before the $, not accounting parentheses). Percent scales by 100 and suffixes % with two decimals and no thousands grouping, so 0.1257 shows 12.57%. The Number format is thousands-grouped with two decimals (1,234.50). A date format renders a serial as M/D/YYYY (serial 45852 → 7/14/2025; Sheets' serial epoch is 1899-12-30). A datetime format renders a serial as M/D/YYYY H:MM:SS (the fraction is the time of day: 46218.5 → 7/15/2026 12:00:00; the whole-day 46218 → 7/15/2026 0:00:00). Google Sheets also DEFAULTS a value that carries a time-of-day to a datetime display: =NOW() returns a Date-typed fractional serial and reads as date + time out of the box, while a whole-day date (=TODAY()) reads as just the date. Increase/ decrease-decimals add or remove one decimal place from the current number format. Re-applying an already-active category clears it back to automatic. In Google Sheets the shortcuts are Number Ctrl+Shift+1, Date Ctrl+Shift+3, Currency Ctrl+Shift+4, Percent Ctrl+Shift+5, Time Ctrl+Shift+2 (Studio's datetime format takes that slot); decrease/increase-decimals are toolbar-only. A formula error shows its code clearly (#DIV/0!, #REF!, #VALUE!, …), regardless of any number format. #### Currency format is applied without changing the value @@ -83,17 +83,17 @@ _Matches Google Sheets:_ A cell's number format is grid-owned PRESENTATION: it c - **A1** shows `46218.5` - **A1** is numberFormat=datetime -#### NOW is a Date-typed serial anchored to today (its time-of-day fraction reads as a datetime) +#### NOW() is a Date-typed serial anchored to today (its time-of-day fraction reads as a datetime) **Given** -- **A1** = `=NOW` -- **B1** = `=INT(A1)=TODAY` +- **A1** = `=NOW()` +- **B1** = `=INT(A1)=TODAY()` **When** I click **A1** **Then** - **B1** shows `true` -- **A1** has formula `=NOW` +- **A1** has formula `=NOW()` #### Re-applying the same category clears it back to automatic diff --git a/content/docs/learn/playground/error-tooltip.mdx b/content/docs/learn/playground/error-tooltip.mdx index fc16186..02f9d37 100644 --- a/content/docs/learn/playground/error-tooltip.mdx +++ b/content/docs/learn/playground/error-tooltip.mdx @@ -15,7 +15,7 @@ When a cell resolves to an error — `#REF!`, `#DIV/0!`, `#NAME?`, `#VALUE!`, `# ## 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`). +_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 — tracked upstream as truecalc/core#731 — 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. #### A diff --git a/content/docs/learn/playground/explain-result.mdx b/content/docs/learn/playground/explain-result.mdx index 79592c2..4480243 100644 --- a/content/docs/learn/playground/explain-result.mdx +++ b/content/docs/learn/playground/explain-result.mdx @@ -15,13 +15,13 @@ The stage is an **animated node graph**: the formula's leaf inputs sit on the bo **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. +**Explain is LIVE — any formula, not just two demos.** Right-clicking ANY formula cell calls the real engine trace producer (`@truecalc/pro`'s `trace_cell_at`, a client WASM package) 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. +**Formula-token highlighting is wired.** Every `TraceNode` carries a `span` — a byte range into the cell's own formula text, read straight off the engine's own generated wire type (`@truecalc/pro/bindings/TraceNode` — no hand-maintained type copy exists). 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). +**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 — as insurance against any future code path that could otherwise swap cells on a live-mounted instance). ## Try it @@ -31,7 +31,7 @@ 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. +_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 oracle brief backs this manifest, per this repo'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 engine 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 diff --git a/content/docs/learn/playground/file-open.mdx b/content/docs/learn/playground/file-open.mdx new file mode 100644 index 0000000..649ea36 --- /dev/null +++ b/content/docs/learn/playground/file-open.mdx @@ -0,0 +1,41 @@ +--- +title: "File > Open — local document switcher" +description: "File > Open lists every document in this browser's local registry by name, most-recently-updated first, and lets you switch to a different one — reusing the exact `?doc=` new-tab mechanism File > Make a copy already opens its copies with." +--- + +{/* GENERATED — do not edit by hand */} + +## What it does + +**File > Open** lists every document stored in this browser's local registry — by name, most-recently-updated first — so a document that only ever existed as a one-time "Make a copy" new-tab link can be found and switched to again. The document you're currently on is shown too, marked **Current** instead of an Open button — opening the tab you're already in onto itself would have no useful effect. + +Picking a different document's **Open** button switches to it in a new, focused browser tab — reusing the exact same `?doc=` `window.open` mechanism File > Make a copy already opens its copies with (see [make-a-copy](./make-a-copy)), not a second "switch document" mechanism. The tab you clicked from is left open and unchanged, same as Make a copy's own new-tab behavior. + +This is local (try-mode) storage only — no account, server, or Drive-equivalent is involved. There is no Sheets-style Recent/Shared/Starred picker chrome to browse, because none of what it browses (a Drive account's files) has a Studio equivalent yet; this is a flat list of what's actually in this ONE browser. + +## Verified behavior + +_Matches Google Sheets:_ Google Sheets' closest analog is File > Open, which opens a Drive file picker (Recent/Shared with me/Starred, search, thumbnails) scoped to the signed-in account's Drive. Studio has no account, Drive, or sharing concept in try mode at all — the same scope boundary the Make a copy oracle brief's own "Accepted divergences" already established for Make a copy — so there is no Drive picker chrome to replicate; this feature's own oracle brief documents that a live re-verification of Sheets' picker itself was attempted this session and could not be completed (no authenticated Google session was reachable), and flags a follow-up re-check rather than asserting an unverified finding. What IS carried over, and WAS already live-verified (the Make a copy oracle brief, point 3, the same reference workbook): confirming a document-opening action in Sheets opens it in a new, focused browser tab, leaving the tab you acted from unchanged in the background — the exact interaction shape this feature's `window.open` `?doc=` mechanism reuses (the same one File > Make a copy already uses), rather than a same-tab redirect that could silently discard whatever the user was doing. + +#### File > Open is available and lists the current document by name + +**Given** +- **A1** = `1` + +**When** I click menu item **file.open** + +**Then** +- **A1** shows `1` +- `[data-menu-item="file.open"]` is visible +- `[data-testid="open-document-dialog"]` is visible containing "Untitled workbook" + +#### Closing the Open dialog leaves the current document untouched + +**Given** +- **A1** = `1` + +**When** I click menu item **file.open**, then click **open-document-cancel** + +**Then** +- **A1** shows `1` +- `[data-testid="open-document-dialog"]` is hidden diff --git a/content/docs/learn/playground/format-rotation.mdx b/content/docs/learn/playground/format-rotation.mdx index b5824da..6f1f48e 100644 --- a/content/docs/learn/playground/format-rotation.mdx +++ b/content/docs/learn/playground/format-rotation.mdx @@ -32,7 +32,7 @@ formatting**. ## 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. +_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 — see this feature's oracle brief — 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) diff --git a/content/docs/learn/playground/formula-range-preview.mdx b/content/docs/learn/playground/formula-range-preview.mdx new file mode 100644 index 0000000..bb5bf6c --- /dev/null +++ b/content/docs/learn/playground/formula-range-preview.mdx @@ -0,0 +1,194 @@ +--- +title: "Formula range-select live-preview bubble" +description: "While a formula is open and a multi-cell range is selected as its argument — drag-selected with the mouse, OR shift-clicked/shift-arrowed with the keyboard (e.g. mid-typing `=SUM(` and pressing Shift+Down repeatedly to grow D3..D6) — a small floating, read-only chip near the selection shows the live-computed aggregate MATCHING THE ENCLOSING FUNCTION (SUM/PRODUCT/AVERAGE/COUNT/COUNTA/MIN/MAX) of the numeric cells being selected, labeled with which operation it is; no chip for a function outside that list. Never affects the formula text itself, and clears itself on commit or cancel." +--- + +{/* GENERATED — do not edit by hand */} + +## What it does + +Start a formula — type `=` and a function name, e.g. `=SUM(` or `=PRODUCT(` +— and instead of referencing one cell, select a **range** to supply it as +the argument, either by **dragging** across it with the mouse or by +**pressing Shift+Arrow** repeatedly with the keyboard (e.g. Shift+Down to +grow the reference one row at a time). A small floating chip appears near +the selection showing the **live-computed aggregate MATCHING THE ENCLOSING +FUNCTION** of the numeric cells being selected — `Sum 100` inside `=SUM(`, +`Product 600` inside `=PRODUCT(` for the same range, and likewise `Average`/ +`Count`/`Min`/`Max` inside `=AVERAGE(`/`=COUNT(` or `=COUNTA(`/`=MIN(`/ +`=MAX(` — updating live as the range grows or shrinks, and labeled with +which operation it is so a `600` (Product) is never mistaken for a `50` +(Sum) of the same cells. A function outside that supported list (e.g. +`=VLOOKUP(`, `=IF(`, `=CONCATENATE(`) shows **no chip at all** — there is no +single obviously-correct live aggregate for those, and guessing one (always +falling back to a SUM, as this feature originally — and incorrectly — did) +would just be a differently-shaped version of showing a misleading number. +Release the mouse (or stop pressing Shift+Arrow) and the chip stays up so +you can actually read it — it's purely read-only, with no dismiss control of +its own. A plain +**single-cell click**, or a plain (non-shifted) **arrow key**, while typing +a formula still just inserts/steers that one cell's reference, exactly as +before — nothing to aggregate over one cell, so no chip appears. Releasing +Shift and pressing a plain arrow while a keyboard range is growing collapses +it back to a single cell, one step further from the range's last edge — the +same shift-then-plain-arrow convention the grid's own (non-formula) range +selection already uses. + +The chip is **presentation only**: whether it's showing or what it says +never changes the reference actually written into the formula — that's +still the plain `D3:D6` token the drag inserts. Committing the formula (or +cancelling it) clears the chip along with the rest of the editing state — +so does the range collapsing back to a single cell. + +## Try it + + + +## Verified behavior + +_Matches Google Sheets:_ In Google Sheets, while a formula is being typed and the caret is at a reference-valid position, selecting a multi-cell range as the argument — by dragging with the mouse, OR by shift-clicking/shift-arrowing with the keyboard — shows a small floating chip near the selection with a live-computed aggregate of the numeric cells being selected MATCHING WHICHEVER FUNCTION THE RANGE IS AN ARGUMENT TO (e.g. "100" inside `=SUM(`, "600" inside `=PRODUCT(` for the same two cells that would sum to 50), dismissible. A plain single-cell click (or a plain, non-shifted arrow key) while typing a formula inserts a reference the same way it always has and shows no chip — there's nothing to aggregate over one cell. The chip is presentation only: it never changes what reference gets inserted into the formula (still the plain `D3:D6` token) and disappears once the formula commits or is cancelled. NOTE (see this feature's oracle brief): this manifest's ground truth is UNVERIFIED against the live Sheets oracle this slice — the oracle study connected to a live Chrome/CDP session but could not reliably drive keyboard-driven cell data entry in that environment (see the oracle brief for the full connection-status writeup); the claims above reflect well-established, uncontroversial prior knowledge of this Sheets behavior, not a pixel-for-pixel re-verification. The owner must confirm against the live oracle before treating this as parity-complete. The keyboard (shift-arrow) half specifically was NEVER live-verified even in this weaker "well-established prior knowledge" sense during the original slice — it was silently unimplemented rather than deliberately deferred (see the oracle's "Accepted divergences" section) — so its exact cell-by-cell growth semantics (which cell the range starts from on the first Shift+Arrow, what a plain arrow does after Shift is released) are this codebase's own reasoned design, mirroring the grid's existing non-formula Shift+Arrow convention (`operations/select`), not a re-derived Sheets pixel-check. The FUNCTION-AWARE aggregate is also this codebase's own reasoned design, not a re-derived Sheets pixel-check: the owner reported the chip always showing a SUM even inside `=PRODUCT(` as a bug against this manifest's OWN stated ground truth ("live-computed aggregate" — never scoped to "always SUM" in the first place), not a new behavior request. MANUAL DISMISS is a deliberate, accepted divergence: Sheets' chip is dismissible via its own close affordance, but this codebase's first implementation of that affordance (an in-pill "×") turned out to be practically unreachable during an active mouse drag — the only path to it crossed grid cells while the mouse button was still held down, which just kept extending the range instead of ever landing a click on the button. The owner's call was to remove the control rather than chase a fix; the chip now relies solely on its non-dismiss lifecycle (commit, cancel, or the range collapsing to a single cell) to disappear. + +#### Dragging a numeric range as a formula argument shows the live-preview chip with the SUM, labeled "Sum" + +**Given** +- **D3** = `10` +- **D4** = `20` +- **D5** = `30` +- **D6** = `40` + +**When** I click **F1**, then press `=`, then press `S`, then press `U`, then press `M`, then press `(`, then drag from **D3** to **D6** + +**Then** +- `[data-slot="range-preview-chip"]` is visible containing "Sum 100" + +#### Committing the formula inserts the plain range reference and the chip disappears + +**Given** +- **D3** = `10` +- **D4** = `20` +- **D5** = `30` +- **D6** = `40` + +**When** I click **F1**, then press `=`, then press `S`, then press `U`, then press `M`, then press `(`, then drag from **D3** to **D6**, then press `)`, then press `Enter` + +**Then** +- **F1** shows `100` +- **F1** has formula `=SUM(D3:D6)` +- `[data-slot="range-preview-chip"]` is hidden + +#### A plain single-cell click while typing a formula shows no chip (unchanged point-mode/click-insert behavior) + +**Given** +- **D3** = `10` + +**When** I click **F1**, then press `=`, then press `S`, then press `U`, then press `M`, then press `(`, then click **D3** + +**Then** +- `[data-testid="formula-input"]` is visible with value "=SUM(D3" +- `[data-slot="range-preview-chip"]` is hidden + +#### Dragging UP/LEFT from the anchor cell tracks the chip to the pointer, not a fixed range corner + +**Given** +- **D3** = `10` +- **D4** = `20` +- **D5** = `30` +- **D6** = `40` + +**When** I click **F1**, then press `=`, then press `S`, then press `U`, then press `M`, then press `(`, then drag from **D6** to **D3** + +**Then** +- `[data-slot="range-preview-chip"]` is visible containing "100" near the top of the screen (≤260px) + +#### An all-text multi-cell range shows no chip — nothing numeric to aggregate + +**Given** +- **B3** = `a` +- **B4** = `b` + +**When** I click **F1**, then press `=`, then press `S`, then press `U`, then press `M`, then press `(`, then drag from **B3** to **B4** + +**Then** +- `[data-testid="formula-input"]` is visible with value "=SUM(B3:B4" +- `[data-slot="range-preview-chip"]` is hidden + +#### Dragging a range inside =PRODUCT( shows the PRODUCT labeled "Product", not the SUM + +**Given** +- **B1** = `20` +- **C1** = `30` + +**When** I click **F1**, then press `=`, then press `P`, then press `R`, then press `O`, then press `D`, then press `U`, then press `C`, then press `T`, then press `(`, then drag from **B1** to **C1** + +**Then** +- `[data-slot="range-preview-chip"]` is visible containing "Product 600" + +#### Committing a =PRODUCT( formula from the drag inserts the plain range and computes the real PRODUCT (not the chip's-former hardcoded SUM) + +**Given** +- **B1** = `20` +- **C1** = `30` + +**When** I click **F1**, then press `=`, then press `P`, then press `R`, then press `O`, then press `D`, then press `U`, then press `C`, then press `T`, then press `(`, then drag from **B1** to **C1**, then press `)`, then press `Enter` + +**Then** +- **F1** shows `600` +- **F1** has formula `=PRODUCT(B1:C1)` +- `[data-slot="range-preview-chip"]` is hidden + +#### Dragging a range inside a function outside the live-preview's supported list (e.g. =CONCATENATE()) shows no chip rather than a guessed aggregate + +**Given** +- **D3** = `10` +- **D4** = `20` +- **D5** = `30` +- **D6** = `40` + +**When** I click **F1**, then press `=`, then press `C`, then press `O`, then press `N`, then press `C`, then press `A`, then press `T`, then press `E`, then press `N`, then press `A`, then press `T`, then press `E`, then press `(`, then drag from **D3** to **D6** + +**Then** +- `[data-testid="formula-input"]` is visible with value "=CONCATENATE(D3:D6" +- `[data-slot="range-preview-chip"]` is hidden + +#### Shift+Arrow (keyboard) also grows a range reference and shows the live-preview chip + +**Given** +- **D3** = `10` +- **D4** = `20` +- **D5** = `30` +- **D6** = `40` + +**When** I click **D2**, then press `=`, then press `S`, then press `U`, then press `M`, then press `(`, then press `ArrowDown`, then press `Shift+ArrowDown`, then press `Shift+ArrowDown`, then press `Shift+ArrowDown` + +**Then** +- `[data-slot="range-preview-chip"]` is visible containing "100" + +#### Committing a Shift+Arrow-built range reference inserts the plain range and the chip disappears + +**Given** +- **D3** = `10` +- **D4** = `20` +- **D5** = `30` +- **D6** = `40` + +**When** I click **D2**, then press `=`, then press `S`, then press `U`, then press `M`, then press `(`, then press `ArrowDown`, then press `Shift+ArrowDown`, then press `Shift+ArrowDown`, then press `Shift+ArrowDown`, then press `)`, then press `Enter` + +**Then** +- **D2** shows `100` +- **D2** has formula `=SUM(D3:D6)` +- `[data-slot="range-preview-chip"]` is hidden + +#### Releasing Shift and pressing a plain arrow collapses the keyboard range to a single cell + +**Given** +- **D3** = `10` +- **D4** = `20` +- **D5** = `30` +- **D6** = `40` + +**When** I click **D2**, then press `=`, then press `S`, then press `U`, then press `M`, then press `(`, then press `ArrowDown`, then press `Shift+ArrowDown`, then press `Shift+ArrowDown`, then press `ArrowDown`, then press `)`, then press `Enter` + +**Then** +- **D2** shows `40` +- **D2** has formula `=SUM(D6)` +- `[data-slot="range-preview-chip"]` is hidden diff --git a/content/docs/learn/playground/grid-context-menu.mdx b/content/docs/learn/playground/grid-context-menu.mdx new file mode 100644 index 0000000..30b6394 --- /dev/null +++ b/content/docs/learn/playground/grid-context-menu.mdx @@ -0,0 +1,401 @@ +--- +title: "Right-click grid context menu" +description: "Right-clicking a cell (or the current selection) in the grid prevents the browser's native context menu and opens a custom one instead — Cut, Copy, Paste, Insert row above/below, Insert column left/right, Delete row, Delete column, and Explain — every row routed through the SAME commands the top Edit/Insert menu already uses, so it's undo/redo compatible by construction. The insert labels show the REAL row/column count for a multi-row/column selection (e.g. \"Insert 3 rows above\"), matching Sheets' own grammar. Right-clicking a row-number/column-letter HEADER directly — or a cell that's part of a header-originated whole-row/whole-column selection — opens a second, richer, axis-only menu instead — only that axis's Insert above/below (or left/right), plus a real-range **Delete rows/columns X-Y** and a new **Clear rows/columns X-Y** (empties content without deleting the rows/columns), reusing the exact same commands (and, for Clear, the exact same `clear.selection` command `Delete`/`Backspace` already run) — no new capability needed." +--- + +{/* GENERATED — do not edit by hand */} + +## What it does + +Right-clicking a cell (or the current selection) anywhere in the grid prevents the browser's native context menu — no more "Save Image As…"/"Inspect" over what looks like a plain `` — and opens a custom, Sheets-shaped menu instead: **Cut**, **Copy**, **Paste**, a separator, **Insert row above**, **Insert row below**, **Insert column left**, **Insert column right**, a separator, **Delete row**, **Delete column**, a separator, and **Explain**. Every row above Explain runs through the exact same command the top **Edit**/**Insert** menu already calls for the identical action — `clipboard.cut`/`clipboard.copy`/`clipboard.paste` and `structure.insertRows`/`insertRowsBelow`/`insertColumns`/`insertColumnsRight`/`deleteRows`/`deleteColumns` — so undo/redo, the command-history panel, and analytics all work identically whether the action came from the top menu or the right-click menu, by construction (one command path, not two). + +The insert labels show the **real row/column count** for the current selection, matching Sheets' own grammar exactly: a single row/column keeps the singular, un-numbered "Insert row above"/"Insert column left" (Sheets never spells out "1"), while a 3-row selection shows "Insert 3 rows above"/"Insert 3 rows below" and a 2-column selection shows "Insert 2 columns left"/"Insert 2 columns right". + +**Right-clicking a row-number/column-letter header directly — or a cell inside a header-originated whole-row/whole-column selection — opens a second, richer menu instead **, matching Sheets: only that ONE axis's items, not both. A row-header menu shows **Insert row above**, **Insert row below**, a separator, **Delete row(s) N** (or **N-M** for a multi-row selection, the real range), and **Clear row(s) N** — no column items at all; a column-header menu mirrors it with column items only. **Clear** is new here — it empties the selected row/column band's content (every cell in it) without deleting the rows/columns themselves or shifting anything, unlike Delete. + +## Try it + + + +Right-click any cell to see the plain menu. Try **Cut**/**Copy** on one cell, then right-click another and **Paste**; try **Insert row above/below** or **Delete row** on a row inside the sample data; select a multi-row/column range first to see the label switch to the real count in both directions. Right-click a row NUMBER or column LETTER in the header strip to see the richer axis-only menu, including the new **Clear** item. + +## Verified behavior + +_Matches Google Sheets:_ In Google Sheets, right-clicking a single cell opens a menu whose first group is Cut/Copy/Paste/Paste special, followed by Insert (1 row above / 1 column left / cells…), then Delete (row / column / cells…) — live-verified; see this feature's oracle brief. For a MULTI-row/column selection, Sheets' own plain-cell menu spells out the real count and BOTH insert directions — "Insert 3 rows above" / "Insert 3 rows below", "Insert 2 columns left" / "Insert 2 columns right" — and a single row/column keeps the singular, un-numbered "Insert row above"/"Insert column left" (Sheets never spells out "1"). Studio's `structure.insertRows`/`insertColumns` commands already insert `count` rows/columns matching the selection's span for the "above"/"left" direction; `structure. insertRowsBelow`/`insertColumnsRight` add the missing "insert after the selection" pair, reusing the exact same `Store.insertRows`/`insertColumns` primitive with `at` one past the selection's last row/column instead of before its first. Sheets also right-click-selects the target the same way a left-click does — inside the current selection, the selection is left alone; outside it, the selection collapses to the clicked cell — which Studio's `CanvasGrid.onContextMenu` replicates. + +The header-triggered menu (live-verified 2026-07-22; see this feature's oracle brief): right-clicking a row-number or column-letter HEADER in Sheets opens a DIFFERENT, richer menu than the plain-cell one — still Cut/Copy/Paste/Paste special first, but the Insert group offers BOTH directions ("Insert 1 row above" AND "Insert 1 row below" together for a header-selected row, live-confirmed), plus Clear row/Hide row/Resize row/Create a filter/Conditional formatting/Data validation/"View more row actions" (only Clear is in scope here — the rest have no backing Studio capability yet, per the issue's own non-goals list). For a MULTI-row/column header selection, this session's live drive of the exact real-range Delete/Clear label wording hit a shared-browser tooling collision (see the oracle's addendum) — Studio's own chosen wording, `Delete rows {r0}-{r1}`/`Clear rows {r0}-{r1}` (no spaces around the hyphen), is a disclosed divergence from any unverified exact Sheets spacing, not a byte-for-byte replication attempt. Studio's `clear.selection` command needed no new capability: a header-originated whole-row/ whole-column selection is structurally just a `SelRange`, identical in shape to any other selection `clearEdits` already handles. + +#### Right-clicking a cell opens the menu with every row present, without invoking anything + +**Given** +- **A1** = `5` + +**When** I right-click **A1** + +**Then** +- **A1** shows `5` +- `[data-testid="grid-context-menu-cut"]` is visible containing "Cut" +- `[data-testid="grid-context-menu-copy"]` is visible containing "Copy" +- `[data-testid="grid-context-menu-paste"]` is visible containing "Paste" +- `[data-testid="grid-context-menu-insert-rows"]` is visible containing "Insert row above" +- `[data-testid="grid-context-menu-insert-rows-below"]` is visible containing "Insert row below" +- `[data-testid="grid-context-menu-insert-columns"]` is visible containing "Insert column left" +- `[data-testid="grid-context-menu-insert-columns-right"]` is visible containing "Insert column right" +- `[data-testid="grid-context-menu-delete-rows"]` is visible containing "Delete row" +- `[data-testid="grid-context-menu-delete-columns"]` is visible containing "Delete column" +- `[data-testid="grid-context-menu-explain"]` is visible containing "Explain" + +#### Cut then Paste via the context menu moves the cell, same as Edit ▸ Cut/Paste + +**Given** +- **A1** = `42` + +**When** I right-click **A1**, then click **grid-context-menu-cut** in the context menu, then select **B1**, then right-click **B1**, then click **grid-context-menu-paste** in the context menu + +**Then** +- **A1** shows `null` +- **B1** shows `42` + +#### Copy then Paste via the context menu duplicates the cell and keeps the source, same as Edit ▸ Copy/Paste + +**Given** +- **A1** = `7` + +**When** I right-click **A1**, then click **grid-context-menu-copy** in the context menu, then select **B1**, then right-click **B1**, then click **grid-context-menu-paste** in the context menu + +**Then** +- **A1** shows `7` +- **B1** shows `7` + +#### Insert row above via the context menu shifts content down, same as Insert ▸ Rows + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` + +**When** I right-click **A2**, then click **grid-context-menu-insert-rows** in the context menu + +**Then** +- **A1** shows `1` +- **A2** shows `null` +- **A3** shows `2` +- **A4** shows `3` + +#### Insert row above/below on a multi-row selection shows the REAL row count in both labels, not a hardcoded singular + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` +- **A4** = `4` + +**When** I select **A1:A3**, then right-click **A2** + +**Then** +- `[data-testid="grid-context-menu-insert-rows"]` is visible containing "Insert 3 rows above" +- `[data-testid="grid-context-menu-insert-rows-below"]` is visible containing "Insert 3 rows below" + +#### Insert row below via the context menu inserts the selection's row count below it + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` +- **A4** = `4` + +**When** I select **A1:A3**, then right-click **A2**, then click **grid-context-menu-insert-rows-below** in the context menu + +**Then** +- **A1** shows `1` +- **A2** shows `2` +- **A3** shows `3` +- **A4** shows `null` +- **A5** shows `null` +- **A6** shows `null` +- **A7** shows `4` + +#### Delete row via the context menu shifts content up, same as Edit ▸ Delete rows + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` + +**When** I right-click **A2**, then click **grid-context-menu-delete-rows** in the context menu + +**Then** +- **A1** shows `1` +- **A2** shows `3` +- **A3** shows `null` + +#### Insert column left via the context menu shifts content right, same as Insert ▸ Columns + +**Given** +- **A1** = `1` +- **B1** = `2` +- **C1** = `3` + +**When** I right-click **B1**, then click **grid-context-menu-insert-columns** in the context menu + +**Then** +- **A1** shows `1` +- **B1** shows `null` +- **C1** shows `2` +- **D1** shows `3` + +#### Insert column left/right on a multi-column selection shows the REAL column count in both labels, not a hardcoded singular + +**Given** +- **A1** = `1` +- **B1** = `2` +- **C1** = `3` +- **D1** = `4` + +**When** I select **A1:C1**, then right-click **B1** + +**Then** +- `[data-testid="grid-context-menu-insert-columns"]` is visible containing "Insert 3 columns left" +- `[data-testid="grid-context-menu-insert-columns-right"]` is visible containing "Insert 3 columns right" + +#### Insert column right via the context menu inserts the selection's column count to its right + +**Given** +- **A1** = `1` +- **B1** = `2` +- **C1** = `3` +- **D1** = `4` + +**When** I select **A1:C1**, then right-click **B1**, then click **grid-context-menu-insert-columns-right** in the context menu + +**Then** +- **A1** shows `1` +- **B1** shows `2` +- **C1** shows `3` +- **D1** shows `null` +- **E1** shows `null` +- **F1** shows `null` +- **G1** shows `4` + +#### Delete column via the context menu shifts content left, same as Edit ▸ Delete columns + +**Given** +- **A1** = `1` +- **B1** = `2` +- **C1** = `3` + +**When** I right-click **B1**, then click **grid-context-menu-delete-columns** in the context menu + +**Then** +- **A1** shows `1` +- **B1** shows `3` +- **C1** shows `null` + +#### A right-click INSIDE the current multi-row selection preserves it, acting on the whole range (Sheets convention) + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` +- **A4** = `4` + +**When** I select **A1:A3**, then right-click **A2**, then click **grid-context-menu-delete-rows** in the context menu + +**Then** +- **A1** shows `4` +- **A2** shows `null` +- **A3** shows `null` +- **A4** shows `null` + +#### A right-click OUTSIDE the current multi-row selection collapses it to the clicked cell before acting (Sheets convention) + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` +- **A4** = `4` + +**When** I select **A1:A3**, then right-click **A4**, then click **grid-context-menu-delete-rows** in the context menu + +**Then** +- **A1** shows `1` +- **A2** shows `2` +- **A3** shows `3` +- **A4** shows `null` + +#### Right-clicking a row-number header directly opens the richer header menu — row-only actions, no column items + +**Given** +- **A2** = `2` + +**When** I right-click the row **2** header + +**Then** +- `[data-testid="grid-context-menu-cut"]` is visible containing "Cut" +- `[data-testid="grid-context-menu-copy"]` is visible containing "Copy" +- `[data-testid="grid-context-menu-paste"]` is visible containing "Paste" +- `[data-testid="grid-context-menu-insert-rows"]` is visible containing "Insert row above" +- `[data-testid="grid-context-menu-insert-rows-below"]` is visible containing "Insert row below" +- `[data-testid="grid-context-menu-delete-rows"]` is visible containing "Delete row 2" +- `[data-testid="grid-context-menu-clear-rows"]` is visible containing "Clear row 2" +- `[data-testid="grid-context-menu-insert-columns"]` is hidden +- `[data-testid="grid-context-menu-insert-columns-right"]` is hidden +- `[data-testid="grid-context-menu-delete-columns"]` is hidden +- `[data-testid="grid-context-menu-clear-columns"]` is hidden + +#### Right-clicking a column-letter header directly opens the richer header menu — column-only actions, no row items + +**Given** +- **B1** = `2` + +**When** I right-click the **B** column header + +**Then** +- `[data-testid="grid-context-menu-insert-columns"]` is visible containing "Insert column left" +- `[data-testid="grid-context-menu-insert-columns-right"]` is visible containing "Insert column right" +- `[data-testid="grid-context-menu-delete-columns"]` is visible containing "Delete column B" +- `[data-testid="grid-context-menu-clear-columns"]` is visible containing "Clear column B" +- `[data-testid="grid-context-menu-insert-rows"]` is hidden +- `[data-testid="grid-context-menu-insert-rows-below"]` is hidden +- `[data-testid="grid-context-menu-delete-rows"]` is hidden +- `[data-testid="grid-context-menu-clear-rows"]` is hidden + +#### A multi-row header selection's header menu shows the REAL range in every label + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` +- **A4** = `4` +- **A5** = `5` + +**When** I select row headers **2:4**, then right-click the row **3** header + +**Then** +- `[data-testid="grid-context-menu-insert-rows"]` is visible containing "Insert 3 rows above" +- `[data-testid="grid-context-menu-insert-rows-below"]` is visible containing "Insert 3 rows below" +- `[data-testid="grid-context-menu-delete-rows"]` is visible containing "Delete rows 2-4" +- `[data-testid="grid-context-menu-clear-rows"]` is visible containing "Clear rows 2-4" + +#### A multi-column header selection's header menu shows the REAL range in every label + +**Given** +- **A1** = `1` +- **B1** = `2` +- **C1** = `3` +- **D1** = `4` +- **E1** = `5` + +**When** I select column headers **B:D**, then right-click the **C** column header + +**Then** +- `[data-testid="grid-context-menu-insert-columns"]` is visible containing "Insert 3 columns left" +- `[data-testid="grid-context-menu-insert-columns-right"]` is visible containing "Insert 3 columns right" +- `[data-testid="grid-context-menu-delete-columns"]` is visible containing "Delete columns B-D" +- `[data-testid="grid-context-menu-clear-columns"]` is visible containing "Clear columns B-D" + +#### Insert row above via the row-header menu shifts content down — same command the plain cell menu already uses + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` + +**When** I right-click the row **2** header, then click **grid-context-menu-insert-rows** in the context menu + +**Then** +- **A1** shows `1` +- **A2** shows `null` +- **A3** shows `2` +- **A4** shows `3` + +#### Delete rows via the row-header menu removes the selected band — same command the plain cell menu already uses + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` + +**When** I right-click the row **2** header, then click **grid-context-menu-delete-rows** in the context menu + +**Then** +- **A1** shows `1` +- **A2** shows `3` +- **A3** shows `null` + +#### Clear rows via the row-header menu empties the row's content WITHOUT deleting or shifting rows + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` +- **B2** = `20` + +**When** I right-click the row **2** header, then click **grid-context-menu-clear-rows** in the context menu + +**Then** +- **A1** shows `1` +- **A2** shows `null` +- **A3** shows `3` +- **B2** shows `null` + +#### Clear columns via the column-header menu empties the column's content WITHOUT deleting or shifting columns + +**Given** +- **A1** = `1` +- **B1** = `2` +- **C1** = `3` +- **B2** = `20` + +**When** I right-click the **B** column header, then click **grid-context-menu-clear-columns** in the context menu + +**Then** +- **A1** shows `1` +- **B1** shows `null` +- **C1** shows `3` +- **B2** shows `null` + +#### A right-click on a plain cell with NO header-based selection still opens the ORIGINAL plain cell menu, unchanged + +**Given** +- **A1** = `1` + +**When** I right-click **A1** + +**Then** +- `[data-testid="grid-context-menu-insert-rows"]` is visible containing "Insert row above" +- `[data-testid="grid-context-menu-insert-columns"]` is visible containing "Insert column left" +- `[data-testid="grid-context-menu-delete-rows"]` is visible containing "Delete row" +- `[data-testid="grid-context-menu-delete-columns"]` is visible containing "Delete column" +- `[data-testid="grid-context-menu-clear-rows"]` is hidden +- `[data-testid="grid-context-menu-clear-columns"]` is hidden + +#### A right-click on a cell inside a header-originated whole-row selection opens the richer ROW header menu, not the plain cell menu + +**Given** +- **A1** = `1` +- **B1** = `2` +- **A2** = `3` +- **B2** = `4` + +**When** I select row headers **1:2**, then right-click **B1** + +**Then** +- `[data-testid="grid-context-menu-delete-rows"]` is visible containing "Delete rows 1-2" +- `[data-testid="grid-context-menu-clear-rows"]` is visible +- `[data-testid="grid-context-menu-insert-columns"]` is hidden + +#### A right-click on a cell inside a header-originated whole-column selection opens the richer COLUMN header menu, not the plain cell menu + +**Given** +- **A1** = `1` +- **B1** = `2` +- **A2** = `3` +- **B2** = `4` + +**When** I select column headers **A:B**, then right-click **A2** + +**Then** +- `[data-testid="grid-context-menu-delete-columns"]` is visible containing "Delete columns A-B" +- `[data-testid="grid-context-menu-clear-columns"]` is visible +- `[data-testid="grid-context-menu-insert-rows"]` is hidden diff --git a/content/docs/learn/playground/history-revert.mdx b/content/docs/learn/playground/history-revert.mdx index 1bb8417..20c311f 100644 --- a/content/docs/learn/playground/history-revert.mdx +++ b/content/docs/learn/playground/history-revert.mdx @@ -11,8 +11,9 @@ The **History** panel is now interactive. Each entry carries an explicit, always 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 - (persistent history — autosave + restore across reload, in THIS browser) has shipped; see [persistent-history](./persistent-history). What's still deferred is the account half: +## Deferred to a saved account + +Persistent history (autosave + restore across reload, in THIS browser) has shipped; see [persistent-history](./persistent-history). What's still deferred is the account half: - **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. diff --git a/content/docs/learn/playground/make-a-copy.mdx b/content/docs/learn/playground/make-a-copy.mdx new file mode 100644 index 0000000..b122372 --- /dev/null +++ b/content/docs/learn/playground/make-a-copy.mdx @@ -0,0 +1,48 @@ +--- +title: "Make a copy — local multi-document storage" +description: "File > Make a copy shows a naming dialog prefilled \"Copy of \", then duplicates the current document's content under the confirmed name into a brand-new, independent local document that opens in its own tab, leaving the source untouched — enabled by a document-keyed registry (every document has its own name, defaulting \"Untitled workbook\") that replaced try mode's single fixed local-storage slot and migrates the existing saved workbook with zero data loss." +--- + +{/* GENERATED — do not edit by hand */} + +## What it does + +**File > Make a copy** opens a naming dialog — a required **Name** field prefilled `Copy of `, its text pre-selected so typing immediately replaces it (matching the oracle's #2 finding) — before duplicating anything. Confirming duplicates the current document's content — every cell, formula, format, and sheet — under the confirmed name into a brand-new, independent local document, and opens it in a new browser tab (focused, matching Google Sheets' own new-tab behavior). The tab you clicked from is left exactly as it was; the two documents are independent from that moment on, each with its own local storage, its own autosave, and its own name. + +This was previously impossible: try mode persisted exactly **one** workbook to a single fixed local-storage slot, so there was nowhere a second document could live, and no document had a name at all. That's what this feature actually adds — a small, real local-storage schema change (a document-keyed **registry**, every entry now carrying a `name` — defaulting `"Untitled workbook"` for the migrated pre-existing document — not a rewrite of how any one document is saved) — with the naming dialog and **File > Open** (see [file-open](./file-open)) as its first two consumers. + +**Local multi-document storage.** Every document is identified by a short local id, a **name**, and a pair of storage keys (its snapshot in `localStorage`, its command log in `IndexedDB` — the exact same two-part shape [persistent-history](./persistent-history) already saves through). A small **registry** (one `localStorage` entry: a list of `{ id, name, keys, createdAt, updatedAt }`) is the list of documents that exist in this browser, plus an **active-document pointer** recording which one a plain reload should return to. Opening `?doc=` (what the new tab's URL carries) opens THAT document; a plain visit with no `?doc=` reopens whichever document was last active — byte-identical to the pre-this-feature single-document behavior when only one document has ever existed. + +**Migration preserves the existing workbook — zero data moved.** The very first time this ships to a browser that already had a saved workbook (the old single fixed slot), the registry is seeded with exactly one entry whose storage keys are the OLD fixed keys, unchanged, and whose **name defaults to "Untitled workbook"**. Nothing is copied or rewritten; the existing document is simply now ALSO listed in the registry, still readable from the exact same place it always was. This bootstrap is idempotent (a no-op on every later boot) and runs automatically, with no migration step to run by hand. A registry a browser already wrote BEFORE `name` existed (the one release where the registry shipped without it) is handled the same defaulting way on read, rather than losing that entry. + +**The copy carries content, not history — matching the oracle.** Per the observed Sheets behavior (a copy is a new Drive file with its own fresh version history, never the source's — see the oracle brief), Make a copy writes the CURRENT live document's cells/formats/sheets into the new document's slot, but never touches its command-log slot — so opening the copy starts with empty History-panel/undo-redo, the same as any other freshly-created document. This is a snapshot at the moment of copying, not a live link: editing the source (or the copy) afterward never affects the other. + +**No account or server involved.** Every piece of this — the registry, the active-document pointer, each document's snapshot and log — lives in this browser's `localStorage`/`IndexedDB` only, entirely separate from persisted mode (account save, `.tc` cloud sync), which is tracked elsewhere. Multi-document storage in the browser is still try mode. + +## Verified behavior + +_Matches Google Sheets:_ Google Sheets' File > Make a copy opens a "Copy document" dialog — a required Name field prefilled `Copy of ` (its text pre-selected for immediate rename), a Folder picker, and Share/comments checkboxes — and confirming it opens the duplicate in a brand-new, focused browser tab: a genuinely new Drive file (its own file id), carrying over the source's full content (every cell, formula, format, and sheet) as of that moment, but starting with its OWN fresh version history, never the source's. The source's own tab is left open and completely unchanged. Studio has no server, sharing, or folders yet, so the Folder picker and Share/comments checkboxes have no Studio analog — but the Name field DOES: Studio's own naming dialog matches it directly (required, prefilled `Copy of <current name>`, pre-selected, editable before confirming). See this feature's oracle brief for the full replication brief (dialog contents, new-tab-and-focus behavior, and the fresh-history finding this feature's "no log copied" design follows). + +#### Make a copy is available (no longer disabled) and opens the naming dialog, prefilled with "Copy of" the current document's name, WITHOUT touching the current document + +**Given** +- **A1** = `42` + +**When** I click menu item **file.makeCopy** + +**Then** +- **A1** shows `42` +- `[data-menu-item="file.makeCopy"]` is visible +- `[data-testid="copy-document-dialog"]` is visible containing "Copy document" +- `[data-testid="copy-document-name-input"]` is visible with value "Copy of Untitled workbook" + +#### Cancelling the naming dialog aborts — no dialog left open, current document still untouched + +**Given** +- **A1** = `42` + +**When** I click menu item **file.makeCopy**, then click **copy-document-cancel** + +**Then** +- **A1** shows `42` +- `[data-testid="copy-document-dialog"]` is hidden diff --git a/content/docs/learn/playground/menu-bar.mdx b/content/docs/learn/playground/menu-bar.mdx index cef2a13..29b7750 100644 --- a/content/docs/learn/playground/menu-bar.mdx +++ b/content/docs/learn/playground/menu-bar.mdx @@ -9,7 +9,7 @@ description: "A Google-Sheets-organized top menu bar sits above the toolbar; act 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). +- 🟢 **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, and — once the active sheet has one — Show chart). The three **View > Show** toggles persist per browser: unchecking one and reloading leaves it unchecked, and a first-time visitor with nothing stored gets gridlines and the formula bar ON. Sheets is *reported* to store its own `Show gridlines` in the document rather than per user — unverified by this repo; the chart-insert feature's own note on browser-local preferences versus document state covers the same distinction. - ◻️ **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. @@ -21,7 +21,7 @@ File absorbs the old standalone "Save, load, export" toolbar button and its Impo ## 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. +_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 this feature 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. View ▸ Full screen hides Sheets' own title/menu row, toolbar, and sheet tabs in place — but NOT its name-box/formula-bar row, which stays visible — leaving the grid plus that one row; it is NOT the browser's native fullscreen API. A single Escape restores everything at once. #### All 7 menus render in Google-Sheets order and grouping @@ -51,16 +51,25 @@ _Matches Google Sheets:_ Google Sheets' real top menu bar is File · Edit · Vie **Then** - `[data-slot="formula-autocomplete"]` is visible -#### A disabled item (Format > Conditional formatting) is visibly disabled and invokes nothing +#### A disabled item (Data > Data validation) is visibly disabled and invokes nothing **Given** - **A1** = `10` -**When** I click menu item **format.conditionalFormatting** +**When** I click menu item **data.dataValidation** **Then** - **A1** shows `10` -- `[data-menu-item="format.conditionalFormatting"]` is visible +- `[data-menu-item="data.dataValidation"]` is visible + +#### Clicking Format > Conditional formatting opens the rules dialog + +**Given** + +**When** I click menu item **format.conditionalFormatting** + +**Then** +- `[data-testid="conditional-format-dialog"]` is visible #### Clicking Format > Merge cells merges the selection, through the SAME confirm-gated seam the toolbar's merge control uses @@ -121,3 +130,42 @@ _Matches Google Sheets:_ Google Sheets' real top menu bar is File · Edit · Vie **Then** - `[data-menu-item="view.gridlines"]` is visible + +#### Hiding the formula bar survives a reload — a View > Show choice is a stored preference, not session state + +**Given** + +**When** I click menu item **view.formulaBar**, then save, reload the page, and load the workbook back + +**Then** +- `[data-testid="menu-bar"]` is visible +- `[data-testid="formula-input"]` is hidden + +#### A first load with nothing stored shows the formula bar — a missing preference never reads as hidden + +**Given** + +**When** I save, reload the page, and load the workbook back + +**Then** +- `[data-testid="formula-input"]` is visible + +#### View > Full screen hides the menu bar itself + +**Given** + +**When** I click menu item **view.fullScreen** + +**Then** +- `[data-testid="menu-bar"]` is hidden +- `[data-testid="full-screen-banner"]` is visible + +#### Escape exits full screen and restores the menu bar + +**Given** + +**When** I click menu item **view.fullScreen**, then press `Escape` + +**Then** +- `[data-testid="menu-bar"]` is visible +- `[data-testid="full-screen-banner"]` is hidden diff --git a/content/docs/learn/playground/merge-cells.mdx b/content/docs/learn/playground/merge-cells.mdx index 1dcb88b..f98347e 100644 --- a/content/docs/learn/playground/merge-cells.mdx +++ b/content/docs/learn/playground/merge-cells.mdx @@ -9,7 +9,7 @@ description: "A merged range renders, selects, and navigates as one spanning cel 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). +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. diff --git a/content/docs/learn/playground/meta.json b/content/docs/learn/playground/meta.json index ccea166..c0f5d29 100644 --- a/content/docs/learn/playground/meta.json +++ b/content/docs/learn/playground/meta.json @@ -43,6 +43,18 @@ "format-underline", "menu-bar", "merge-cells", - "persistent-history" + "persistent-history", + "chart-axis-labels", + "chart-insert", + "command-palette", + "conditional-formatting", + "file-open", + "formula-range-preview", + "grid-context-menu", + "make-a-copy", + "share-config", + "sort-range", + "structure-insert-delete", + "views" ] } diff --git a/content/docs/learn/playground/navigate-range-select.mdx b/content/docs/learn/playground/navigate-range-select.mdx index 503e7ce..1607861 100644 --- a/content/docs/learn/playground/navigate-range-select.mdx +++ b/content/docs/learn/playground/navigate-range-select.mdx @@ -41,7 +41,7 @@ compare with holding the plain right arrow, which walks one cell at a time. ## Verified behavior -_Matches Google Sheets:_ In Google Sheets, clicking a cell selects it alone; shift-clicking or shift-arrowing another cell extends the selection into a rectangular range (anchored at the first click, with the moving corner following the pointer/ arrow), rendered as a translucent blue fill with a solid outline. Arrow keys, Tab, and Enter move the active cell one cell at a time (Tab right, Enter down after a commit). Ctrl+Arrow ("jump to data edge") is the fast-navigation chord: from the active cell, in the arrow's direction, it (a) jumps to the LAST cell of the contiguous non-empty run when BOTH the active cell and the adjacent cell have content, (b) jumps to the FIRST non-empty cell ahead when the adjacent cell is empty (regardless of whether the active cell itself has content), (c) lands on the immediately-adjacent cell when the active cell is empty but the adjacent cell has content — the classic "double Ctrl+Down" from a blank cell above a data column: one press reaches the TOP of the data, a second (now from a non-empty active cell) reaches the bottom — or (d) jumps to the edge of the sheet when there is no more data in that direction. At the sheet's edge already, Ctrl+Arrow is a no-op. Ctrl+Shift+ Arrow applies the same jump to the moving corner of the selection, extending the range instead of relocating the lone active cell. +_Matches Google Sheets:_ In Google Sheets, clicking a cell selects it alone; shift-clicking or shift-arrowing another cell extends the selection into a rectangular range (anchored at the first click, with the moving corner following the pointer/ arrow), rendered as a translucent blue fill with a solid outline. Arrow keys, Tab, and Enter move the active cell one cell at a time (Tab right, Enter down after a commit). Ctrl+Arrow ("jump to data edge") is the fast-navigation chord: from the active cell, in the arrow's direction, it (a) jumps to the LAST cell of the contiguous non-empty run when BOTH the active cell and the adjacent cell have content, (b) jumps to the FIRST non-empty cell ahead when the adjacent cell is empty (regardless of whether the active cell itself has content), (c) lands on the immediately-adjacent cell when the active cell is empty but the adjacent cell has content — the classic "double Ctrl+Down" from a blank cell above a data column: one press reaches the TOP of the data, a second (now from a non-empty active cell) reaches the bottom — or (d) jumps to the edge of the sheet when there is no more data in that direction. At the sheet's edge already, Ctrl+Arrow is a no-op. Ctrl+Shift+ Arrow applies the same jump to the moving corner of the selection, extending the range instead of relocating the lone active cell. Tab is a COMMIT-AND-MOVE, not merely a move: pressed with the cell editor open it commits the edit and steps one column right, and Shift+Tab commits and steps one column LEFT — Shift reverses Tab's direction rather than extending the selection the way it does for Shift+Arrow. Sheets ALSO remembers the column a Tab run began in and returns there, one row down, on the Enter that ends the run (typing through H22 →Tab→ I22 →Tab→ J22 then pressing Enter lands on H23, not J23), and cycles the active cell WITHIN a selected range rather than leaving it — both observed, both NOT yet implemented in Studio, tracked separately. All of this is observed, not recalled — see this feature's oracle brief, observations 1–5. #### Clicking a cell makes it the sole active selection @@ -61,6 +61,35 @@ _Matches Google Sheets:_ In Google Sheets, clicking a cell selects it alone; shi **Then** - the active cell is **B1** +#### Shift+Tab moves the active cell one column left + +**Given** + +**When** I click **C1**, then press `Shift+Tab` + +**Then** +- the active cell is **B1** + +#### Tab from an open editor commits the edit and moves one column right + +**Given** + +**When** I click **B2**, then press `9`, then press `Tab` + +**Then** +- **B2** shows `9` +- the active cell is **C2** + +#### Shift+Tab from an open editor commits the edit and moves one column left + +**Given** + +**When** I click **C2**, then press `9`, then press `Shift+Tab` + +**Then** +- **C2** shows `9` +- the active cell is **B2** + #### Committing an edit with Enter moves the active cell down **Given** diff --git a/content/docs/learn/playground/persistent-history.mdx b/content/docs/learn/playground/persistent-history.mdx index b88a0f7..da8a184 100644 --- a/content/docs/learn/playground/persistent-history.mdx +++ b/content/docs/learn/playground/persistent-history.mdx @@ -15,7 +15,7 @@ Studio now autosaves **continuously** — the document and its full command log 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). +- **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`. diff --git a/content/docs/learn/playground/share-config.mdx b/content/docs/learn/playground/share-config.mdx new file mode 100644 index 0000000..bb35f7c --- /dev/null +++ b/content/docs/learn/playground/share-config.mdx @@ -0,0 +1,69 @@ +--- +title: "Embed/share presentation config (ShareConfig)" +description: "A `ShareConfig` controls how a grid is embedded/shared — which range/sheet is visible (an explicit A1 range, or auto-detected used content, capped by maxRows/maxCols), independently-combinable chrome-visibility toggles (menu bar/toolbar/formula bar/sheet tabs), and a whole-embed read-only flag — delivered as URL query params on the existing `?seed=` mechanism; every existing embed with no ShareConfig params keeps working exactly as before." +--- + +{/* GENERATED — do not edit by hand */} + +## What it does + +A `ShareConfig` controls how a grid is presented when embedded or shared — +separate from a user's own working-view preferences (the existing grid +Settings), the same way Google Sheets' "Publish to web" is its own dialog, +not a view setting. + +- **`sheetId`** (optional): unset shows the whole workbook (the viewer can + switch sheets via the tab bar, subject to `chrome.sheetTabs`); set scopes + the embed to that one sheet and the tab bar is **always** hidden — there's + nothing to switch to. +- **`range`** (optional, A1-style, e.g. `A1:D10`): the base visible shape. + Unset auto-detects the sheet's own used-content extent instead — no more + "one filled cell renders as a huge, mostly-blank grid." +- **`maxRows`/`maxCols`** (optional): cap whichever of the above is larger. + Never grow past a smaller shape. +- **`chrome`** (`menuBar`/`toolbar`/`formulaBar`/`sheetTabs`): independent, + combinable visibility toggles — not a fixed preset. All default visible. +- **`readOnly`**: makes the whole embed read-only — no cell edits, no + format/merge/sheet-structure changes, no undo/redo. Signaled visually: a "View only" badge in the + TopBar chrome, and a `not-allowed` cursor over the grid — a small, GLOBAL + indicator, not per-cell styling. +- **`hideBranding`**: reserved in the type, inert in v1 — see "Non-goals" below. + +**Delivery**: `ShareConfig` fields ride the existing `?seed=` URL mechanism +as query params, read once at load — e.g. +`?seed=A1=Hello&range=A1:D10&hideChrome=menuBar,toolbar&readOnly=1`. Chrome +uses **hide-list** semantics (`hideChrome=menuBar,toolbar`), not a show-list — +every element defaults to visible, matching today's behavior with **no +ShareConfig params set at all** (a bare `?seed=` link keeps working exactly +as before — no crop, full chrome, editable). Wanting ONLY the auto-detect +crop with every other field at its default (the design doc's own headline +motivating case — "a sheet with one filled cell shouldn't embed as a huge +blank grid") needs one explicit activation signal since there's otherwise no +param to distinguish that request from "no ShareConfig at all": `?seed=...&crop=1` +(`crop` is a URL-delivery-only flag, not a `ShareConfig` field itself — it is +the signal that says "apply the default crop", which is not the same request as +"no ShareConfig at all"). + +**Non-goals** (explicitly out of scope, tracked separately or not at all): +per-range protected editing, the "Made with TrueCalc" branding +badge and any paid-tier gating for it, internal scroll for content beyond the +cropped shape, and named ranges (`range` is a plain A1 string only). + +## Verified behavior + +_Matches Google Sheets:_ This is an embed/iframe-delivery concept — "crop a shared grid to a range, cap it, toggle chrome, make it read-only via URL params" — with no direct Google Sheets analogue to study live (Sheets' closest relative, "Publish to web," is a server-rendered snapshot with a completely different mechanism, no client-side crop/chrome/read-only composition to observe). Per this repo's own methodology, that absence is documented explicitly rather than fabricated — see this feature's oracle brief for the full reasoning. Ground truth for this feature is the originating issue's own acceptance criteria (reproduced verbatim in the oracle brief), the same "no oracle, use the issue itself" precedent the persistent-history and command-history features establish for TrueCalc-specific (non-spreadsheet-UI) features. + +#### with no ShareConfig params, every chrome element still renders and editing still works — the backward-compatibility acceptance criterion + +**Given** +- **A1** = `1` + +**When** I click **A1**, then type `2` into **B1** + +**Then** +- **A1** shows `1` +- **B1** shows `2` +- `[data-testid="menu-bar"]` is visible +- `[data-testid="toolbar"]` is visible +- `[data-testid="formula-input"]` is visible +- `[aria-label="Sheet tabs"]` is visible diff --git a/content/docs/learn/playground/shortcuts.mdx b/content/docs/learn/playground/shortcuts.mdx index ded08d5..bbf8d2a 100644 --- a/content/docs/learn/playground/shortcuts.mdx +++ b/content/docs/learn/playground/shortcuts.mdx @@ -14,7 +14,7 @@ or click outside to close it. ## Verified behavior -_Matches Google Sheets:_ Individually, Studio's bindings already match Google Sheets: Ctrl+B bold, Ctrl+I italic, Ctrl+C/X/V copy/cut/paste, Ctrl+Z undo, Ctrl+Y (or Ctrl+Shift+Z) redo, Ctrl+D/R fill down/right, and Ctrl+Shift+7 all-borders are the same chords Sheets uses (each verified in its own feature manifest — format-bold.mdx, clipboard.mdx, undo.mdx, fill.mdx, format-borders.mdx — this manifest doesn't re-assert them, only that they're all reachable from one categorized list). The cheat-sheet TRIGGER itself is a deliberate divergence: Google Sheets opens its shortcut reference on Ctrl+/ (Cmd+/ on Mac). Studio binds a bare '?' (Shift+/) instead, following the newer cross-app convention (Gmail, Slack, GitHub, Trello, Notion) — chosen so it doesn't compete with Ctrl+/, which several browsers/extensions already reserve. Excel has no single-key shortcut-list binding to match either way (its shortcuts live in a Help-pane search), so there's no Excel parity claim to make here. +_Matches Google Sheets:_ Individually, Studio's bindings already match Google Sheets: Ctrl+B bold, Ctrl+I italic, Ctrl+C/X/V copy/cut/paste, Ctrl+Z undo, Ctrl+Y (or Ctrl+Shift+Z) redo, Ctrl+D/R fill down/right, and Ctrl+Shift+7 all-borders are the same chords Sheets uses (each verified in its own feature — bold, clipboard, undo, fill, and borders — this manifest doesn't re-assert them, only that they're all reachable from one categorized list). The cheat-sheet TRIGGER itself is a deliberate divergence: Google Sheets opens its shortcut reference on Ctrl+/ (Cmd+/ on Mac). Studio binds a bare '?' (Shift+/) instead, following the newer cross-app convention (Gmail, Slack, GitHub, Trello, Notion) — chosen so it doesn't compete with Ctrl+/, which several browsers/extensions already reserve. Excel has no single-key shortcut-list binding to match either way (its shortcuts live in a Help-pane search), so there's no Excel parity claim to make here. #### Shift+/ opens a categorized panel listing the registry's shortcuts @@ -29,7 +29,7 @@ _Matches Google Sheets:_ Individually, Studio's bindings already match Google Sh **Given** -**When** I click **A1**, then press `Shift+?`, then press `Escape` +**When** I click **A1**, then press `Shift+?`, then see `[data-testid="shortcuts-panel"]` on screen, then press `Escape` **Then** - `[data-testid="shortcuts-panel"]` is hidden diff --git a/content/docs/learn/playground/sort-range.mdx b/content/docs/learn/playground/sort-range.mdx new file mode 100644 index 0000000..4bfcb2c --- /dev/null +++ b/content/docs/learn/playground/sort-range.mdx @@ -0,0 +1,291 @@ +--- +title: "Sort a range (daily-driver slice)" +description: "Data ▸ Sort range ▸ \"Sort range A → Z\" / \"Z → A\" reorders the rows of the CURRENT SELECTION by its first column, carrying every selected column of a row along with it and nothing else — a one-column selection reorders that column alone, exactly as Google Sheets does. Formulas move with their cells, their relative references translating to the new row while `$`-anchored ones stay pinned. Numbers (dates among them) order before text, text before booleans, booleans before errors, and blanks stay last in BOTH directions. The whole permutation is ONE undoable command, so a single Ctrl+Z restores the exact prior order." +--- + +{/* GENERATED — do not edit by hand */} + +## What it does + +**Data ▸ Sort range ▸ Sort range A → Z** (and **Z → A**) reorders the rows of the current selection by the values in its **first column**, matching Google Sheets' own two quick menu rows. + +Two things about it are easy to get wrong, and both are taken directly from the live oracle rather than from intuition: + +1. **It sorts the SELECTION, not the row.** Only cells inside the selected rectangle move. If you select one column of a table and sort it, that column is reordered and every other column stays exactly where it is — so each row's values no longer belong together. Google Sheets does precisely this, with no warning and no "do you want to expand the selection?" prompt, and Studio replicates it faithfully. Select the whole table when you mean to sort the whole table. +2. **Undo is one step.** The entire permutation is a single command, so one <kbd>Ctrl</kbd>+<kbd>Z</kbd> puts every cell — values _and_ formula text — back exactly where it was. + +Formulas travel with their cell. A **relative** reference translates by the number of rows the formula moved, so `=A2*2` sitting in row 2 reads `=A1*2` after landing in row 1 and keeps pointing at its own row; a **`$`-anchored** reference is left verbatim, so `=$Z$2` stays `=$Z$2` wherever it lands. That is the fill/copy translate, not the `$`-blind rewrite an insert/delete performs — a sort moves the _formula_, never the cell it refers to. + +Formatting travels with the row too — a bold cell is still bold where it lands, and a date-formatted cell is still a date. That is not just cosmetic parity: Studio stores a date as a serial with a `date` number format and derives the cell's date _typing_ from it, so leaving the format behind would turn the plain number that lands on that row into a 1900 date. + +Mixed types order the way Sheets orders them: **numbers first** (a date sorts among them, by its serial), then **text** (compared case-insensitively, so `apple` comes before `Banana`), then **booleans**, then **errors** — and **blanks stay at the bottom in both directions**, never flipping to the top when you sort descending. + +## Try it + +<TrueCalc mode="standalone" seed="A1=delta;B1=3;A2=alpha;B2=1;A3=charlie;B3=2" /> + +## What this slice deliberately does not do + +Sorting is destructive, so anything the live oracle session did not actually demonstrate is left out rather than approximated: + +- **No "Data has header row" option.** The checkbox was seen, and ticking it was seen to relabel the Sort-by dropdown with the header text — but what it does to the sorted _output_ was never confirmed. Studio's sort always permutes the whole selection, exactly like the quick menu row that _was_ observed end to end (which sorted a header row into the data). To keep a header out of a sort, leave it out of the selection. +- **No multi-column sort**, no `Data ▸ Sort sheet`, and no advanced-sort dialog. +- **No ordering is claimed within the boolean group or within the error group**, and no tie-break between two equal keys: the sort is _stable_, so equal keys keep their original relative order. +- **A range overlapping a merged cell, or a spilled dynamic array, is refused** rather than sorted. Neither was ever put inside a sorted range in the oracle session, and both have a concrete way to lose data if guessed at: permuting content under a merge hides it (the merge stays put, so whatever lands in a covered cell vanishes from the grid while still sitting in the model), and moving a spill anchor re-spills from a new origin, hard-materialises the echoes it left behind, and cannot be undone in one step. The menu row is a no-op on such a selection; the API command throws with the reason. +- **A relative reference with nowhere to go becomes `#REF!`.** `=A1` moving up out of row 1 has no cell to point at. Sort hands the text to the same `adjustFormula` translate that fill and copy/paste already use, so it behaves identically to them — but Sheets was never asked what _it_ does, so this is recorded as unobserved rather than claimed as parity. +- **No filter.** `Data ▸ Create a filter` stays "coming soon". The oracle captured a filter's creation chrome (green column headers, per-header dropdown buttons, a bold header row) but none of the behaviour that matters — what the dropdown contains, how hidden rows renumber, whether it survives reload, whether undo removes it in one step — and Studio has no row-hiding mechanism at all today. It is filed separately; the wider parity scope stays open. + +Every one of these is recorded, with the reason, in the oracle brief's **"NOT OBSERVED — do not guess these"** section. + +## Verified behavior + +_Matches Google Sheets:_ Google Sheets' Data ▸ Sort range is a submenu offering "Sort range by column X (A to Z)" — where X is the selection's FIRST column — the same "(Z to A)", and an "Advanced range sorting options" dialog (a "Data has header row" checkbox, a Sort-by column dropdown, an A-to-Z/Z-to-A radio pair, "Add another sort column", Cancel/Sort). The two quick rows sort the ENTIRE selection with no header inference — live-verified: sorting A1:C5 of a table whose row 1 held "Name/Score/Note" put "Name" last, among the data. Sorting reorders ONLY the selected rectangle: selecting one column of a three-column table and sorting it moved that column alone and left every other column of each row exactly where it was, with no warning and no "expand the selection?" prompt — the classic data-corrupting difference between "sort range" and a whole-row sort, and behaviour Studio replicates faithfully rather than second-guessing. Within the selection a row travels as a unit. Formulas move with their cell and their RELATIVE references translate by the row delta (a formula reading "=G2*2" in row 2 read "=G1*2" after moving to row 1) while `$`-anchored ones are left verbatim ("=$A$2" stayed "=$A$2") — the fill/copy translate, not the $-blind structural-edit rewrite. Ascending, a column holding 2, 10, a date, "apple", "Banana", TRUE, =1/0 and one empty cell ordered as: 2 · 10 · the date (by its serial, among the numbers) · apple · Banana (text compared case-INSENSITIVELY) · TRUE · #DIV/0! · blank. Descending gave the exact reverse of that non-blank run with the blank STILL last. A cell's FORMATTING travels with its row — live-verified: a bold cell moving from row 1 to row 3 was still bold there, and a date-formatted cell still rendered as a date in its new row. One Ctrl+Z restored the exact pre-sort order in a single step. This daily-driver slice ships exactly the two quick menu rows: no "Data has header row" option and no multi-column sort (the dialog's only two unique capabilities — and what the header checkbox does to the sorted OUTPUT was never observed, so it is not guessed at here), no Data ▸ Sort sheet, and no filter. Full parity is tracked separately; every unobserved edge is listed in the oracle brief's "NOT OBSERVED — do not guess these" section. + +#### Data ▸ Sort range ▸ A → Z reorders the selection and carries every selected column with each row + +**Given** +- **A1** = `delta` +- **B1** = `3` +- **A2** = `alpha` +- **B2** = `1` +- **A3** = `charlie` +- **B3** = `2` + +**When** I select **A1:B3**, then click menu item **data.sortRangeAsc** + +**Then** +- **A1** shows `alpha` +- **B1** shows `1` +- **A2** shows `charlie` +- **B2** shows `2` +- **A3** shows `delta` +- **B3** shows `3` + +#### Z → A sorts the same selection in the opposite order + +**Given** +- **A1** = `delta` +- **B1** = `3` +- **A2** = `alpha` +- **B2** = `1` +- **A3** = `charlie` +- **B3** = `2` + +**When** I select **A1:B3**, then click menu item **data.sortRangeDesc** + +**Then** +- **A1** shows `delta` +- **B1** shows `3` +- **A2** shows `charlie` +- **B2** shows `2` +- **A3** shows `alpha` +- **B3** shows `1` + +#### Sorting a ONE-COLUMN selection reorders that column alone and leaves the rest of each row behind + +**Given** +- **A1** = `delta` +- **B1** = `keep-1` +- **A2** = `alpha` +- **B2** = `keep-2` +- **A3** = `charlie` +- **B3** = `keep-3` + +**When** I select **A1:A3**, then click menu item **data.sortRangeAsc** + +**Then** +- **A1** shows `alpha` +- **A2** shows `charlie` +- **A3** shows `delta` +- **B1** shows `keep-1` +- **B2** shows `keep-2` +- **B3** shows `keep-3` + +#### A formula moves with its row and its RELATIVE reference translates to the new row + +**Given** +- **A1** = `30` +- **B1** = `=A1*2` +- **A2** = `10` +- **B2** = `=A2*2` +- **A3** = `20` +- **B3** = `=A3*2` + +**When** I select **A1:B3**, then click menu item **data.sortRangeAsc** + +**Then** +- **A1** shows `10` +- **A2** shows `20` +- **A3** shows `30` +- **B1** has formula `=A1*2` +- **B2** has formula `=A2*2` +- **B3** has formula `=A3*2` + +#### A $-anchored reference is NOT adjusted — it travels with its cell verbatim + +**Given** +- **A1** = `30` +- **C1** = `=$Z$1` +- **A2** = `10` +- **C2** = `=$Z$2` +- **A3** = `20` +- **C3** = `=$Z$3` + +**When** I select **A1:C3**, then click menu item **data.sortRangeAsc** + +**Then** +- **A1** shows `10` +- **A2** shows `20` +- **A3** shows `30` +- **C1** has formula `=$Z$2` +- **C2** has formula `=$Z$3` +- **C3** has formula `=$Z$1` + +#### Ascending puts numbers before text, text before booleans, booleans before errors, blanks last + +**Given** +- **A1** = `10` +- **A2** = `apple` +- **A4** = `TRUE` +- **A5** = `=1/0` +- **A6** = `2` +- **A7** = `Banana` + +**When** I select **A1:A7**, then click menu item **data.sortRangeAsc** + +**Then** +- **A1** shows `2` +- **A2** shows `10` +- **A3** shows `apple` +- **A4** shows `Banana` +- **A5** shows `true` +- **A6** shows `#DIV/0!` +- **A7** shows `null` + +#### Descending reverses that order but leaves blanks LAST, not first + +**Given** +- **A1** = `10` +- **A2** = `apple` +- **A4** = `TRUE` +- **A5** = `=1/0` +- **A6** = `2` +- **A7** = `Banana` + +**When** I select **A1:A7**, then click menu item **data.sortRangeDesc** + +**Then** +- **A1** shows `#DIV/0!` +- **A2** shows `true` +- **A3** shows `Banana` +- **A4** shows `apple` +- **A5** shows `10` +- **A6** shows `2` +- **A7** shows `null` + +#### ONE undo restores the exact pre-sort order, every column included + +**Given** +- **A1** = `delta` +- **B1** = `3` +- **C1** = `n-delta` +- **A2** = `alpha` +- **B2** = `1` +- **C2** = `n-alpha` +- **A3** = `charlie` +- **B3** = `2` +- **C3** = `n-charlie` + +**When** I select **A1:C3**, then click menu item **data.sortRangeAsc**, then click **A1**, then undo + +**Then** +- **A1** shows `delta` +- **B1** shows `3` +- **C1** shows `n-delta` +- **A2** shows `alpha` +- **B2** shows `1` +- **C2** shows `n-alpha` +- **A3** shows `charlie` +- **B3** shows `2` +- **C3** shows `n-charlie` + +#### Undo after a sort that moved formulas restores their original text too + +**Given** +- **A1** = `30` +- **B1** = `=A1*2` +- **A2** = `10` +- **B2** = `=A2*2` + +**When** I select **A1:B2**, then click menu item **data.sortRangeAsc**, then click **A1**, then undo + +**Then** +- **A1** shows `30` +- **A2** shows `10` +- **B1** shows `60` +- **B2** shows `20` +- **B1** has formula `=A1*2` +- **B2** has formula `=A2*2` + +#### Sorting a single-row selection changes nothing + +**Given** +- **A1** = `delta` +- **B1** = `alpha` + +**When** I select **A1:B1**, then click menu item **data.sortRangeAsc** + +**Then** +- **A1** shows `delta` +- **B1** shows `alpha` + +#### A cell's formatting travels with its row + +**Given** +- **A1** = `delta` +- **A2** = `alpha` + +**When** I click **A1**, then press `Control+b`, then select **A1:A2**, then click menu item **data.sortRangeAsc** + +**Then** +- **A1** shows `alpha` +- **A2** shows `delta` +- **A1** is bold=false +- **A2** is bold + +#### A date sorts by its serial among the numbers, and stays a date in its new row + +**Given** +- **A1** = `8/5/2026` +- **A2** = `10` + +**When** I select **A1:A2**, then click menu item **data.sortRangeAsc** + +**Then** +- **A1** shows `10` +- **A1** is numberFormat=false +- **A2** is numberFormat=date + +#### Sorting a range that overlaps a merge is refused, leaving the data untouched + +**Given** +- **A1** = `delta` +- **B1** = `anchor` +- **A2** = `alpha` +- **B2** = `keep-me` + +**When** I merge **B1:C1**, then select **A1:C2**, then click menu item **data.sortRangeAsc** + +**Then** +- **A1** shows `delta` +- **A2** shows `alpha` +- **B2** shows `keep-me` + +#### Data ▸ Create a filter is still disabled — this slice ships sort only + +**Given** + +**When** I click menu item **data.createFilter** + +**Then** +- `[data-menu-item="data.createFilter"]` is visible diff --git a/content/docs/learn/playground/spill-rendering.mdx b/content/docs/learn/playground/spill-rendering.mdx index 1603e3d..471d5e9 100644 --- a/content/docs/learn/playground/spill-rendering.mdx +++ b/content/docs/learn/playground/spill-rendering.mdx @@ -11,9 +11,9 @@ Type a dynamic-array formula — `=SEQUENCE(3)`, `=SEQUENCE(2,3)`, `=A1:A3*2` ## Verified behavior -_Matches Google Sheets:_ In Google Sheets a dynamic-array formula computes at its ANCHOR cell and "spills" the result across neighbouring cells. The anchor holds the formula and displays the array's top-left value; the other cells are read-only echoes of the result and carry no formula of their own. Orientation follows the array's shape — =SEQUENCE(3) spills DOWN a 3x1 column (A1:A3 = 1,2,3), =SEQUENCE(1,3) spills ACROSS a 1x3 row (A1:C1 = 1,2,3), and =SEQUENCE(2,3) fills a 2x3 block. When a cell within a spill cell is selected, Sheets outlines the whole spilled range with a thin blue "spill boundary". If any target cell is occupied the array cannot spill: the anchor shows #SPILL!, no values spill, and the blocking value is preserved. (As of @truecalc/workbook 3.2.0 the engine spills N x 1 arrays VERTICALLY, matching Sheets — the orientation parity gap tracked in truecalc/core#707 is resolved. The blocked-spill error is still #SPILL! in Sheets vs a generic error in the current engine. See the oracle brief.) +_Matches Google Sheets:_ In Google Sheets a dynamic-array formula computes at its ANCHOR cell and "spills" the result across neighbouring cells. The anchor holds the formula and displays the array's top-left value; the other cells are read-only echoes of the result and carry no formula of their own. Orientation follows the array's shape — =SEQUENCE(3) spills DOWN a 3x1 column (A1:A3 = 1,2,3), =SEQUENCE(1,3) spills ACROSS a 1x3 row (A1:C1 = 1,2,3), and =SEQUENCE(2,3) fills a 2x3 block. An operation over a source RANGE derives its orientation from that range (observed live in Sheets 2026-08-05, oracle obs. 6-9): =SORT(A1:A3), =UNIQUE(A1:A3) and =FILTER(A1:A3,...) over a vertical A1:A3 are all N x 1 and spill DOWN, while =TRANSPOSE(A1:A3) is the one that flips — a single-column source becomes a 1 x N ROW that spills ACROSS. Elementwise range arithmetic needs the explicit wrapper in Sheets: a bare =A1:A3*2 returns a SINGLE row-aligned value there, and =ARRAYFORMULA(A1:A3*2) is what yields the 3 x 1 column. TrueCalc's engine spills the bare form too — a deliberate engine divergence recorded in the oracle brief — and when it does, it uses the same vertical orientation Sheets gives the ARRAYFORMULA form. When a cell within a spill cell is selected, Sheets outlines the whole spilled range with a thin blue "spill boundary". If any target cell is occupied the array cannot spill: the anchor shows #SPILL!, no values spill, and the blocking value is preserved. (The orientation parity gap tracked in truecalc/core#707 is resolved as of @truecalc/workbook 7.x — every family above now matches Sheets' orientation; earlier engines emitted 1 x N rows for the derived single-column results and a column for TRANSPOSE, i.e. exactly backwards. The blocked-spill error is still #SPILL! in Sheets vs a generic error in the current engine. See the oracle brief.) -#### A single-column dynamic array spills DOWN a column (vertical, 3.2.0) +#### A single-column dynamic array spills DOWN a column **Given** @@ -64,3 +64,76 @@ _Matches Google Sheets:_ In Google Sheets a dynamic-array formula computes at it - **G6** shows `x` - **F5** shows `null` - **E6** shows `null` + +#### An elementwise op over a vertical range spills DOWN the column + +**Given** +- **E1** = `1` +- **E2** = `2` +- **E3** = `3` + +**When** I type `=E1:E3*2` into **G1** + +**Then** +- **G2** shows `4` +- **G3** shows `6` +- **H1** shows `null` +- **I1** shows `null` +- **G1** has formula `=E1:E3*2` + +#### SORT, UNIQUE and FILTER over a vertical range keep the column orientation + +**Given** +- **E1** = `3` +- **E2** = `1` +- **E3** = `3` + +**When** I type `=SORT(E1:E3)` into **G1**, then type `=UNIQUE(E1:E3)` into **G5**, then type `=FILTER(E1:E3,E1:E3>1)` into **G9** + +**Then** +- **G2** shows `3` +- **G3** shows `3` +- **G4** shows `null` +- **H1** shows `null` +- **I1** shows `null` +- **G6** shows `1` +- **G7** shows `null` +- **H5** shows `null` +- **G10** shows `3` +- **G11** shows `null` +- **H9** shows `null` +- **G1** has formula `=SORT(E1:E3)` +- **G5** has formula `=UNIQUE(E1:E3)` +- **G9** has formula `=FILTER(E1:E3,E1:E3>1)` + +#### TRANSPOSE flips the other way — a column source spills ACROSS a row + +**Given** +- **E1** = `1` +- **E2** = `2` +- **E3** = `3` + +**When** I type `=TRANSPOSE(E1:E3)` into **G1** + +**Then** +- **H1** shows `2` +- **I1** shows `3` +- **G2** shows `null` +- **G3** shows `null` +- **G1** has formula `=TRANSPOSE(E1:E3)` + +#### A value directly BELOW the anchor now blocks an elementwise spill + +**Given** +- **E1** = `1` +- **E2** = `2` +- **E3** = `3` +- **G2** = `note` + +**When** I type `=E1:E3*2` into **G1** + +**Then** +- **G2** shows `note` +- **G3** shows `null` +- **H1** shows `null` +- **G1** has formula `=E1:E3*2` diff --git a/content/docs/learn/playground/structure-insert-delete.mdx b/content/docs/learn/playground/structure-insert-delete.mdx new file mode 100644 index 0000000..177ba3f --- /dev/null +++ b/content/docs/learn/playground/structure-insert-delete.mdx @@ -0,0 +1,175 @@ +--- +title: "Insert & delete rows/columns" +description: "Insert.rows/Insert.columns/Edit ▸ Delete rows/Edit ▸ Delete columns shift cell content across the whole sheet and re-adjust every formula's own references, matching Google Sheets — the foundation primitive" +--- + +{/* GENERATED — do not edit by hand */} + +## What it does + +**Insert ▸ Rows** / **Insert ▸ Columns** and **Edit ▸ Delete ▸ Rows** / **Edit ▸ Delete ▸ Columns** insert or remove whole rows/columns relative to the current selection — matching Google Sheets: inserting puts as many blank rows/columns as are selected immediately before the selection's first row/column; deleting removes exactly the selected rows/columns. Every cell at/after the affected boundary shifts to its new address, and every formula's own references re-adjust to keep pointing at the same logical cells — including `$`-anchored ones, which shift here even though a copy/paste translate keeps them pinned (this isn't a copy; the referenced cell itself moved). A reference into a deleted row/column becomes `#REF!`; see the oracle brief for the exact (and non-obvious) rule for when a RANGE reference shrinks vs. collapses entirely. + +## Try it + +<TrueCalc mode="standalone" seed="A1=1;A2=2;A3=3;B1=Total;B2==SUM(A1:A3)" /> + +## Verified behavior + +_Matches Google Sheets:_ In Google Sheets, inserting N rows/columns shifts every cell at/after the insertion point down/right by N, and re-adjusts EVERY formula reference (anywhere in the workbook) that points at/after the boundary — including $-anchored references, which shift here even though a copy/paste translate would keep them pinned (a structural edit moves the referenced cell itself, it isn't a copy). A range reference straddling the boundary extends to include the inserted rows/columns. Deleting N rows/columns shifts everything after the deleted band back by N and drops content inside it; a single-cell reference into the deleted band becomes #REF!, and a range reference collapses to the literal whole-formula #REF! (not a shrink to the surviving corner, and not a nested #REF! inside the original function call) whenever EITHER of its two corners falls inside the deleted band — it shrinks normally only when the deleted band passes strictly through its interior, touching neither corner (live-verified; see this feature's oracle brief). RESOLVED GAP (was truecalc/core#716, fixed by core#754, shipped in @truecalc/workbook 6.0.0): the engine's formula parser previously rejected the `#REF!` error literal, so a formula whose TEXT collapsed to `=#REF!` (or contained it, e.g. `=#REF!+B1`) resolved to `null` — the cell painted BLANK, not the red `#REF!` Google Sheets shows. The parser now accepts the error-literal family as a value (the engine adapter's regression tests cover both the bare and compound cases), so this cell now renders the visible red `#REF!` text like every other error, matching Sheets. + +#### insertRows shifts content at/after the boundary down, leaves earlier rows untouched + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` + +**When** I insert 1 row(s) before row 2 + +**Then** +- **A1** shows `1` +- **A2** shows `null` +- **A3** shows `2` +- **A4** shows `3` + +#### insertRows re-adjusts a relocated formula's own out-of-band references + +**Given** +- **A1** = `10` +- **A5** = `50` +- **B3** = `=A1+A5` + +**When** I insert 1 row(s) before row 3 + +**Then** +- **B4** shows `60` +- **B4** has formula `=A1+A6` + +#### insertRows extends a range reference that straddles the boundary + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` +- **A4** = `4` +- **B1** = `=SUM(A1:A4)` + +**When** I insert 1 row(s) before row 3 + +**Then** +- **B1** has formula `=SUM(A1:A5)` + +#### insertColumns mirrors insertRows on the column axis + +**Given** +- **A1** = `1` +- **B1** = `2` + +**When** I insert 1 column(s) before column index 0 + +**Then** +- **A1** shows `null` +- **B1** shows `1` +- **C1** shows `2` + +#### deleteRows removes the band and shifts later content up + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` + +**When** I delete 1 row(s) starting at row 2 + +**Then** +- **A1** shows `1` +- **A2** shows `3` +- **A3** shows `null` + +#### deleteRows turns a single-cell reference into the deleted band into + +**Given** +- **A2** = `5` +- **B1** = `=A2` + +**When** I delete 1 row(s) starting at row 2 + +**Then** +- **B1** shows `#REF!` +- **B1** has formula `=#REF!` + +#### deleteRows shrinks a range reference when the deleted band is strictly interior + +**Given** +- **A1** = `1` +- **A2** = `2` +- **A3** = `3` +- **A4** = `4` +- **A5** = `5` +- **B1** = `=SUM(A1:A5)` + +**When** I delete 1 row(s) starting at row 3 + +**Then** +- **B1** has formula `=SUM(A1:A4)` + +#### deleteRows collapses a range reference to + +**Given** +- **A2** = `2` +- **A3** = `3` +- **B1** = `=SUM(A2:A3)` + +**When** I delete 1 row(s) starting at row 2 + +**Then** +- **B1** has formula `=#REF!` + +#### deleteColumns mirrors deleteRows on the column axis + +**Given** +- **A1** = `1` +- **B1** = `2` +- **C1** = `3` + +**When** I delete 1 column(s) starting at column index 1 + +**Then** +- **A1** shows `1` +- **B1** shows `3` +- **C1** shows `null` + +#### insertRows/deleteRows are undoable in one step + +**Given** +- **A1** = `1` + +**When** I insert 1 row(s) before row 1, then click **A1**, then undo + +**Then** +- **A1** shows `1` +- **A2** shows `null` + +#### the Insert menu's Rows item is active and inserts before the selection + +**Given** +- **A1** = `1` +- **A2** = `2` + +**When** I click **A1**, then click menu item **insert.rows** + +**Then** +- **A1** shows `null` +- **A2** shows `1` + +#### the Edit ▸ Delete ▸ Rows menu item is active and deletes the selected row + +**Given** +- **A1** = `1` +- **A2** = `2` + +**When** I click **A1**, then click menu item **edit.deleteRows** + +**Then** +- **A1** shows `2` +- **A2** shows `null` diff --git a/content/docs/learn/playground/undo.mdx b/content/docs/learn/playground/undo.mdx index 71edb7c..7493686 100644 --- a/content/docs/learn/playground/undo.mdx +++ b/content/docs/learn/playground/undo.mdx @@ -7,7 +7,7 @@ description: "Ctrl+Z reverts the last change; Ctrl+Y (or Ctrl+Shift+Z) re-applie ## What it does -Every edit — typing a value, filling, pasting, clearing — is one undoable step. Press **Ctrl+Z** to revert the last step and **Ctrl+Y** (or **Ctrl+Shift+Z**) to re-apply it. A fresh edit after undoing clears the redo history, exactly like Google Sheets. +Every edit — typing a value, filling, pasting, clearing — is one undoable step. Press **Ctrl+Z** to revert the last step and **Ctrl+Y** (or **Ctrl+Shift+Z**) to re-apply it — or click the toolbar's **Undo**/**Redo** buttons, the first two controls, same as Google Sheets. Both grey out when there's nothing left to undo/redo. A fresh edit after undoing clears the redo history, exactly like Google Sheets. ## Verified behavior diff --git a/content/docs/learn/playground/view-workbook.mdx b/content/docs/learn/playground/view-workbook.mdx index 321dbc4..3b6c7d2 100644 --- a/content/docs/learn/playground/view-workbook.mdx +++ b/content/docs/learn/playground/view-workbook.mdx @@ -17,7 +17,7 @@ Every cell with content shows its computed value on the canvas grid, laid out th _Matches Google Sheets:_ In Google Sheets, a cell's displayed value is type-driven by default: numbers and dates align to the right of the cell, text (and blank/unrecognized content) aligns to the left — matching the same automatic rule Sheets uses before any explicit alignment override is applied. A formula that errors (e.g. "=1/0") shows its error code ("#DIV/0!") in place of a value, rendered in a visually distinct (red) color so it reads as an error at a glance rather than as ordinary text. Sheets only ever draws/lays out the rows and columns in or near the viewport — scrolling a sheet with thousands of rows stays smooth because off-screen cells are never rendered. -#### A literal number renders with no explicit alignment override (right-aligned by the type-based default — see How it works) +#### A literal number renders with no explicit alignment override (right-aligned by the type-based default) **Given** - **A1** = `42` @@ -27,7 +27,7 @@ _Matches Google Sheets:_ In Google Sheets, a cell's displayed value is type-driv **Then** - **A1** shows `42` -#### A literal text value renders with no explicit alignment override (left-aligned by the type-based default — see How it works) +#### A literal text value renders with no explicit alignment override (left-aligned by the type-based default) **Given** - **A1** = `hello` diff --git a/content/docs/learn/playground/views.mdx b/content/docs/learn/playground/views.mdx new file mode 100644 index 0000000..a058516 --- /dev/null +++ b/content/docs/learn/playground/views.mdx @@ -0,0 +1,147 @@ +--- +title: "Freeze rows & columns · Zoom" +description: "View ▸ Freeze pins the first N rows and/or columns so they stay under the headers while the rest of the grid scrolls, with a divider marking the split; View ▸ Zoom scales the whole grid surface — cell pitch, cell text, the header strips and any user-resized row/column — to one of seven presets.\n" +--- + +{/* GENERATED — do not edit by hand */} + +## What it does + +**View ▸ Freeze** pins the top rows and/or the leftmost columns of the sheet so +they stay put while the rest scrolls — the way you keep a header row visible +over a long table. The submenu offers the same eight choices Sheets does: + +- **No rows** · **1 row** · **2 rows** · **Up to row N** +- **No columns** · **1 column** · **2 columns** · **Up to column X** + +`N` and `X` follow the active cell, so "Up to row 5" is how you freeze more +than two. Frozen rows and frozen columns are independent; apply both and the +top-left block stays fixed on both axes. A grey divider marks each split, drawn +across the header strip as well as the cells. A freeze is part of the +document — it is saved with the workbook and comes back on reload. + +**View ▸ Zoom** scales the grid to **50%**, **75%**, **90%**, **100%**, +**125%**, **150%** or **200%**. It scales the grid surface only — cell sizes, +cell text, the row-number gutter, the column-header strip, and any row or +column you resized yourself, all by the same factor. The menu bar, toolbar and +formula bar are untouched: this is not a browser zoom, and it changes nothing +about the document. The current level carries a check in the menu. Unlike a +freeze, zoom is **per session**: reload and you are back at 100%, matching the +reference behavior recorded in the oracle brief. + +## Try it + +<TrueCalc mode="standalone" seed="A1=Region;B1=Q1;A2=North;B2=10;A3=South;B3=20" /> + +Open **View ▸ Freeze ▸ 1 row** and scroll — row 1 stays put. Then try +**View ▸ Zoom ▸ 150%** and watch the headers scale with the cells. + +## Verified behavior + +_Matches Google Sheets:_ Observed live in the reference workbook (this feature's oracle brief, studied 2026-08-05). View ▸ Freeze is a one-level submenu of exactly eight rows in two separator-divided groups — "No rows / 1 row / 2 rows / Up to row N" and "No columns / 1 column / 2 columns / Up to column X" — where N and X track the ACTIVE CELL and update live as the selection moves. The submenu shows no current-state marker at all: with two rows and two columns already frozen, none of the eight rows carries a check, a radio dot or aria-checked. Frozen rows do not scroll: with 2 rows frozen and the body paged down to row 70, rows 1 and 2 stayed pinned directly under the column headers, keeping their real row-header numbers; frozen columns behave identically on the other axis, and with both applied the top-left block stays fixed under scrolling on both axes. The split is drawn as a solid #C7C7C7 band 4 CSS px thick that crosses the header strips as well as the cell area — the horizontal one spans the full grid width including the row-number gutter, the vertical one the full height including the column-header strip — with the sheet's ordinary 1px #E1E1E1 gridline still rendered immediately before it. A freeze SURVIVES a page reload: it is document state. View ▸ Zoom is a second one-level submenu of exactly seven fixed presets — 50%, 75%, 90%, 100%, 125%, 150%, 200%, with no "Custom…" row anywhere (the toolbar's own zoom dropdown lists the identical seven) — and unlike Freeze it DOES mark the current level with a ✓. Zoom scales the grid surface and only the grid surface, proportionally: measured at 75% against 100%, the row-number gutter went 45.5 → 34 CSS px, a user-resized column 26 → 19.5, the row pitch ≈21 → ≈16, and the header glyphs shrank with them, while the menu bar, toolbar, formula-bar row and sheet-tab strip were pixel-identical. Zoom applies to the frozen panes too, and the freeze divider's thickness scales with it (4 → 3 CSS px at 75%) though its colour does not. Zoom does NOT survive a page reload — a workbook left at 75% came back at 100% in the very same reload that restored the freeze intact. It changes no cell value, formula or format. A second pass measured three more things the first one had left unobserved: "Up to row N" freezes INCLUSIVELY (active cell on row 3, "Up to row 3" clicked, divider landed below row 3 with row 4 the first scrolling row); freeze is PER SHEET (Sheet1 frozen 3 deep, Sheet2 showed no divider at all); and zoom above 100% scales by exactly the same single factor as below it (at 150%: gutter 90 → 135 device px, row pitch 42 → 63, a resized column 54 → 81, divider 8 → 12 — ×1.5 in every case). + +#### A frozen row stays under the column headers while the body scrolls away from it + +**Given** +- **A1** = `header` + +**When** I click menu item **view.freeze.rows1**, then select **A60**, then type `still A1` into **A1** + +**Then** +- **A1** shows `still A1` + +#### Control: with no freeze, that same screen position is NOT A1 once the body has scrolled + +**Given** +- **A1** = `header` + +**When** I select **A60**, then type `not A1` into **A1** + +**Then** +- **A1** shows `header` + +#### Frozen columns pin the left edge while the body scrolls right + +**Given** +- **A1** = `left edge` + +**When** I click menu item **view.freeze.columns2**, then select **Z1**, then type `still A1` into **A1** + +**Then** +- **A1** shows `still A1` + +#### "No rows" releases the freeze again + +**Given** +- **A1** = `header` + +**When** I click menu item **view.freeze.rows1**, then click menu item **view.freeze.noRows**, then select **A60**, then type `not A1` into **A1** + +**Then** +- **A1** shows `header` + +#### "Up to row N" freezes INCLUSIVELY, through the active cell's own row + +**Given** +- **A1** = `header` +- **A2** = `subhead` + +**When** I select **A2**, then click menu item **view.freeze.upToRow**, then select **A60**, then type `still A2` into **A2** + +**Then** +- **A2** shows `still A2` + +#### A freeze survives a reload — it is document state, not session state + +**Given** +- **A1** = `header` + +**When** I click menu item **view.freeze.rows1**, then wait 1200ms, then reload the page, then select **A60**, then type `still A1` into **A1** + +**Then** +- **A1** shows `still A1` + +#### Zoom below 100% shrinks the grid's cell pitch proportionally + +**Given** + +**When** I click menu item **view.zoom.75** + +**Then** +- column **A** is exactly 75px wide +- row **1** is exactly 16px tall + +#### Zoom above 100% grows it by the same single factor + +**Given** + +**When** I click menu item **view.zoom.150** + +**Then** +- column **A** is exactly 150px wide +- row **1** is exactly 32px tall + +#### Zoom changes no value, formula or result + +**Given** +- **A1** = `2` +- **B1** = `=A1*3` + +**When** I click menu item **view.zoom.75** + +**Then** +- **A1** shows `2` +- **B1** shows `6` +- **B1** has formula `=A1*3` +- column **A** is exactly 75px wide + +#### Zoom resets to 100% on reload — it is session state, not document state + +**Given** +- **A1** = `kept` + +**When** I click menu item **view.zoom.75**, then wait 1200ms, then reload the page + +**Then** +- **A1** shows `kept` +- column **A** is exactly 100px wide