Skip to content

feat: the tool panel — rail, pane, and the columns section (SP1) - #486

Merged
blove merged 29 commits into
mainfrom
blove/tool-panel-sp1-shell-columns
Aug 25, 2026
Merged

feat: the tool panel — rail, pane, and the columns section (SP1)#486
blove merged 29 commits into
mainfrom
blove/tool-panel-sp1-shell-columns

Conversation

@blove

@blove blove commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

What this is

The tool panel, SP1 of three: a vertical icon rail docked at the grid's right edge, opening a full-height pane — shipped on by default on PretableSurface and the <Pretable> preset, with its first section, Columns: visibility toggles, search, reset, a per-row pin menu, and drag + keyboard reorder. SP2 (the filter builder over the typed operator model) and SP3 (grouping config) slot into the same section contract.

Spec: docs/superpowers/specs/2026-08-24-tool-panel-design.md. Plan: docs/superpowers/plans/2026-08-24-tool-panel-sp1-shell-columns.md.

The one engine change: column visibility

It didn't exist. hidden?: boolean now lives on columnLayout entries — hidden columns keep their position, width, and pin state (re-showing restores them) and are filtered out at the drawn-order projections, so the seven span-resolving consumers (copy, paste, selection, announcements…) inherit correctness from the single source of truth they already read. setColumnVisible repairs focus, selection anchor, and open edit sessions to the nearest visible neighbor; keyboard navigation skips hidden columns; beginEdit refuses them. The public column-order vocabulary is uniformly drawn: onColumnOrderChange, state.columnOrder, moveColumn, getColumns() — hidden ids exist only in the engine layout, spliced back in at the two write paths (each with its own documented semantic, both test-pinned against unification).

Design notes

  • The shell is a vertical tablist — roving tabindex, one Tab stop, arrows move focus without activating, Escape in the pane returns to the tab. The WCAG tab-exit guard (grid-tab-wrap-rows.spec.ts) learned the rail as a named, asserted stop: one press past it must land outside the grid, so a second stop or a trap fails loudly.
  • Chrome state is React-owned; every operation is an engine method. The engine doesn't know the panel exists — headless consumers carry zero panel surface.
  • Zero new tokens. Rail/pane/rows reuse the 50-token contract; the kebab menu reuses the header menu's popover contract wholesale (zero new menu CSS) and runs on useHeaderPopover, so it follows its row through scroll and closes when the anchor is gone. The card chrome (border/radius/shadow) moved from the viewport to the new layout wrapper so the rail sits inside the card — comment-guarded in both directions, pixel-proven under all four theme states.
  • Drag commits on drop, never drag-leave; Escape cancels (mirroring both existing drag surfaces). The drop resolves from the pointerup event's own coordinates and capture is taken at pointerdown — both fixes came out of real cross-engine races the e2e reproduced (pointerup outrunning the last coalesced pointermove; the first delivered move landing outside a 16px grip).
  • Keyboard reorder is Shift+ArrowUp/Down on the focused grip (the chip strip's chord, verticalized); crossing a subgroup boundary is pin-only, and the engine's stable partition provably lands the row at the near edge.

Review process

Every task ran implementer → spec review → quality review, with mutation testing throughout (~40 mutations across guards, geometry, capture timing, and a11y). Defects caught and fixed before merge include: unpinning a hidden column revealing it, setColumns dropping visibility-only updates, a selected-tab hover clobber via the background shorthand, the pin menu drifting on pane scroll, the two pointer races above, and reducedMotion silently ignored as a top-level Playwright option (which would also have red-X'd the build gate).

Verification

Gate Result
grid-core / core / react / ui / website unit 147 · 7 · 1309 · 90 · 558 — all green
typecheck · lint · prettier · api:check clean, reports fresh
Website e2e (full, prod build, chromium+webkit) 304 passed, 0 failed (4 designed skips)
Bench cascade gate 4/4
Pixel proof rail = resolved --pretable-bg-header in light/dark/excel/material; card chrome on the wrapper, none doubled on the viewport; pane at 264px

Changesets: @pretable/core minor, @pretable/react + @pretable/ui minor. Rebased onto 2ad7feea (#485's copy/paste coupling test passes against the drawn-order change).

Known follow-ups (deliberate, not oversights)

  • The section's strings ("Pinned left", "Search columns", menu items…) are hardcoded English — the messages-system gap is noted in-code; worth one pass when SP2 adds more strings.
  • Bench S2 DOM-count comparability: the rail now renders in the bench pretable adapter (default-on is as-shipped), adding a handful of nodes vs TanStack — negligible against the existing 100-vs-294 gap, but worth knowing before the next comparative run.
  • SP2 pressure points flagged by review: extract a shared menu-keyboard hook at the third role="menu", and usePendingFocus at the third pending-focus trio.

🤖 Generated with Claude Code

blove and others added 29 commits August 24, 2026 19:55
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…epair comments

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Hiding a column now also cancels an open edit session addressing it —
the same repair setColumns applies when the edited column leaves the
layout — and beginEdit refuses a hidden target outright.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The drawn projection, the facade's getColumns and mergeRenderColumns all
filter hidden layout entries, so every span consumer — copy, paste,
selection, CSV, announcements — inherits correctness from one place.
moveColumn takes a DRAWN-space index and splices hidden ids back into
the engine's full roster by visible neighbor; the controlled
state.columnOrder write-back accepts the drawn vocabulary and does the
same splice. The full layout stays reachable via getState().columnLayout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s; drawn-vocabulary comments

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rules only — the React components land in later tasks, so these target
data-pretable-tool-* attributes nothing emits yet. Four structural guards
pin the surfaces (rail=header, pane=toolbar), the token contract, the
no-opacity dimming rule, and the element roster; each was mutation-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s for the row controls

The selected-tab rule follows the hover rule at equal specificity, and its
background SHORTHAND was resetting background-image — no hover tint on the
open tab. Now background-color, with a guard pinning it (mutation-tested).
The row grip and kebab get the header ⋮'s coarse-pointer ::after hit-area
treatment, centred because they have no neighbouring target to steal from.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The surface renders [grid stack][pane?][rail] in one flex row wrapped by
[data-pretable-tool-layout], which takes over the card chrome from the
viewport (grid.css redistributes border/radius/shadow, the same dance the
group-panel wrapper does vertically). toolPanel?: boolean | config, default
true; activeSection present makes the open section controlled, else internal
state seeded by defaultActiveSection. The rail tablist gets its accessible
name through the surface's messages layer (toolPanelLabel), and
setColumnVisible joins the typed PretableReactGrid handle, retiring the
documented cast in the column-order tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tool panel's placeholder becomes the real columns section: the full
columnLayout roster (hidden entries included and dimmed), subgrouped
Pinned left / Columns / Pinned right, with visibility toggles wired to
setColumnVisible, case-insensitive label search that hides emptied
subgroup labels, and a reset that replays the surface-mount layout
(pin, then visibility, then the full-roster order). The grip and kebab
complete the row anatomy but stay inert for Tasks 8 and 9.

The section subscribes to the engine's layout slice itself via
useSyncExternalStore, so the surface's descriptor memo closes over
stable handles only — the Task 6 review's stale-closure trap stays
fixed, and the memo's handoff comment now says why the deps are safe.

Riders from that review: a composition test pinning the group-panel
wrapper inside the tool layout's grid area, and the css-cascade guard
now asserts overflow:hidden on the wrapper and border-radius:0 on the
inner viewport (both mutation-verified).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reset's roster-drift branches — splicing an id added since the
surface's capture back at its current position, and filtering out an id
removed since — were reachable (the group column joins the layout
mid-session) but untested. Two tests now pin them, each proven by
mutation: append-instead-of-splice and delete-the-splice both fail the
added-column test (the pre-reset order shuffles the initial ids too,
because with them already in place the order replay is a no-op and a
deleted splice was undetectable), and dropping the removed-ids filter
fails the removed-column test (setColumnOrder throws invalid-ui-state
on the stale id).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The kebab on each columns-section row now opens a portaled role=menu with
Pin left / Pin right / Unpin (current state disabled), reusing the header
ColumnMenu's popover attribute contract so grid.css styles it with zero
new rules. Escape preventDefaults so the pane's rail-tab focus yank skips
it and focus returns to the kebab; selecting refocuses the (remounted)
kebab through a per-id node map. Also: an empty-search state line, and
the surface's indexedGrid now feeds ColumnsSection with no cast at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An inline (unportaled) menu survived every existing test: jsdom cannot
see that the viewport's contain:content would clip a position:fixed
popover, so the menu's DOM location is the enforceable proxy. The open
test now asserts the menu parents in document.body and NOT in the pane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The menu's anchor rect was frozen at open, so wheel-scrolling the pane's
list box left the position:fixed menu drifting — wheel fires neither
pointerdown nor a focus change, so nothing closed it. The section now
reuses useHeaderPopover (the header popovers fought exactly this): the
menu re-measures on capture-phase scroll/resize, follows its kebab, and
closes when the anchor is genuinely gone. Searching the open row out now
clears the menu STATE at its source (the input's onChange), so clearing
the search cannot remount a zombie menu at a stale rect; the render
guard now looks up the rendered (roster+search filtered) list and its
comment says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure move ahead of Task 9's drag machinery, per the Task 8 review's
file-size note. No behavior change; the 32 tool-panel tests stay green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pointer drag on the grip commits on drop, never mid-move: the pure
dropTargetForPointer(y, rowRects, groups) resolves the pointer to an
insertion slot + target subgroup (a group gap splits at its middle, so
the same slot exists as end-of-group-above and start-of-group-below
with different pins), and the drop pins first, then writes the FULL
setColumnOrder roster with only the moved id relocated. The commit
reads the live target from a ref, not the render closure — Chromium's
pointerup can outrun the final move's re-render.

Keyboard alternative (a11y gate): the grip is a focusable span
(role=button, tabIndex=0 — a native button would drag UA chrome into
the [data-pretable-tool-row-grip] CSS) answering Shift+ArrowUp/Down,
the group-chip strip's chord turned vertical; crossing a rendered
subgroup boundary re-pins via the engine's stable pin regroup. Ends
no-wrap no-commit, focus follows the row through fragment remounts.

11 unit tests pin the geometry (jsdom cannot express rects), 9 jsdom
tests pin the keyboard path + drag attributes, and the new Playwright
spec proves drag, cross-boundary re-pin, the rail/pane tab walk, and
the Task 6 minWidth:0 pin on a real page in both engines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t drags cannot drop

Three review findings, one systematic-debugging arc:

- Capture moves to pointerdown (GroupPanel's rule): engines rAF-coalesce
  pointermoves, so on a ~14px handle the first DELIVERED move can already
  be outside the grip and a capture taken in the move handler never
  happens. The 5px threshold still gates the dragging STATE. Pinned by a
  jsdom spy on setPointerCapture (jsdom has no real capture, so the spy
  is the only in-process signal; mutation-checked — late capture fails
  exactly that test).
- The drop now resolves from the pointerup event's OWN coordinates,
  measured at release: both engines routinely coalesce away the last
  pointermove before a quick release, so any target tracked during the
  moves can be one step behind where the user let go — probed as a
  cross-boundary drop landing on the wrong side of the subgroup split in
  Chromium and WebKit alike.
- Escape mid-drag cancels without engine mutation — the rule both other
  drag surfaces already follow (header reorder, chip drag); tested.

New jsdom coverage: ArrowDown off Pinned-left lands FIRST of the
unpinned group; a cross-boundary move under an active search leaves
filtered-out ids in place; Escape-cancel; capture timing.

The e2e spec's residual flake was the DOCS PAGE, not the component
(probed: the site smooth-scrolls every in-page jump, and an intermittent
focus-reveal scroll fires ~300ms after the rail click — coordinates
measured mid-animation are stale by press time). The spec now declares
prefers-reduced-motion (the site's own gate for instant scrolling),
waits for layout stability before pressing, verifies drag arming with a
bounded re-acquire, and measures travel targets after arming. 60
consecutive e2e tests green on a load-40 box.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Playwright 1.62.1 has no top-level reducedMotion test option: next
build's typecheck fails on the bare key (TS2353, which would red-X the
required website build gate) and at runtime the unknown key is silently
ignored — the previous green sweep was carried by the other hardenings
with smooth scroll still active. contextOptions reaches
browser.newContext, so the emulation actually applies now.

Also trims an overclaiming comment: the Escape-cancel's preventDefault
only interlocks the pane's handler while focus sits outside the pane
(document bubble listeners run after the React-root handler) — the chip
drag's cancel shares the characteristic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The consumer-facing half of Tool Panel SP1. The <Pretable> preset gains
the toolPanel passthrough (default-on already reached it — the default
lives in the surface — so the new prop is the ability to disable or
configure), with a test proving the rail renders by default and
toolPanel={false} removes it. react.api.md regenerated for the new
PretableBaseProps member.

New grid/tool-panel.mdx documents the default-on stance, the
PretableToolPanelConfig contract, the columns section's operations, and
the verified keyboard reference, led by a live example whose
defaultActiveSection opens the Columns pane on load. The docs guard's
rosters register the page's Configuration table (bound complete to
PretableToolPanelConfig, with Type and Required columns checked) and
the ToolPanelSectionId enumeration. pretable-component.mdx's
Limitations paragraph now tells the truth about the one piece of
config UI that ships on by default, pretable-surface.mdx's
Configuration table gains the Tool panel row, and the sidebar lists
the page after Column layout.

Rider from Task 9's review: the e2e keyboard-chord test now asserts
the initial header order before the press, so roster drift cannot make
its post-press assertion pass vacuously.

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

Three accuracy fixes from spec review. The keyboard table's Escape row
now states the layered-dismissal reality (mid-drag cancel and menu
close dismiss first; only with nothing open does focus return to the
rail tab); the controlled-state hazard says re-imposition happens when
the write-back effect re-runs rather than promising the very next
render; and the preset test's comment no longer claims a named-prop
forward the implementation (one spread object, gated by the interface
member) does not perform.

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

vercel Bot commented Aug 25, 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 25, 2026 3:31am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-dy36w9t3e-cacheplane.vercel.app
Commit: 612f54448bc4bdadfaaa8b35187726b09fcb2121

Updated automatically by the deploy-preview job.

@blove
blove merged commit 693f01e into main Aug 25, 2026
20 checks passed
@blove
blove deleted the blove/tool-panel-sp1-shell-columns branch August 25, 2026 03:47
@blove blove mentioned this pull request Aug 25, 2026
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