Conversation
…rect the Tab claim `wrap-rows` was a WCAG 2.1.2 keyboard trap — Tab and Shift+Tab consumed unconditionally, clamped at both corners, 120 presses never out. The fix was to RELEASE at the corners, and until now that fix rested on a jsdom test. jsdom has no sequential focus order at all, so it can only say which presses the surface calls `preventDefault()` on; "the grid did not consume the press" and "focus left the grid" are different statements, and only the second is the absence of a trap. No page on the site rendered a `wrap-rows` grid, so nobody had ever driven the one configuration that used to trap. Adds `/fixtures/tab-wrap-rows` (3 columns x 4 rows, `wrap-rows`, bracketed by two text inputs) and a spec that asserts EXACT press counts in Chromium and WebKit. Text inputs rather than buttons as sentinels: macOS keeps bare buttons out of Safari's tab order while CI's Linux WebKit includes them, so a button sentinel pins an operating system. Naming the landing element is what distinguishes a one-press release from a lap of the document. Measured, identical in both engines: - Tab from the top-left cell: 12 presses out — exactly rows x columns. - Tab from mid-grid (r2,bravo): 8 presses out. - Shift+Tab from the top-left cell: 1 press out. - Shift+Tab from (r3,charlie): 9 presses out. - Tab / Shift+Tab from a header cell, first column or last: 1 press, both directions. So the header table's "in one press, whatever `tabBehavior` is set to" is TRUE — it is scoped to the header, and the surface hands Tab back on a header cell before it looks at the column index. What was false is the page's opening summary, which promised "one more Tab leaves" without qualification: under `wrap-rows` that is 8 and 12 presses on a grid of twelve cells. That sentence now names the default and points at the walk, the header row names its own scope, and the round-trip sentence says the grid restores the cell it left FROM — the release corner, not where the walk started. Mutation-proved by restoring the corner clamp in `pretable-surface.tsx`: 10 of the 18 tests fail (5 per engine, `presses: null`), and the four header tests per engine keep passing, which is correct — the header release is a different branch. Reverted; no `packages/` change ships here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he casts
`@pretable/core` bundles `@pretable-internal/grid-core` and
`@pretable-internal/row-model` (`noExternal`), so `tsup`'s bundled `.d.ts`
re-emits their declarations alongside the copies `tsc` writes into each
package's own `dist`. `@pretable/react` compiled against BOTH, and paid for it
with three casts in `pretable-model.ts`.
Two things made two emissions two types:
1. `unique symbol` brands, which are nominal PER DECLARATION FILE. Four were
affected — `groupIdBrand`, `rowModelDescriptor`, `columnDescriptor`,
`gridUiCoreType` — plus `rowLayoutControllerType` in renderer-dom, which had
not leaked yet. All are now string-literal keys (`~pretableGroupId`), which
are structural, so N copies are one type. Nominality is unchanged: the
branded types are intersections nothing inhabits without a cast, and `~`
cannot be written as an identifier. The silent half mattered more than the
casts: `RowOf` / `RowIdOf` / `ColumnsOf` match structurally on the row-model
brand and resolved to `never` across the seam with no diagnostic.
2. Deferred conditional types, which TypeScript relates by ALIAS IDENTITY —
`PretableAggregateOutputOf<TAggregate>` cannot be fixed by any brand change.
So the packages that compile alongside `@pretable/core` now reach the engine
THROUGH it: `@pretable/react` imports `ɵcreateGridUiCore` and friends from
`@pretable/core` rather than from `@pretable-internal/grid-core`, and
`@pretable-internal/renderer-dom` types its row model from `@pretable/core`
too. One declaration emission, and one runtime copy of the engine instead of
two.
Also fixes the hand-copied `moveFocus` union: `PretableReactGrid.moveFocus`
imports `PretableIndexedFocusMovement` instead of respelling it (the copy was
two members behind and the repair had been a cast at the call site in
`pretable-surface.tsx`), the duplicated `{pageRows?: number}` becomes the new
`PretableIndexedMoveFocusOptions`, `pretable-surface.tsx` stops shadowing
`PretableFocusDirection` with a local copy and stops respelling
`PretableMoveFocusOptions`, and `PretableGridUiSnapshot` becomes an alias of
`PretableGridUiState` rather than a 74-line structural copy of it (which also
retires `PretableReactRowRange(Index)`, second names for
`PretableIndexedRowRange(Index)`).
Guards:
- `scripts/__tests__/public-api-symbol-brands.test.mjs` fails if a symbol-keyed
brand reappears in any published API report; the package set is discovered
from `api-extractor.json`, so a new package is covered the day it is added.
- `packages/react/src/__tests__/cross-emission-type-identity.types.ts` asserts
the two emissions are the same types, including `RowOf` not collapsing.
- `type-tests/react/focus-movement-union.types.tsx` pins the movement union and
its options to the engine's, in both directions.
- `packages/react/src/__tests__/move-focus-movements.test.tsx` asserts every
movement still moves focus where it should, and that `pageRows` arrives.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
Vercel preview readyPreview: https://pretable-4sm0o0sa4-cacheplane.vercel.app Updated automatically by the |
The conflict was substantive, not textual: main added a `pageRows` option to the inline copy of the move-focus options while this branch was replacing that copy with the imported `PretableMoveFocusOptions`. Keeping only one side would have dropped either the de-duplication or the new option, so `pageRows` moves onto the named type where the copy used to spell it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The last three follow-ups in our lane. Two turned out to be bigger than filed.
1. A branded type was nominally split — and five brands were affected, not one
pretable-model.tscarried anas unknown asbecausePretableGroupId'sunique symbolwas emitted once bytscintogrid-core/distand again bytsupintocore/dist— twounique symbols from two declaration files are two different types.The audit found five brands crossing that boundary, and one of them was worse than a cast:
groupIdBrandrowModelDescriptorcolumnDescriptorgridUiCoreTyperowLayoutControllerTypeRowOf<>/RowIdOf<>/ColumnsOf<>match structurally onrowModelDescriptor, so across the seam they resolved toneverwith no diagnostic at all. Strictly worse than a cast, which at least marks the spot.Approach: string-literal brand keys (
readonly "~pretableGroupId"). Structural, so N emissions are one type regardless of future bundling topology. The "re-export the declaration" alternatives were rejected because@pretable-internal/*are private and unpublished — both would require publishing an internal engine's entire surface.Brands were only half of it
TypeScript relates a deferred conditional type by the identity of its alias declaration, and
PretableAggregateOutputOf<>is one — no brand change can reach that. So the engine now has a single emission:@pretable/reactreaches it throughɵcreateGridUiCorere-exported from@pretable/core, rather than importing@pretable-internal/grid-coredirectly.Independently verified:
react/dist/index.mjsdefinescreateGridUiCorezero times and imports itfrom '@pretable/core', where it is defined once. Two runtime copies became one.A correction to the brief
I said
typecheck:publicwould expose the split. It would not —react/dist/index.d.tsimportsPretableGroupIdfrom@pretable/corerather than re-emitting it, so consumer-level type tests saw one consistent brand. The split lived in react's own dist-resolved compile and in the.api.mdreports, which printedreadonly [groupIdBrand]while never declaringgroupIdBrand— not even self-consistent. Assertions went where the defect is, plus a fail-closed report guard.pretable-model.tscasts: 8 → 5;as unknown as: 2 → 0. The five that remain are unrelated local narrowings.2. A hand-copied union that had already broken the build
moveFocus'smovementparameter was a hand-written copy of the string union. Whenfirst-column/last-columnwere added, the stale copy rejected them and broke the build. It now importsPretableIndexedFocusMovement; the cast at the call site is gone.Pinned so the drift cannot return: adding a member to the engine union leaves
pretable-model.tswith an empty diff and fails only the enumeration assertion.3. The
wrap-rowsclaim was true — the summary above it was notwrap-rowswas a keyboard trap until recently (WCAG 2.1.2, 120 presses without escape), so "Tab leaves in one press" is the claim that the trap is gone — and it was the one configuration nothing on the site rendered.A fixture now drives it. Chromium and WebKit produced byte-identical traces:
The flagged sentence lives in the header table under "While the cursor is on it", and the surface hands Tab back on a header cell before it ever looks at the column index — so one press is correct in its stated scope, now verified rather than assumed.
What was false is the page's opening summary: "one Tab enters it… and one more Tab leaves", unqualified. Under
wrap-rowsthat is 8 and 12 on a twelve-cell grid. 12 is exactly rows × columns; backward from that same cell is 1, because it is already at the other release corner. The round-trip sentence was also wrong — Shift+Tab restores the cell the walk left from, not where it started.Test Plan
typecheck:public, lint, prettier, build,api:check— clean.wrap-rowscorner clamp fails 10 of 18 exit tests while the four header tests per engine keep passing, since the header release is a separate branch.rowModelDescriptormutation catches the silentnevercase, which had no diagnostic before.Found, not fixed — two are latent hazards
A scan turned up ~29 duplicated types. The diagnosed class is fixed; the rest are reported rather than sprawling the diff. Two are real hazards where a copy was silently widened:
SurfaceFacade.getSnapshot().editing.statusisstring, notPretableEditStatus— and is compared against"editing"atpretable-surface.tsx:3273.csv.ts:348takes{kind: string}, notPretableExpansionDefault— compared against"expanded", so renaming that literal would silently flip CSV completeness.Also:
PretableSortDirectionhas 8 byte-identical copies;PretableExportScope10;ColumnTypeandPretableColumnTypeare byte-identical and both published from@pretable/core— two public names for one union.🤖 Generated with Claude Code