Skip to content

fix(react): the clipboard carries only real data columns - #495

Merged
blove merged 3 commits into
mainfrom
claude/clipboard-spreadsheet-shape
Aug 27, 2026
Merged

fix(react): the clipboard carries only real data columns#495
blove merged 3 commits into
mainfrom
claude/clipboard-spreadsheet-shape

Conversation

@blove

@blove blove commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

The bug

Pasting from Excel into a grouped grid lost the user's first column.

Grouping prepends a synthetic column (__pretable_group__) that the surface derives for the label, twisty and child count. Both clipboard sides treated it as a real field, so it held a paste slot Excel knows nothing about: a spreadsheet hands over N values for the N columns a user can actually write, and we tiled them across N+1 targets. The first value landed in a column nothing can be written to, was rejected as not-editable, and every other value shifted one column right.

The decision

The clipboard is a spreadsheet interchange format, not an internal round-trip format. Where the two conflict, the spreadsheet wins. See docs/superpowers/specs/2026-08-26-clipboard-spreadsheet-shape-design.md.

So the clipboard carries only real data columns, and a group row puts its label in the leftmost column of the copied range — of the range, not of the grid — with aggregates in their own columns:

Technology<TAB><TAB><TAB>1240000

That is what Excel's Subtotal and Google Sheets' pivot output produce, so a pasted block reads as native. Accepted cost, taken deliberately: when that column is numeric a text label lands in it. It is a header row and spreadsheets tolerate it.

Both sides, together

#485 measured that removing paste's slot alone inverts the damage — a copy-then-paste of a grouped row silently blanks column a and shifts right. So this changes every emitter and the target space in one commit, behind a single shared predicate (isSyntheticColumnId):

  • copy.ts — filters the group column out of dataColumns; group label at c === colLo; a range bound on either synthetic column resolves to the first data column.
  • csv.ts — same filter on its separate emit path; label in the first exported column.
  • paste.ts — the group column no longer occupies a target slot, and an anchor landing on it re-anchors to the first data column, the way a row-select anchor already did.
  • pretable-surface.tsx (resolvePasteAnchor) — measures the selection over the same column space. Without this a whole-row grouped selection came out one column wider than anything could be written to and announced a phantom "clipped to fit".
  • Docs: clipboard.mdx (new Group rows section), paste.mdx, export.mdx, grouping.mdx.

The group row is no longer dropped when it produces no field: it always carries a label, so a copied block is rectangular over every row it spans.

Proof

  • The Excel case, end to end: a grouped PretableSurface, focus on the leftmost cell, three values pasted for the three writable columns → one value per column, rejected empty, and no synthetic id anywhere in the payload.
  • The existing guard (paste-map.test.ts) is kept as written — it passes when both sides carry the column or neither, and fails only when they disagree. Its range now bounds on the row-select column (the whole-row copy), which is the bound that actually spans the synthetic space, so it catches a one-sided change to either emitter. Verified by mutation in both directions.
  • Eight negative controls, one per behavioural line: each reverted alone, each turns a specific assertion red, each restored.
  • Ungrouped output is byte-identical, proven rather than asserted: the pre-change modules were vendored in and diffed against the new ones over 800 copy bound/row combinations, the CSV option matrix, and the paste anchor/shape matrix. That harness found and fixed one real ungrouped divergence before it shipped (a row-select-to-unknown-column range).

Full gate green: build, typecheck, lint, format, test, plus api:check.

🤖 Generated with Claude Code

blove and others added 2 commits August 26, 2026 17:24
Pasting from Excel into a grouped grid lost the user's first column. The
synthetic `__pretable_group__` column held a paste slot, so N values for N
visible columns tiled across N+1 targets: the first landed in a column
nothing can be written to, and every other value shifted one right.

The clipboard is a spreadsheet interchange format, not an internal
round-trip format. Both sides now filter the same predicate
(`isSyntheticColumnId`), and a group row puts its label in the leftmost
column of the copied range with aggregates in their own — the shape
Excel's Subtotal and Sheets' pivot output produce.

Both sides together, deliberately: #485 measured that removing paste's
slot alone silently blanks column `a` on a copy-then-paste and shifts the
rest right. `paste-map.test.ts` guards the coupling and its range is now
bounded on the row-select column, so it catches a one-sided change to
either emitter.

Also re-anchors a range bound or paste anchor that lands on the group
column to the first data column, in copy, paste, and the surface's anchor
resolver — without it a whole-row grouped selection measured one column
wider than anything could be written to and announced a phantom clip.

Ungrouped output is byte-identical: proven against the pre-change modules
over 800 copy bound/row combinations, the CSV option matrix, and the paste
anchor/shape matrix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 27, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
pretable Ignored Ignored Aug 27, 2026 1:21am

Request Review

@blove
blove enabled auto-merge (squash) August 27, 2026 00:36
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-2quvkaogz-cacheplane.vercel.app
Commit: cc01a25c6c713e696f5b346cc4934271d42487e1

Updated automatically by the deploy-preview job.

The synthetic group column no longer occupies a clipboard field, so a
grouped copy is N fields wide, not N+1. Data rows lose the empty lead;
group rows keep their label, now in the leftmost real column.

These pinned the pre-change shape and are the CI half of the same
both-sides change — the website Playwright smoke suite is not part of
`pnpm test`, so it passed locally and failed against the preview.
@blove
blove merged commit 48735be into main Aug 27, 2026
20 checks passed
@blove
blove deleted the claude/clipboard-spreadsheet-shape branch August 27, 2026 01:38
blove added a commit that referenced this pull request Aug 27, 2026
…carries

summarizeSelection filtered only the row-select column, so while grouped it
counted the derived group column too — the sidebar claimed one more column
than Cmd+C actually copies. Same defect the library seams had, and it is
directly in the Excel-interop story: 'N x 3 selected' next to a two-column
paste.

Two tests pinned the over-count and are corrected, not weakened:
- selection.test.ts asserted cols: 3 and its comment claimed the group
  column 'is inside the rectangle Cmd+C copies'. Rows and columns are not
  symmetric: group ROWS carry labels and aggregates and do copy; the group
  COLUMN is presentation and was dropped from copy/CSV/paste in #495.
- grouping.spec.ts derived its expectation as headerCells - 1, subtracting
  only the selector. It now counts headers carrying a data-pretable-column-id
  and excludes the group column, so it still fails if a hero column is added.

Negative control: reverting the filter fails selection.test.ts.
blove added a commit that referenced this pull request Aug 27, 2026
… said it was (#497)

* chore: stop a failing package hiding the rest of the suite, and strike a done roadmap line

pnpm -r bails on the first failing package by default, so one timeout in an
early package silently skipped every package after it — a full run once
never reached core, renderer-dom, react or the apps at all, while the
failure read as a single known flake. --no-bail runs them all and STILL
exits 1 (measured on pnpm 10.12.1; pnpm's own docs claim otherwise). Left
on the build step, where a failure should stop everything.

Residual: the three phases are still chained with &&, so a package-test
failure keeps the apps from running. Fixing that needs exit-code collection,
not a flag.

Roadmap: "public API/documentation gaps + stable row identity" is done.
All four .api.md reports are at zero ae-forgotten-export; getRowId is
required at every entry point with its index parameter removed (#293); and
four fail-closed guards cover API surface (#290), docs tables (#280),
engine reconciliation (#266) and facade forwarding (#301).

* fix(react): the group column is not a data column, and the seams that said it was

Grouping's boundaries with pinning, editing and streaming had no tests at all,
which is where all three prior grouping correctness rounds came from (#259,
#264, #495). Probing those three seams found two live defects, both the #495
shape: a consumer-facing "data column" set that filters only the row-select
checkbox instead of `isSyntheticColumnId`. Ungrouped the two predicates are the
same test, so each site looked correct for as long as nothing was grouped.

1. Announced column counts described a grid one column wider than the artifact.
   Grouped, Cmd+A then Cmd+C put 2 columns on the clipboard and announced 3;
   `exportCsv` wrote a 2-column file and announced 3. `copy.ts`, `csv.ts` and
   `paste.ts` all drop both synthetics, so the announcement — a statement ABOUT
   that clipboard or that file — has to drop them too. `computeSelectionExtent`
   now resolves spans in DATA ordinals with a group-column bound collapsing to
   data column 0, byte-for-byte `resolveRangeBounds`.

2. `onSelectedRowIdChange` went silent while grouped. A range over every real
   data column is a full row, but `singleFullRowSelection` was handed the drawn
   list, whose first entry is the synthetic group column — so the range never
   matched and a consumer's "open the selected record" wiring stopped firing the
   moment the user grouped.

Tests: grouping boundaries added to the three suites that had none —
right-pin (drawn order, pinned insets, grouping BY a right-pinned column,
`groupColumn: {pinned:"left"}`, group-row alignment, drawn `aria-colcount`),
editing (commit inside a group, group rows refuse an editor, an edit cancels
when grouping hides its column or collapses its row, a commit that re-paths
into a collapsed group settles), and streaming (selection/focus survive an
in-place update and a key re-path, expansion and aggregates track, a removed
focused row clears focus, an in-flight editor survives a re-path). Every one
runs a grouped leg against an ungrouped control.

Editing and streaming were probed and found CORRECT — those tests pin
behaviour rather than fix it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(website): the hero's selection summary counts what the clipboard carries

summarizeSelection filtered only the row-select column, so while grouped it
counted the derived group column too — the sidebar claimed one more column
than Cmd+C actually copies. Same defect the library seams had, and it is
directly in the Excel-interop story: 'N x 3 selected' next to a two-column
paste.

Two tests pinned the over-count and are corrected, not weakened:
- selection.test.ts asserted cols: 3 and its comment claimed the group
  column 'is inside the rectangle Cmd+C copies'. Rows and columns are not
  symmetric: group ROWS carry labels and aggregates and do copy; the group
  COLUMN is presentation and was dropped from copy/CSV/paste in #495.
- grouping.spec.ts derived its expectation as headerCells - 1, subtracting
  only the selector. It now counts headers carrying a data-pretable-column-id
  and excludes the group column, so it still fails if a hero column is added.

Negative control: reverting the filter fails selection.test.ts.

* test(react): poll the header assertion that races setQuery's settle

external-filter-authority polled for the rows to settle, then asserted the
header state bare. setQuery settles asynchronously across cooperative slices
(#321), so the rows can be drawn a slice before the header reports — under
load the bare assertion loses that race and reads ariaSort 'none' /
filterActive 'false'.

Not caused by this branch: three full react runs on it produced three
different outcomes (eviction-population-change, then this, then 1457 green),
which is the signature of a load race rather than a regression. Both suspects
pass 3/3 in isolation.

Surfaced now because --no-bail (this branch) stops a failing package hiding
the ones after it, so more of the suite actually runs.

* test(website): drive the stubbed clock inside the wait it gates

HeroGrid's replay test stubs requestAnimationFrame, so the replay only
advances when a frame is flushed by hand. It flushed exactly two, then
waitFor'd for an aggregate to change. If those two did not move THAT
sector's value, the retry loop span against a frozen clock until timeout —
waiting cannot rescue a value nothing is ticking. Each attempt now flushes
another frame, so the loop advances the thing it waits on.

HONEST LIMIT: I could not reproduce the CI failure locally to prove this
fixes it. The old version passes 6/6 at load ~14; the observed failures
were at load 30-50. So this is reasoned, not demonstrated — it removes a
real mechanism that can produce exactly this symptom, and is strictly more
correct given rAF is stubbed, but it is not a verified fix for that run.

NOT caused by this branch, proven structurally rather than statistically:
the failing assertions read summary-nav/summary-pnl, which come from
buildModel(rows) in PortfolioSummary and depend only on rows. This branch
changed summarizeSelection, whose output feeds a sibling SelectionSection.
There is no path from the change to those elements.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant