Skip to content

feat(react): PretableButton, PretableIconButton, and the components slot - #580

Merged
blove merged 31 commits into
mainfrom
blove/components-sp1-button
Sep 5, 2026
Merged

feat(react): PretableButton, PretableIconButton, and the components slot#580
blove merged 31 commits into
mainfrom
blove/components-sp1-button

Conversation

@blove

@blove blove commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Components SP1 — the contract, proven on the grid's twelve plain push-buttons. Spec: docs/superpowers/specs/2026-09-04-components-sp1-button-design.md; plan: docs/superpowers/plans/2026-09-04-components-sp1-button.md.

What ships

  • PretableButton (ghost | link) and PretableIconButton (aria-label required — a compile error, not a WCAG failure found later; an empty one warns in development). Always type="button"; className/style pass straight through; ref forwarded; styled through data-pretable-button / -icon-button / -variant / -site.
  • components={{ Button, IconButton }} on the surface and the preset: one slot per type, applied everywhere, resolved once and memoised on slot identity, delivered by context so the portalled filter dialog gets it too. The placement prop is site, not rolerole is the ARIA attribute on every button.
  • The twelve sites migrated. Every site keeps its original attribute, so nothing that identified a button stops identifying it. The shared look moved from twelve site rules onto the component rules; grid.css is net smaller.

Out of scope by decision (spec): menu items, checkboxes, the tab, the twisty, header cells, filter-join, and the editors' internal month-step / number-stepper buttons — each waits for its own component.

Verification

  • No pixel moved. Every site's computed longhands, box and focus ring were recorded before any code changed and re-measured after the migration with the same script; each of the 21 differences is a rendered-equal computed value or one of the deliberate changes named in the changeset (product focus ring on five buttons that wore the UA ring; control radius on two; position: relative off the kit icon rule and kept per-site where a ::after hit area needs it).
  • Guards, mutation-checked. Kit rules present with the ring/disabled/hover discipline; every site rule declares only what is its own — the first version of that guard demanded zero :focus-visible rules per site, which would have deleted the funnel's and column menu's keyboard reveal; it was rewritten to check declarations, not selector counts, and to require what each site genuinely owns so a deleted rule cannot pass. Kit base rules first, state rules last; every site hover carries :not(:disabled), per comma-part. The two portalled surfaces that host kit controls now declare line-height too — font: inherit had been pulling the host page's into Clear for a consumer with no CSS reset.
  • jsdom. The components; the context — its memo pinned by a changed-slot case that fails on an empty dependency array; the override through the surface, including a replacement inside the portalled dialog and a same-components re-render that does not remount; all twelve sites pinned to their kit component and site name.
  • Types, against the built dist/. aria-label required, type rejected, site open and keeping built-in autocomplete (the first assertion was mutation-proved not to test openness), slots optional and omissible.
  • Browser (/fixtures/components). Every labelled and icon button is the consumer's; the dialog's Clear is a child of <body> and is the replacement; a replaced kebab still anchors its menu at the placement rule's exact clamp, and Escape returns focus through the forwarded ref (the assertion that actually depends on it — the menu anchor comes from currentTarget either way).
  • Docs. A Components page with a live example; three tables registered with the api-surface guard — whose readers could not see a quoted member name ("aria-label"), so the page's most load-bearing prop was invisible to it until both readers were widened (blast radius: one member in the whole corpus).
  • format, lint, typecheck, typecheck:public, api:check, test, bench:e2e (29), and the website's smoke / tool-panel / header-keyboard / popover-scroll / grouping / components suites (146 + 38) pass. One website unit test (partial-row-stream demo) timed out twice under a load average of 18–20 and passed 3/3 in isolation; it touches nothing on this branch.

🤖 Generated with Claude Code

blove and others added 30 commits September 5, 2026 09:06
Opens the component-kit program: every control in the grid rebuilt on a
pre-built, accessible, styleable kit a consumer can also replace per
component type. Four sub-projects by component type; this spec is the first
and settles the contract the rest inherit — placement in @pretable/react,
one `components` slot per type with a `role` in the props, the house styling
channel (attributes + tokens + className passthrough), and context as the
plumbing because popovers are portalled.

Proven against the grid's twelve plain push-buttons, which keep their
existing data attributes so nothing that identifies a button today stops
identifying it. Menu items, checkboxes, tabs and the twisty are roles that
happen to use <button>, and wait for their own components.

Also records the decision that the Excel theme stays light-only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Two corrections from reading the code the plan has to match. The four
labelled actions are `border: 0; background: transparent` with a hover tint —
a ghost button, not an outlined one; the earlier survey read `border: 0` as
a border. And `role` cannot be the prop: it is the ARIA attribute on every
button, so a replacement spreading `role="filter-clear"` onto a <button>
would emit invalid ARIA. The prop is `site`, which is what the spec already
defined it to mean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fifteen tasks, TDD throughout: the pixel baseline first, the two components,
the type contract, the context, the surface wiring with the override tests
landing red, the component rules and a site-rule guard that stays red until
the twelve sites migrate and their CSS collapses, the public API, the pixel
re-measure, docs with registered tables, the browser proof through the
portalled dialog, and a verification step that runs the bench suite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The kit's first two components. Always type="button"; className and style
pass through; the contract attributes follow the spread so no prop displaces
them; IconButton's accessible name is a required prop.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tract attributes

"aria-label": string makes omission a compile error but lets "" through, which
is the failure the prop exists to prevent; warnOnce closes it in development
the way PretableStatus's missing-children warning does. The hostile-spread test
now pins all four contract attributes, and `site` is asserted never to reach
the DOM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The type requires it; the runtime cannot, so the guard that warns about an
empty name must not itself throw on an absent one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
aria-label required on IconButton, no `type` prop, an open `site`, a closed
variant, and native attributes flowing through with the ref.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
One slot per type, resolved over the defaults and memoised on each slot's
identity; context rather than props because the popovers are portalled.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ostile cast

`Extract<PretableButtonSite, "filter-clear">` was the same whether or not the
union was open, so the alias could not fail for the reason its name claimed;
`Equal` against the full union can, and was mutation-checked. The
source-relative import is marked as the temporary state it is, and the
missing-name test casts to the component's own props type.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An empty dependency array passed every test, because every test fed the memo
unchanged values. The negative twin now fails it. Also: the React 18
forwardRef obligation is stated where the type cannot enforce it, both
branches of the resolution are frozen, and the context has a DevTools name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Resolved once, memoised on slot identity, provided on both of the surface's
return paths so the portalled popovers inherit it. The override tests land
red on the site assertions, which the migrations turn green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The shared push-button box on the component attributes, first in the file
so site rules still win by order; ghost and link looks; the standard ring,
disabled treatment and forced-colours ink.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Red until the site rules collapse onto the component rules (Task 10).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+ filter, + group, Add group, Expand all, Collapse all. Each keeps its site
attribute, so nothing that identified it stops identifying it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…variant

The Clear button is inside the portalled filter dialog, which is the case
the components context exists for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…est says what it pins

The kebab-menu test opened the menu on the built-in as readily as on a
replacement, so it could not fail; it now asserts the node is the replacement
first. The remount test's comment claimed the memo, which a same-type
reconcile hides from it; the memo is pinned at the hook level, and the comment
now points there.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The site-rule guard demanded zero :focus-visible rules per site, which would
have forced the funnel's and the column menu's keyboard reveal (opacity: 1)
out of the file — a focus-visibility regression arriving through a test
whose message said the opposite. It now forbids the ring's DECLARATION at a
site, keeps a positive check of what each site genuinely owns so a deleted
rule cannot pass, forbids the whole shared box rather than five of its
lines, and its list is mirrored into the kit guard so the two read the same
list from both ends. A third guard pins the source order the design rests
on. Two stay red by design until the site rules collapse.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Funnel, column menu, row kebab, chip remove, filter-row remove and group
remove. Every site keeps its attribute, its ref, its tabindex and its
handlers; the override suite goes fully green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Twelve sites keep a size, a reveal, an alignment; the box, ring and disabled
ink are the component's. Kit state rules move to the end of the layer so a
site's own state colour cannot outrank the disabled ink; position: relative
leaves the kit icon rule and stays only where a ::after hit area needs it
(funnel, column menu, row kebab) — on the two remove buttons, which sit in
flow among later siblings, it would have reordered painting; the two shared
focus lists are split, not deleted, so the five non-button controls on them
keep their ring.

Deliberate changes, from the review, not regressions: filter-clear and
tool-reset gain the kit's border-radius and the product focus ring
(tool-reset wore the UA ring); for a consumer without a CSS reset
filter-clear also gains the dialog's font, which Tailwind preflight already
gave it on our own site; the funnel and the column-menu button gain
flex: none.

Four older guards read declarations that have moved onto the kit rules
(a radius, two padding: 0s, box-sizing, the ghost box and its disabled
state, and the existence of a [data-pretable-filter-add] rule). Each is
re-pointed at the kit rule that now carries it rather than dropped, so the
same claim is still made from the one place it is true.

Guards mutation-checked: border: 0 added back to the funnel's base rule
fails "a push-button site rule declares only what is its own" ("redeclares
/border:\s*0/ — the kit button rule owns it"); the kit :disabled rule moved
back to the top section fails "the kit rules bracket the site rules in
source order" ("expected 2545 to be greater than 36192"). Both reverted;
packages/ui 109/109, packages/react override suite 7/7, typecheck and lint
clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ew minors

All twelve sites are now asserted in jsdom to carry the kit attribute and
their site name — the bridge that makes the CSS guards load-bearing, since
a site quietly back on a raw <button> would have left them green. Also:
JSDoc on the two components slots (their report markers were
"undocumented"), a key-omissibility pin in the type test, the components
hook read at the top of every component body, and a stale "Task 8/9"
docblock in ColumnsSection removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…disabled

The ✕ is a CloseIcon sized by --pretable-icon-size, so font-size and
line-height sized nothing and the comment that justified them was untrue.
Every icon site's hover now carries :not(:disabled), and the site guard
keeps it — latent today, since no icon site is disabled, but the rule is
#573's. The bracket guard's comment no longer describes a red state that
has since gone green.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s per comma-part

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A replacement lands inside the portalled filter dialog, and the grid still
anchors its menu on and returns focus to a replaced icon button.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An icon button carries no variant; the site attribute is written only when a
site is given; the example replaces labelled buttons only, and its Clear
lives in a column funnel's dialog, not the Filters section.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e-height

font: inherit on the kit button pulled the host page's line-height into the
filter dialog's Clear and every menu item; the surfaces declared family,
size and colour but not the fourth member. 1.5 is what the grid's own body
computes and what the pixel baseline measured. The portaled-trio guard now
requires it on these two blocks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Task 13 moved the three unit-test count constants and missed this e2e one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 5, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated
pretable Ignored Ignored Sep 5, 2026 7:14pm UTC

Request Review

@blove
blove enabled auto-merge (squash) September 5, 2026 19:14
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Vercel preview ready

Preview: https://pretable-uq0i1kh3v-cacheplane.vercel.app
Commit: 9766c9233da254af8e2c346fc316d692adddf89e

Updated automatically by the deploy-preview job.

@blove
blove merged commit 9ddc19a into main Sep 5, 2026
21 checks passed
@blove
blove deleted the blove/components-sp1-button branch September 5, 2026 19:35
@blove blove mentioned this pull request Sep 5, 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