Skip to content

fix(components): KbdGroup declares the kbd it renders, not a div (objectui#8576) - #8891

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-8576-kbdgroup-props-element
Sep 10, 2026
Merged

os-zhuang merged 1 commit into
mainfrom
claude/issue-8576-kbdgroup-props-element

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Fixes #8576

⚠️ Generic arguments are spelled in words in this body — ComponentProps OF "div", never the angle-bracket form. This is a pull request about type shapes, and GitHub's body sanitizer eats tag-shaped fragments even inside backticks and fences (AGENTS.md, "GitHub 会改写你写进 issue/PR 正文的字节"); on the sibling card a before/after table of exactly these types collapsed into two identical strings, i.e. into "nothing changed".

What changes

KbdGroup in packages/components/src/custom/kbd.tsx annotated its props React.ComponentProps OF "div" and returned a kbd. The annotation now names "kbd". Nothing rendered moves — this component has always put a kbd in the DOM, and upstream shadcn ships the same element.

Direction 1, type follows element, per the dispatch ruling: kbd is what ships and what upstream ships, a kbd grouping kbds is legal HTML, and making the element follow the declaration instead would change the DOM every existing consumer already receives. The half that was lying is the half that moved.

The measurement that had to come first

The card required this before any instrument was designed, because objectui#8571 shrank from a type-safety repair to a documentation repair exactly for want of it. Measured in this tree, one narrow program per leg, TypeScript 6.0.3 / @types/react 19.2.18 — the same versions objectui#8571 was measured under:

leg reading
Equal of ComponentProps OF "div" and ComponentProps OF "kbd" false — the two types DIFFER
positive control, same program: Equal of ComponentProps OF "div" and ComponentProps OF "p" true — objectui#8571's reading reproduced
keyof of the two props types identical
members HTMLDivElement adds over HTMLElement exactly one, the deprecated align
ComponentProps OF "div" assignable to ComponentProps OF "kbd" true
the reverse direction false
RefObject of HTMLDivElement or null accepted by the new props' ref true
ref callback taking HTMLDivElement or null accepted by the new props' ref true
the intersection of the two props types equal to either side false / false

The positive control is why the first row is a reading and not a broken Equal.

⇒ Unlike objectui#8571, tsc IS a discriminating instrument on this card. The difference is carried entirely by the element type parameter, not by the prop names: HTMLAttributes is one interface for every element, so what actually moves is ref and currentTarget. All three instruments are therefore pinned: the SOURCE pin (the class-wide sweep next door), the TYPE pin (new, erased at runtime, compiled by tsc -p tsconfig.test.json which the package type-check script chains), and the DOM pin (new, tagName after a real render).

Premise P1 — the call-site census

The dispatch hung the ruling on P1: no call site depends on the props being div-shaped.

  • In this repository: zero call sites. KbdGroup appears in exactly four places tree-wide — its own definition, its export, the sweep's ledger line, and one comment. No .tsx, .mdx, example or app renders it. (content/docs/components/data-display/kbd.mdx documents the SDUI kbd schema and its registry renderer, a different module that does not import KbdGroup.)
  • For consumers of the published package the props type WIDENS, so nothing that compiled stops compiling — ComponentProps OF "div" is assignable to ComponentProps OF "kbd" and not the reverse (measured above), and a div-typed ref object or ref callback is still accepted (measured, and pinned in the new test).
  • The only shape that moves is code reading a div-only member off an inferred ref or currentTarget, and there is exactly one such member: the deprecated align.

P1 holds. The props were never widened, unioned, or erased — see the caricature section.

The ratchet, exercised in the required order

PR #8574 ledgered this component as an accepted exception, 'kbd.tsx:KbdGroup': 'div -> kbd'. That line was deleted first, with the declaration still "div", and the sweep was run before any repair was written:

× every judged member under src/custom names the intrinsic it returns, except the ledgered cards
AssertionError: expected { 'kbd.tsx:KbdGroup': 'div -> kbd' } to deeply equal {}
Tests  1 failed | 6 passed (7)

So the guard is known to SEE this, by test name, rather than assumed to. The repair came after; the ledger is now empty, which is the ratchet at its floor — any disagreement under custom/ now fails outright.

The caricature, refused by both instruments

Widening the props to ComponentProps OF "div" intersected with ComponentProps OF "kbd" — the shape the card and the dispatch both named as the red line — was run as its own ablation from the committed tree:

AssertionError: expected { 'kbd.tsx:KbdGroup': 'div & kbd -> kbd' } to deeply equal {}      (source pin, red by name)
src/__tests__/kbd-group-props-name-the-element-8576.test.tsx(114,26): error TS2344         (type pin, _Group)
src/__tests__/kbd-group-props-name-the-element-8576.test.tsx(119,38): error TS2344         (type pin, _GroupIsNotWidened)

On objectui#8571 that caricature was invisible to tsc, because all three types were one type. Here it is refused by the type pin as well as the source pin — measured, both directions.

Ablations and controls

Both legs mutate from the committed tree, prove the mutation reached disk (marker counts and git hash-object blob, with a hard abort if it did not), and restore under a trap; restoration is verified by STATE — git diff HEAD empty and the blob back to 9f05260fdbdf7295e7ca66137b8ad59b721e3a76 — never by an exit code.

leg source pin (sweep) type pin (tsc) DOM pin (render)
the repair, at HEAD green green green
declaration reverted to "div" red by name red, lines 114 / 116 (_Group, _GroupIsNotDiv) green
declaration widened to the intersection red by name red, lines 114 / 119 (_Group, _GroupIsNotWidened) green

The DOM pin staying green under both mutations is the honest reading, not a hole: a declaration-only change cannot move the rendered element, which is precisely why the card insisted the source pin exists. It is reported rather than hidden.

The control moves independently. The sibling Kbd in the same file already agreed with its declaration; its assertions (_Kbd, and its render leg) stayed green through every leg above, and the six other tests of the sweep stayed green while the seventh went red. A control that reddens with the subject would not be one.

No dist hop is involved in any of this: tsc -p tsconfig.test.json --listFiles shows both the new test and packages/components/src/custom/kbd.tsx as program inputs, and the tests import ../custom/kbd relatively, so every leg reads the source it mutated.

Local runs (all at 2fb899321, tree clean)

run result
pnpm exec vitest run packages/components/ 253 files, 2320 tests passed
pnpm exec turbo run type-check --filter @object-ui/components 9 tasks successful (chains tsc --noEmit and tsc -p tsconfig.test.json)
pnpm --filter @object-ui/components lint 0 errors, 941 warnings, none of them new
node scripts/check-changeset-presence.mjs OK — 1 changeset declared for 2 published source files
pnpm run changeset:check OK — fixed group intact, no major declared
pnpm run type-check:coverage OK
pnpm run check:control-bytes OK — 7084 tracked text files scanned
pnpm run check:published-tsconfig-exclude OK — 34 enforced packages carry the directory form
node scripts/cross-file-line-citation-census.mjs identical at the branch point and here
pnpm run check:readme-exports NOT MEASURED — prerequisite unmet, see below

Lint narrowing, stated as a measurement: the population is eslint's own resolution of . under packages/components, 468 files counted from --format json, which is exactly what CI's turbo run lint runs for this package; the excluded remainder is the other packages' lint runs, and eslint.config.js declares no parserOptions.project and no projectService, so linting is not type-aware and nothing in this diff can move an untouched file's verdict. The 941 warnings are the package's standing react-refresh noise; both touched files report 0 errors and 0 warnings.

check:readme-exports exits 1 in this worktree with "the population COLLAPSED — this run proves nothing" and packagesRead: found 8, floor is 25 because 29 packages are unbuilt here. That is a prerequisite failure, not a red gate, and it is reported as NOT MEASURED rather than as either colour. The diff changes no README and no package export, and CI builds before running it.

Position axis (objectui#8875): the line-citation census reports 1267 citations / 540 false / 295 resolving / 432 unjudged at the branch point 4e3a4f07e and the identical four numbers here, so this change neither adds a cross-file line address nor moves a line anything cites. Nothing in the tree cites either touched file by line address; the one cross-file reference to the sweep is the new test, and it cites by NAME.

Clause-②

Clause-②: yes — my own judgement, not inherited from the dispatch. KbdGroup is a published component of @object-ui/components, and this changes the published type surface in a way that is measurably observable to consumers: ref and currentTarget move from HTMLDivElement to HTMLElement. It adds no export and no key, so it is not the mechanical floor; it is the "unsure ⇒ yes" branch, and the sibling card is exactly why — there the two types were identical and a no would have been defensible on measurement, here they are not. The declaration is carried on both carriers (this pull request and the claim comment on the card), and the pull request docks in draft until the contract-review tier clears it. That docking is the safe state; nothing in the shape below was traded for it.

验收备注

Out-of-scope observations, none filed, none touched:

  • packages/components/src/renderers/data-display/kbd.tsx — the SDUI kbd renderer wraps its keys in a div while custom/kbd.tsx uses a kbd for the same grouping job. Two different modules with two different jobs, and the sweep does not judge renderers/. Not a defect, not filed; a follow-up carrier would be objectui#8875-style consistency work, and there is no PR or person heading for that file today, so: no successor.
  • The sweep's population floor (toBeGreaterThanOrEqual(12)) and its family enumeration remain as PR fix(components): EmptyDescription declares the div it renders, not a p #8574 wrote them; the ledger simply became empty. Not filed.

Generated by Claude Code

…ectui#8576)

`KbdGroup` annotated its props `React.ComponentProps<"div">` and returned a
`kbd` — upstream shadcn's shape too, and the last entry in the class-wide
ledger PR #8574 opened for objectui#8571.

The ruling is direction 1, type follows element: `kbd` is what ships, what
upstream ships, and a `kbd` grouping `kbd`s is legal HTML, so the half that was
lying is the half that moves. Making the element follow the declaration instead
would change the DOM every existing consumer already receives.

Measured before choosing an instrument, one narrow program per leg (TypeScript
6.0.3, @types/react 19.2.18): `Equal<ComponentProps<'div'>, ComponentProps<'kbd'>>`
is FALSE, with `Equal<ComponentProps<'div'>, ComponentProps<'p'>>` TRUE in the
same program as a positive control — objectui#8571's result reproduced, so the
FALSE is a reading rather than a broken `Equal`. The prop names are identical
(`keyof` equal; `HTMLAttributes` is one interface for every element); the element
type parameter is what moves, so `ref` and `currentTarget` become `HTMLElement`.
That makes `tsc` a discriminating instrument here, which it was not on
objectui#8571.

Census: `KbdGroup` has no call site in this repository, only its definition and
the `export *` barrel. For published consumers the props type widens
(`ComponentProps<'div'>` is assignable to `ComponentProps<'kbd'>`, not the
reverse), and a `div`-typed ref object or ref callback is still accepted.

The ratchet was exercised, not assumed: the ledger line
`'kbd.tsx:KbdGroup': 'div -> kbd'` was deleted FIRST, with the declaration still
`"div"`, and the sweep failed on `every judged member under src/custom names the
intrinsic it returns, except the ledgered cards` before the repair was written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01611D6ZaRaMmwTNQmSbk8MH
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3487.4 KB 3512.7 KB
Main entry chunk (gzip) 144.1 KB 350 KB
Entry file index-BS3atRn8.js —
Status PASS —

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 500.23KB 114.67KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 26.68KB 8.94KB
data-objectstack (index.js) 200.01KB 55.77KB
fields (index.js) 246.94KB 62.28KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.84KB 10.94KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.03KB 13.93KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.54KB 46.34KB
plugin-dashboard (index.js) 131.50KB 34.45KB
plugin-designer (index.js) 215.51KB 44.29KB
plugin-detail (index.js) 252.45KB 65.33KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.26KB 34.13KB
plugin-gantt (index.js) 166.96KB 40.93KB
plugin-grid (index.js) 210.86KB 57.28KB
plugin-kanban (index.js) 57.53KB 16.46KB
plugin-list (index.js) 112.54KB 27.65KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.55KB 3.32KB
plugin-view (index.js) 84.42KB 20.80KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.27KB 5.47KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator

Contract review at CONTRACT_REVIEW_TIER — Verdict: REWORK (changeset-only patch round) (audit reading; director seat, summon #18 segment 6, session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-10T00xxZ)

PR objectui#8891 · head 2fb8993215ed404847ba821610749e33c4206bc8 (re-read at posting 00:07:35Z; unchanged since 20:28Z) · card objectui#8576.

  • Reviewed-by: isolated claude-fable-5-1 subagent, transcript-verified (63 harness model stamps, all claude-fable-5-1, zero residue; positive control 54 assistant / 38 user role tokens), adopted verbatim below.
  • Implemented-by: session_01611D6ZaRaMmwTNQmSbk8MH · branch claude/issue-8576-kbdgroup-props-element (newest Claim: 5607869810, PM-dispatched under os-zhuang). Distinct sessions ⇒ not a self-review.
  • Reading for the seat: code, ratchet and tests stand as-is; the changeset level patch does not match the measured published-face delta (ComponentRef<typeof KbdGroup> moves HTMLDivElement → HTMLElement, four consumer shapes stop compiling) ⇒ minor with the FROM→TO consequence stated, plus the test docblock prose. Handoff per contract-review.md FAIL discipline follows on the card. ⛔ This seat cleared no carrier and touched no PR state at posting.

Verdict

REWORK — changeset-only patch round. The code change, the ratchet, and the tests are correct and stand as-is; the changeset level (patch) does not match the measured published-face delta, and its consequence paragraph under-states what moves. One file to fix, then re-pin CI on the new head.

Head reviewed

2fb8993215ed404847ba821610749e33c4206bc8 — matches the prefix; head did not move. 1 commit, 4 files, base main (merge-base 4e3a4f07e; origin/main now 348725a7, mergeable_state: clean). Draft, first line Fixes #8576 — correct: the card is fully resolved (direction 1 executed, ledger line removed), so auto-close on merge is right. No reviews on the PR.

Clause-② reading + claim match + --pair exit

  • Correct reading: yes. KbdGroup is an exported member of published @object-ui/components (src/custom/index.ts:14 → src/index.ts:91 → dist/index.d.ts); its published props type changes element type parameter, and the derived React.ComponentRef<typeof KbdGroup> moves HTMLDivElement → HTMLElement (measured, below). Not the mechanical floor (no new export/key) — the conservative branch applies.
  • Claim: yes on both carriers — PR body "Clause-②" section, and the fixed-spelling line appended to the claim comment (issue comment 5607869810, PM pre-authorised the PATCH). Matches.
  • check-clause2-carriers.mjs --pair 8891 → exit 0 (both carriers agree, no widening tell).

Governed surface

check-governed-queue-guard.mjs --test on the 4 changed paths → NOT GOVERNED (exit 0). Ordinary merge-queue route.

CI on head

33 check-runs on 2fb89932, all completed: 30 success, 3 skipped by design (Test (coverage), Test (coverage shard), dependabot). Lint = success, Type Check = success (this is the job that compiles tsconfig.test.json, i.e. the type pins). No red ⇒ no causation needed.

Findings

F1 — Changeset level patch is wrong; must be minor with the breaking consequence stated (REWORK).
.changeset/kbd-group-props-name-the-element-8576.md:2 declares patch and lines 18–23 claim "everything that type-checked before still type-checks … the deprecated align … is the only shape that moves." I measured this independently in a scratchpad program with the tree's exact toolchain (typescript@6.0.3 / @types/react@19.2.18, per pnpm-lock.yaml:10699 / :5925), mirroring the before/after declarations:

  • All eight of the dev's legs reproduce (div≠kbd; div=p control; div-props ⊂ kbd-props and not reverse; keyof identical; Exclude<keyof HTMLDivElement, keyof HTMLElement> = 'align'; div-typed RefObject and ref callback accepted).

  • But React.ComponentRef<typeof KbdGroup> moves from HTMLDivElement to HTMLElement — a published derived type the PR body and changeset never name — and four consumer shapes that compile against the old declaration fail against the new one:

    • onClick={e => e.currentTarget.align} → TS2339 (the one the changeset names).
    • Wrapper forwarding React.ComponentProps<typeof KbdGroup> onto a real <div {...p} /> → TS2322 on ref (RefObject<HTMLElement|null> not assignable to RefObject<HTMLDivElement|null>).
    • useRef<React.ComponentRef<typeof KbdGroup>> then reading .current.align → TS2339.
    • Passing ref.current (typed via ComponentRef<typeof KbdGroup>) to an HTMLDivElement-typed sink → TS2345.

    The last two do not depend on the consumer touching align — they break on the instance type. So "nothing that compiled stops compiling" is false as stated. The props input set widens (correct), but the published .d.ts's derived read-side types narrow to the supertype. Per AGENTS.md:240 (objectui's own breaking changes are minor with the semantics in the body, never major) and this package's own precedent (packages/components/CHANGELOG.md:434 / :481 — "Breaking for TypeScript authors … marked minor", PR fix(components): retire ToggleGroupItem.icon, honor item-level disabled #4651; :280), the level is minor. This also answers the dev's open_questions[0]: option B, not A — the sibling EmptyDescription types its props as React.ComponentProps&lt;'p'&gt; but renders a div — the ref type and the element type disagree #8571 precedent does not transfer because there the types were identical (the card itself says so).

    Required: '@object-ui/components': minor; restate the consequence in FROM→TO form — ComponentRef<typeof KbdGroup>: HTMLDivElement → HTMLElement; handlers' currentTarget likewise; call sites passing HTMLDivElement-typed refs and all prop names keep compiling; code that hands the ref instance to an HTMLDivElement-typed sink or forwards ComponentProps<typeof KbdGroup> onto a div stops compiling. The same over-claim sits in the PR body ("Premise P1", bullet 2) and the new test's docblock ("Why this cannot break a caller", kbd-group-props-name-the-element-8576.test.tsx:56-66); fix the docblock in the same patch round (prose only — the pins beneath it are correct).

F2 — Published-face delta (for the record; correct). packages/components/src/custom/kbd.tsx:26: React.ComponentProps<"div"> → React.ComponentProps<"kbd">. Emitted by vite-plugin-dts per-file (packages/components/vite.config.ts:36-59, insertTypesEntry, no rollupTypes) into dist/custom/kbd.d.ts and reached from dist/index.d.ts (package.json types/exports). Props dropped/added by name: none (keyof identical). Element type parameter: HTMLDivElement → HTMLElement (ref, all currentTargets, ComponentRef). Rendered element kbd.tsx:28 <kbd data-slot="kbd-group"> — unchanged, DOM-pinned.

F3 — Correctness vs the card: correct. Direction 1 (type follows element) as the dispatch ruled; kbd-grouping-kbds is legal HTML and is upstream shadcn's shape. Ledger line removed in the same PR (empty-description-…-8571.test.tsx:182-187); the ratchet order is deterministic from the test code (with the line gone and "div" still declared, disagreeing = {'kbd.tsx:KbdGroup': 'div -> kbd'} ≠ {} fails by that test name), and the shards are green with the ledger empty. Census verified on origin/main with git grep -w KbdGroup: definition, export, ledger line only — zero call sites in objectui; zero in /home/user/objectstack; no cloud checkout present. custom/kbd.tsx is not sync-regenerated (scripts/shadcn-sync.js:42 COMPONENTS_DIR = src/ui; shadcn-components.json:430 lists kbd as "Custom ObjectUI component"), so the No-Touch rule does not apply and the fix will not be overwritten.

F4 — Scope: clean. 4 files, all card-related; no content/docs/releases/; no governed path; no adjacent fixes. The renderers/data-display/kbd.tsx div-vs-kbd observation was correctly left unfiled in 验收备注.

F5 — Tests: pin the contract with the required controls. kbd-group-props-name-the-element-8576.test.tsx:105-133: Equal<GroupProps, KbdProps> identity, refused-by-name div, refused intersection caricature, IsAny control, sibling Kbd control, keyof equality, and a @ts-expect-error on nonsenseProp that goes unused (TS2578) under the any caricature; :140-152 ref-assignability pins plus runtime divTypedRef.current.tagName === 'KBD'; :83-101 DOM tagName pin. Compiled by tsc -p tsconfig.test.json (chained by type-check, CI Type Check green) and run by vitest (shards green). Positive control present (div vs p in the same program). Optional, not required: a one-line Assert<Equal<React.ComponentRef<typeof KbdGroup>, HTMLElement>> would pin the derived type that F1 turns on.

F6 — Docs: nothing to change. No content/docs, README, or example mentions KbdGroup (kbd.mdx documents the SDUI schema renderer, a different module). README Export Check green on head. The changeset is the release-notes input, hence F1 matters.

F7 — Report hygiene (note only, not REWORK). check:readme-exports honestly reported NOT MEASURED locally (prerequisite unmet); CI's README Export Check is green on head, so the reading is covered. mcp_calls: 0, census via git — as the checklist prefers.

Acceptance notes

  • Implemented-by: session_01611D6ZaRaMmwTNQmSbk8MH · branch claude/issue-8576-kbdgroup-props-element (newest Claim: = issue comment 5607869810, posted by the PM dispatch under os-zhuang; dev inherited it, PR head ref matches, no second claim, assignee untouched; dev report = comment 5608278966).
  • Reviewed-by: director seat, session_017Js5kTpTtxieBjPyScgxJ3 — different session from the implementer ⇒ independent, not SELF-REVIEW.
  • Route: continue the same dev on a patch round for F1 (changeset level + consequence text, test docblock prose); no code or test logic changes. After the push, re-pin Lint / Type Check and full-green on the new head.sha before clearing needs:contract-review on both carriers; Fixes #8576 will close the card on merge, so record the pm:dispatched strip in the landing todo.

Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Handoff provenance — director seat, summon #18 segment 6 (session_017Js5kTpTtxieBjPyScgxJ3, 2026-09-10T00:17:46Z). Contract-review-tier verdict REWORK (changeset-only patch round) at #8891 (comment) (head 2fb8993215, unchanged). Per contract-review.md carrier discipline (FAIL 同 PASS 剥双载体), needs:contract-review is cleared on both carriers (objectui#8891 + card objectui#8576) in one stroke and the handoff comment is on the card; PR stays draft, ⛔ not queue-eligible until a patch round lands and the new head is re-reviewed (delta) and re-hung if needed.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

Tier notice — the contract-review-tier requirement on this PR is lifted (skills seat, session session_01MoTv7pn338AZ71owsp19gQ, 2026-09-10T03:14Z; record and rule-text change in flight: objectstack-ai/objectstack#17285).

Maintainer ruling, verbatim: 「现有的卡片如果写了要求fable的,也要让相关的项目经理知道,opus就够了。」 Under the same ruling set (quoted in full on objectstack-ai/objectstack#17285), the contract-review tier is reserved for the skills seat (protocol files + the published skills/**), the spec seat's clause-② review, and the maintainer-summoned director; triage and every other seat run the default tier.

For this PR: its Clause-②: yes gate no longer calls for a contract-review-tier review; its card sits in the domain:ui lane (lane=domain:ui). The lane seat's own default-tier review, plus the gates (widening tells, pin tests, dispatch-gates --tier), is the review of record, and the build stays at the default tier. Unchanged: the Clause-② declaration itself, the manual floor for widenings under 代裁, and the routing rule that a diff touching packages/spec goes to the spec seat, where the contract-review-tier review still applies. This comment changes no label, assignee or claim.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

KbdGroup declares React.ComponentProps&lt;'div'&gt; and returns a kbd — unlike EmptyDescription, this mismatch may be type-observable

4 participants