Skip to content

fix(components): the required asterisk is a real aria-hidden span, not CSS generated content (objectui#10368) - #10428

Merged
os-litant merged 4 commits into
mainfrom
claude/issue-10368-required-marker-real-span
Sep 25, 2026
Merged

os-litant merged 4 commits into
mainfrom
claude/issue-10368-required-marker-real-span

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #10368
Clause-②: no

What changed

Six published renderers in @object-ui/components drew the required asterisk as CSS generated content: a Tailwind ::after content utility on the control's associated label. The accessible-name computation includes generated content, so Chromium named a required field labelled "Title" as Title*. A pseudo-element cannot carry aria-hidden.

  • Markup. The asterisk is now a real span with aria-hidden="true" and data-required-marker="true". This is the shape the form renderer's FormLabel marker already uses (ADR-0054 C4), and the shape of objectui#3299 / ActionParamDialog and objectui#10361.
  • Sites. custom/field.tsx (FieldContainer), renderers/basic/text-input.tsx (element:text_input), and renderers/form/input.tsx, textarea.tsx, checkbox.tsx and select.tsx. They were re-located by content on origin/main 2fc2a2439. Before the change, a grep for the asterisk content utility matched these six class sites plus the field.tsx comment in non-test src.
  • False premises corrected.
    • The FieldContainer comment said the CSS asterisk "never enters" the name.
    • The CheckboxSchema.required docblock in packages/types/src/form.ts described the pseudo-element style. The rewrite also drops that docblock's two stale cross-file line addresses and cites by content instead.
  • Why comments don't name the utility. No source comment spells the literal utility any more. Tailwind scans comments in src, so a spelled-out utility would compile the rule back into style.css.

Measurement: Chromium's accessibility tree on the compiled class (A3)

The card's number came from an equivalent CSS rule. This one is from the compiled class itself.

  • Probe. A one-off script, not committed. It SSR-renders the six sites from worktree source with required set and the label "Title". It styles them with the sheet compiled from packages/components/src/index.css through the same postcss + @tailwindcss/postcss pipeline that scripts/build-css.mjs runs. It loads the page in Chromium 141.0.7390.37 and reads CDP Accessibility.getFullAXTree.
  • Where each run was taken. "Before" is the base tree 2fc2a2439. "After" is 8d744575d, the last commit that touches source or tests.
site role name, before name, after required (CDP), before and after
FieldContainer textbox Title* Title true (aria-required)
element:text_input textbox Title* Title true (native)
input textbox Title* Title true (native)
textarea textbox Title* Title true (native)
checkbox checkbox Title* Title no required property listed (see A2)
select combobox empty empty true (aria-required)

The compiled sheet contains the asterisk content rule before the change and does not contain it after.

PM mechanism assumptions

  • A1 visual parity: holds (measured).
    • The computed marker colour is rgb(239, 68, 68) before (::after) and after (the span). The margin-left is 2px in both.
    • Label widths and label colours are unchanged at every site.
    • The full-page Chromium screenshots before and after are byte-identical (same sha256).
    • The four form renderers keep text-destructive on the label itself, as before. The span also carries text-destructive and ml-0.5.
    • No site used a class that only works on the pseudo-element. The checkbox label's peer-disabled:* classes apply to the label box, and the span sits inside that box.
  • A2 required state: unchanged at every site.
    • FieldContainer uses aria-required and still has no native required.
    • element:text_input, input and textarea use the native required.
    • checkbox: Radix writes aria-required="true" on the role=checkbox button.
    • select: Radix writes aria-required="true" on the combobox trigger.
    • No validation code was touched.
    • ⚠️ On the checkbox, Chromium's CDP property list has no required entry for that role=checkbox node, both before and after, even though the attribute is present. This is a reading of the inspector's mapping, not something this PR changes.
  • A3: measured, as shown in the table above.
  • A4 other consumers.
    • No test or snapshot asserted the label's after: class. The only test that asserted a required label's clean name was pin 1, rewritten below.
    • Twenty other components test files mention these renderers. They were run at the final code state and all passed (see Local verification).
    • No test in another package renders these six sites with a required label.
    • Downstream effect, measured with @testing-library/dom 10.4.1: an exact-string getByLabelText('Title') no longer finds a required field, because it reads the label's whole text, hidden * included. getByLabelText(/Title/) and getByRole('textbox', { name: 'Title' }) still find it. The changeset says so.

Pins rewritten, not deleted

  • field-container-aria-required.test.tsx. The last case's verdict used to be toHaveAccessibleName('Title') under happy-dom, next to a comment saying the asterisk could never leak.
    • The case now judges the markup. Nothing in the associated label draws generated content (a content-[ or content-( utility, bare or behind a variant). The * is one aria-hidden element. The label text outside aria-hidden subtrees is exactly Title.
    • The happy-dom name is kept only as corroboration, and the case's comment says why. dom-accessibility-api 0.6.3 (the jest-dom copy) returns Title * for the same span without aria-hidden and Title with it, so that line does go red on the span losing aria-hidden. It is still blind to generated content.
    • The header now says the asterisk did reach the name.
  • text-input-description-association.test.tsx: premise partly falsified. None of this pin's cases set required. Its header already said the asterisk IS in the name in a real browser, so this pin never falsely asserted a clean name for a required field.
    • What was wrong: its "second limit" paragraph described the old markup.
    • That paragraph is rewritten, and a required case is added. It asserts by markup that the marker is in neither the name nor the description channel, and that the native required is kept.
  • New required-marker-markup.test.tsx.
    • The six sites, each required and optional, with the same markup assertions plus each control's required state.
    • A package-wide source guard: no asterisk content utility in any non-test src file. It has a population control (the six site files are in the scanned set) and a pattern control (every spelling it guards matches, and content-none does not). Tests are excluded from Tailwind's sources and from this scan.
    • Its filesystem root is import.meta.url, not the cwd.

Reverse proof

  • Method. One site at a time was put back to its exact base-tree label, using ablation-replace with an anchor that must hit (at 304f68f82, committed). The run covered the new test and both pins.
  • Checks around each run. Each mutation was confirmed on disk before the run: marker count 0, pseudo-utility count 1. Each file was restored to the HEAD blob afterwards, and git diff HEAD was empty.
  • Expected direction: red. Observed: red, in exactly the predicted cases.
site put back failed which
custom/field.tsx 3 of 30 new test's FieldContainer required case · source guard · pin 1's rewritten case
renderers/basic/text-input.tsx 3 of 30 new test's element:text_input required case · source guard · pin 2's new required case
renderers/form/input.tsx 2 of 30 new test's input required case · source guard

Local verification

The final HEAD is 98977bbfe. It differs from 8d744575d only in the changeset's wording. No source or test file has changed since 8d744575d.

  • Components tests, at 304f68f82. 82 files, 915 passed, 10 skipped. The run covered:
    • the new and rewritten tests;
    • form-renderers.test.tsx;
    • every suite under renderers/form/__tests__/;
    • the text-input label and inputs suites;
    • the 13 packages/types suites that name form.ts.
  • Components tests, at 8d744575d. 23 files, 394 passed. The run covered the three new and rewritten files, plus every other components test file that names these renderers.
    • Between 304f68f82 and 8d744575d, only the new test file changed (its as any casts were removed).
  • Type-check.
    • Components: tsc --noEmit exit 0, and tsc -p tsconfig.test.json exit 0. --listFiles includes all three test files.
    • @object-ui/types: type-check exit 0.
    • These ran on the tree committed as 8d744575d.
  • Lint on the 10 touched .ts/.tsx files, at 98977bbfe. 0 errors and 34 warnings. The same files on the base tree also give 34 warnings, so this PR adds no new warnings.
    • Population and file count. The ESLint config's **/*.{ts,tsx} block covers all 10 files, and the --format json output has 10 entries.
    • Why the narrowed run is enough. The config has no type-aware linting (no parserOptions.project or projectService), and no rule under eslint-rules/ reads the filesystem. So this diff cannot change the lint verdict on any untouched file.
  • Gates, at 98977bbfe, all exit 0:
    • check-changeset-presence: 1 changeset declared.
    • check-changeset-no-major.
    • check:new-line-citations: 0 new citations.
    • check:control-bytes.
    • check:changeset-claims: 12 pending changesets name touched files. Each was re-read, and all are still true.
    • check:pending-changeset-literals, check:test-path-roots, check:handler-key-reads, check:vi-mock-override-shape.
  • Left to CI: the full components package and the other packages.

Acceptance notes

  • The select label. select.tsx's label has no htmlFor, and the trigger gets no id. Its marker changed shape, but its name never had the asterisk. The new test deliberately does not pin the association either way. See the out-of-scope finding in the report.
  • A weak existing pin. form-renderers.test.tsx's "should show required indicator when required" asserts only text-destructive on the label, although its comment says it checks the indicator. It is unchanged and still green, and it is outside this claim's file surface.
  • Line addresses in form.ts. The file has other cross-file line addresses, for example in the CheckboxSchema.wrapperClass docblock. Only the required docblock was in this claim's surface, so the others are left for whoever next touches them.
  • Blind spot in check:test-path-roots. The new test's recursive walker passes its directory as a parameter. The gate lists those two calls under --blind rather than classifying them. The root is import.meta.url.

Generated by Claude Code

…S generated content (objectui#10368)

The six labelled form renderers drew the required marker as a Tailwind
`::after` content utility on the control's associated label. The
accessible-name computation includes generated content, so Chromium named a
required field labelled "Title" as "Title*". A pseudo-element cannot carry
`aria-hidden`, so the marker is now a real `span aria-hidden="true"` with
`data-required-marker`, the shape the form renderer's FormLabel already uses.

The FieldContainer comment and the CheckboxSchema.required docblock that
described the old style are corrected. The two pins that asserted the clean
name under happy-dom (which computes no generated content) now carry their
verdict on the markup; a new test covers all six sites plus a source guard.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
…ontainer marker comment

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
`renderComponent` takes `BaseSchema`, whose index signature already admits
these renderer keys, so the casts only added lint warnings.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BP8CMtACxTdLjqR6rhd33C
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 12 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/6150-undeclared-but-consumed-keys.md

  • names form/checkbox.tsx → packages/components/src/renderers/form/checkbox.tsx — edited by this change

    | type | key | declared as | read at | |---|---|---|---| | TextSchema | content | string | renderers/basic/text.tsx — {schema.content \|\| schema.value} | | CarouselSchema | opts | RecordANGLE-BRACKETS(string, unknown) | complex/carousel.tsx — opts={schema.opts} | | CarouselSchema | orientation | 'horizontal' \| 'vertical' | complex/carousel.tsx | | CarouselSchema | itemClassName | string | complex/carousel.tsx — per-slide class | | FilterBuilderSchema | wrapperClass | string | complex/filter-builder.tsx | | TreeViewSchema | nodes | TreeNode[] | data-display/tree-view.tsx | | TreeViewSchema | title | string | data-display/tree-view.tsx | | TreeViewSchema | onNodeClick | (node: TreeNode) => void | data-display/tree-view.tsx — INVOKED | | CheckboxSchema | required | boolean | form/checkbox.tsx — drives the * marker | | FileUploadSchema | buttonText | string | form/file-upload.tsx | | FileUploadSchema | wrapperClass | string | form/file-upload.tsx | | HoverCardSchema | align | OverlayAlignment | overlay/hover-card.tsx | | ContextMenuSchema | trigger | SchemaNode \| SchemaNode[] | overlay/context-menu.tsx |

.changeset/6396-previous-values-dom-leak.md

  • names packages/types/src/form.ts → packages/types/src/form.ts — edited by this change

    Scope is the runtime leak only. The declared key stays exactly as declared (packages/types/src/form.ts, packages/types/src/zod/form.zod.ts are untouched): it has a live consumer, so there is nothing here for the enforce-or-remove channel.

.changeset/6783-readprops-degenerate-config-bag.md

  • names text-input.tsx → packages/components/src/renderers/basic/text-input.tsx — edited by this change

    Five modules under packages/components/src/renderers/basic/ — elements.tsx, data-list.tsx, text-input.tsx, record-picker.tsx, metadata-viewer.tsx — each carried a copy of the same reader, { ...(schema?.props ?? {}), ...(schema?.properties ?? {}) }. ?? only replaces null/undefined, so a non-object bag went into the object spread and came back out as indexed keys: for properties: 'not-a-bag', the config bag a renderer received was { '0': 'n', '1': 'o', … '8': 'g' } — nine keys nobody authored. The five copies are now one readProps (renderers/basic/readProps.ts) that asks isConfigBag, and a degenerate bag on either side contributes no keys.

.changeset/6938-checkbox-wrapper-class.md

  • names packages/types/src/form.ts → packages/types/src/form.ts — edited by this change

    packages/components/src/renderers/form/checkbox.tsx:36 reads cn("flex items-center space-x-2", schema.wrapperClass) — classes on the wrapper div around the box and its label — and neither the TypeScript interface in packages/types/src/form.ts nor the zod mirror in zod/form.zod.ts declared the key. It compiled through BaseSchema's index signature and parsed through .passthrough(), admitted unexamined. The same key, on the same class of read, is declared on FileUploadSchema and FilterBuilderSchema (objectui#6150); the checkbox was left out only because its doc page's schema block is a six-line summary.

.changeset/7697-combobox-option-root-barrel.md

  • names src/form.ts → packages/types/src/form.ts — edited by this change

    Additive only. ComboboxOption is added to the root barrel's existing named re-export list from ./form.js, next to the sibling option types that were already there (SelectOption, RadioOption). Nothing is removed, retyped or narrowed: the declaration stays in src/form.ts, its three members (value, label, disabled?) are unchanged, and the @object-ui/types/form subpath spelling keeps working exactly as before. Both spellings now resolve to the same declaration.

.changeset/7722-wrapper-class-five-more.md

  • names form.ts → packages/types/src/form.ts — edited by this change

    Each of renderers/form/switch.tsx, textarea.tsx, date-picker.tsx, select.tsx and renderers/data-display/list.tsx reads schema.wrapperClass onto its wrapper element, and neither the TypeScript interface (form.ts, data-display.ts) nor the zod mirror (zod/form.zod.ts, zod/data-display.zod.ts) declared the key. The reads compiled through BaseSchema's index signature (objectui#5155) and the values parsed through .passthrough(), admitted unexamined. The same key, on the same class of read, is declared on CheckboxSchema (objectui#6938), FileUploadSchema and FilterBuilderSchema (objectui#6150); these five were left out only because their doc pages never listed it.

.changeset/8072-input-wrapper-class-mirrored.md

  • names packages/components/src/renderers/form/input.tsx → packages/components/src/renderers/form/input.tsx — edited by this change

    packages/components/src/renderers/form/input.tsx reads cn("grid w-full items-center gap-1.5", schema.wrapperClass) onto the wrapper div around the input and its label. The TypeScript face has declared the key all along (form.ts, docblock "Input wrapper CSS class"); the zod mirror never did, so the value rode through .passthrough() and { type: 'input', wrapperClass: 42 } validated GREEN — while the identical document on any of the other eight schema.wrapperClass readers (checkbox, file-upload, filter-builder — objectui#6150 / finding(types): 4 more genuinely-read undeclared keys the #6150 census could not see, plus one declared-but-dead key — all on the same 8 renderers #6938; switch, textarea, date-picker, select, list — objectui#7722) was refused at the key.

  • names form.ts → packages/types/src/form.ts — edited by this change

    packages/components/src/renderers/form/input.tsx reads cn("grid w-full items-center gap-1.5", schema.wrapperClass) onto the wrapper div around the input and its label. The TypeScript face has declared the key all along (form.ts, docblock "Input wrapper CSS class"); the zod mirror never did, so the value rode through .passthrough() and { type: 'input', wrapperClass: 42 } validated GREEN — while the identical document on any of the other eight schema.wrapperClass readers (checkbox, file-upload, filter-builder — objectui#6150 / finding(types): 4 more genuinely-read undeclared keys the #6150 census could not see, plus one declared-but-dead key — all on the same 8 renderers #6938; switch, textarea, date-picker, select, list — objectui#7722) was refused at the key.

.changeset/8499-node-slot-registered-arms.md

  • names renderers/form/input.tsx → packages/components/src/renderers/form/input.tsx — edited by this change

    • SemanticElementSchema (zod/layout.zod.ts) — the seven HTML sectioning tags renderers/layout/semantic.tsx registers: aside main header nav footer section article. - HtmlElementSchema (zod/layout.zod.ts) — the 37 safe flow/inline tags renderers/basic/html-elements.tsx registers (h1…h6, p, a, ul, img, …), plus the per-tag keys that module forwards to the DOM (href, target, rel, title, src, alt, width, height, dateTime, cite). - InputShorthandSchema (zod/form.zod.ts) — email / password, the two aliases renderers/form/input.tsx registers onto the input renderer with inputType pinned. inputType is deliberately NOT declared on this arm: the wrapper spreads its own value last, so an authored one is overwritten. - UiCalendarSchema (zod/form.zod.ts) — ui:calendar, the date-picker primitive renderers/form/calendar.tsx registers under exactly that key (skipFallback, because bare calendar belongs to the plugin-calendar view).

.changeset/8762-shorthand-input-type-refusal.md

  • names packages/components/src/renderers/form/input.tsx → packages/components/src/renderers/form/input.tsx — edited by this change

    • Before: { "type": "password", "inputType": "text" } validated green, and the renderer drew a MASKED field anyway. packages/components/src/renderers/form/input.tsx registers both shorthands by wrapping the input renderer and spreading its own inputType LAST, so the authored value was overwritten before the renderer read it. BaseSchema is .passthrough(), so the key even survived into safeParse's output. Nothing anywhere said so. - After: the same document is refused at path: ['inputType'] with guidance naming the key, the reason, and the spelling to write instead. One string feeds both the parse-time message and .describe(), so the generated docs cannot drift from the error.

.changeset/9406-types-root-barrel-two-names.md

  • names form.ts → packages/types/src/form.ts — edited by this change

    form-barrel-mirror-9406.test.ts keeps it closed, and it is DERIVED rather than a pair of presence assertions: it reads form.ts's export list and the root barrel's ./form.js re-export list on every run and names whatever is in the first and not the second. A pin asserting "these two names are present" would pass on the day the next declaration lands in form.ts and is forgotten, which is this class reopening yet again. Names deliberately left off the list get a ledger row carrying the reason instead, and a row goes red once its name reaches the barrel or stops being declared.

.changeset/9526-types-root-barrel-command-element-types.md

  • names form.ts → packages/types/src/form.ts — edited by this change

    The change is purely additive: the two names join the root barrel's existing named re-export list from ./form.js, beside CommandSchema. Nothing is removed, renamed or narrowed, the declarations stay in form.ts, and the /form subpath keeps working. It is the third instance of one class, repaired by the same route each time: objectui#7697 for ComboboxOption, and objectui#9406 (director decision batch 133, item 2, letter (a)) for InputShorthandSchema and UiCalendarSchema.

.changeset/text-input-i18n-label-arms-5717.md

  • names text-input.tsx → packages/components/src/renderers/basic/text-input.tsx — edited by this change

    @objectstack/spec types all three keys as the I18nLabel union (string | RecordANGLE-BRACKETS(string, string)) — measured on the installed 17.1.0 pin, per key, from the schema's own verdicts — and text-input.tsx has resolved all three through pickLocalized at their read sites since it was written. Only the ComponentMeta entries stayed at a single 'string' arm. Driven through the same manifestFromConfigs + validateTree pair the JSX-page compiler and the save gate use, an author writing { en: 'Owner', 'zh-CN': '负责人' } got three warnings on a write that renders correctly in the viewer's language:

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Angle-bracketed names in the quoted prose above are rewritten as ANGLE-BRACKETS(name): GitHub deletes tag-shaped fragments from a stored body, and a quote that silently loses the identifier it is about is worse than a visible repair.

Compared the checked-out tree with 3335767b4 (merge-base with origin/main): 10 file(s) changed outside .changeset/, read against 1371 pending declaration(s) that publish a body (1946 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3043.7 KB 3104.5 KB
Main entry chunk (gzip) 148.4 KB 350 KB
Entry file index-Bf9_mFdk.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.68KB 6.20KB
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) 27.95KB 10.04KB
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.52KB 3.45KB
auth (index.js) 3.63KB 1.64KB
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) 543.20KB 129.69KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 224.12KB 62.26KB
fields (index.js) 255.56KB 64.73KB
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) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.28KB 11.09KB
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) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
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) 16.01KB 3.93KB
plugin-calendar (index.js) 51.44KB 14.62KB
plugin-charts (index.js) 72.54KB 20.26KB
plugin-chatbot (index.js) 198.27KB 47.18KB
plugin-dashboard (index.js) 133.45KB 35.33KB
plugin-designer (index.js) 216.12KB 44.37KB
plugin-detail (index.js) 260.94KB 67.93KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 146.34KB 37.47KB
plugin-gantt (index.js) 168.47KB 41.53KB
plugin-grid (index.js) 215.43KB 58.90KB
plugin-kanban (index.js) 49.30KB 15.39KB
plugin-list (index.js) 114.36KB 28.24KB
plugin-map (index.js) 22.05KB 7.14KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.55KB 11.99KB
plugin-timeline (index.js) 30.72KB 8.96KB
plugin-tree (index.js) 10.74KB 3.76KB
plugin-view (index.js) 85.86KB 21.38KB
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) 114.58KB 37.60KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.78KB 2.56KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 18.27KB 6.20KB
types (ai.js) 4.11KB 2.06KB
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) 5.00KB 2.39KB
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) 17.15KB 6.32KB
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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 98977bbfe5d8372efcdc0f7a70f8a9aa65a61dd4

Rendered by an isolated review subagent spawned by the domain:ui#4 seat; its served tier was checked against its transcript stamps (71 of 71 model stamps at the review tier). Adopted by this seat.

① Derived judgments

  • (a) Completeness — no generated-content required marker survives. git grep at the head over the whole packages/ tree (and, as a control, the whole tree) for content-['*'], content-["*"], after:content, before:content, ::after beside an asterisk, and a raw CSS content: '*' rule. Every hit: packages/components/src/__tests__/required-marker-markup.test.tsx:210 and :213 (the new test's own pattern control, a test file); packages/plugin-markdown/src/MarkdownImpl.tsx:212 (prose-code:before:content-none prose-code:after:content-none, a typography reset with no asterisk and no label: unrelated); prose-only mentions of ::after in the two pins' and the new test's headers, in packages/types/src/form.ts:460, and in empty-base-classes-override-friendly-8525.test.tsx:331-332 (the preflight border rule: unrelated). No raw CSS asterisk rule anywhere. At the merge-base 2fc2a243911c62f9cb24366a66453ea3e89b337c the whole tree had eleven hits: the six class sites, the field.tsx comment, the form.ts docblock and three test-header mentions; all eleven are gone at the head. The sibling card objectui#10367's surfaces (ScreenView, SchemaForm, AppCreationWizard) produce no hit at base or head, so they are a different mechanism, not a remaining site. The new source guard: rooted on import.meta.url (the test's directory's parent, i.e. packages/components/src), it walks recursively, skips __tests__ and node_modules directories and any *.test.ts(x) file, reads only .ts/.tsx files, and rejects any line matching content-\[\s*['"]?[_ ]*\*. That is exactly what it claims (non-test .ts/.tsx under this package's src); it does not read index.css or sidebar-fixes.css (a hand-written CSS asterisk rule would slip past it) and does not reach other packages, both consistent with its title "anywhere in this package". It cannot pass vacuously: the population control asserts the six site paths are in the walked set (an empty or mis-rooted walk goes red) and the pattern control asserts the regex accepts four spellings and rejects content-none. Those test-file literals do not re-enter the compiled sheet: packages/components/src/index.css uses source(none) with @source '../src/**/*.{ts,tsx}' minus ./**/*.test.{ts,tsx} and ./**/__tests__/**, the only @source lines in the package.
  • (b) The shape at each site. All six render, inside the Label primitive (Radix react-label root, which renders a label element), immediately after {label}: a span element with className="ml-0.5 text-destructive", data-required-marker="true", aria-hidden="true" and the text *, gated by the site's required flag. JSX trims the line breaks, so no whitespace text node sits between the label text and the span. Five labels carry htmlFor to the control's id (field.tsx fieldId, text-input.tsx schema?.id, input.tsx/textarea.tsx/checkbox.tsx schema.id); select.tsx has none (see g). None of the six uses aria-labelledby, and no control is named by a node that contains the span without aria-hidden. Under the accessible-name computation the label-for subtree is read with aria-hidden="true" subtrees excluded, so the name is Title with the span out. The new test asserts this structurally at every site (textOutsideAriaHidden(label) === 'Title', exactly one [data-required-marker], aria-hidden="true", no content-[/content-( class token on the label or any descendant). Visual parity by reading: after:ml-0.5/after:text-destructive on the pseudo-element became ml-0.5/text-destructive on an inline span; the four form renderers keep text-destructive on the label itself as on base. The dev's pixel-identical screenshot claim was not re-run here.
  • (c) The required state is unchanged. git diff base..head over the six files: no added or removed line contains required= or aria-required other than three comment lines. Unchanged at head: field.tsx aria-required={required || undefined} on the Slot and still no native required; text-input.tsx required={props.required} on Input; input.tsx and textarea.tsx required={schema.required}; checkbox.tsx required={schema.required} on the Radix Checkbox (which writes aria-required on the role=checkbox button); select.tsx required={schema.required} on the Radix Select (which writes aria-required on the combobox trigger). No validation code is touched.
  • (d) The pins. Pin 1 field-container-aria-required.test.tsx: 5 it( at base, 5 at head, none skipped/only/todo. The last case is retitled and its verdict is now markup (no generated-content class token on the label or descendants; exactly one [data-required-marker] with aria-hidden="true" and text *; a clone with aria-hidden subtrees removed has text Title; aria-required still true); toHaveAccessibleName('Title') is kept last under a comment that names it "Corroboration, NOT the evidence" and says why (it goes red if the span loses aria-hidden, but is blind to generated content). The header now says the asterisk did reach the name. Pin 2 text-input-description-association.test.tsx: 9 it( at base, 10 at head, none skipped/only/todo. The dev's claim is verified at base: the word required appears only in the header (line 37), no case authored required, and the header already said "It IS part of the accessible name in a real browser. No case below depends on that either way." So pin 2 never asserted a clean name for a required field; only its "second limit" paragraph described the old markup, and that paragraph is rewritten. The added case renders with required: true, judges the markup, checks the description paragraph does not contain the marker, and pins the native required. New test: describe.each over six sites × (required, optional) = 12 cases plus three guard cases = 15; 15 + 5 + 10 = 30, matching "of 30". Ablation arithmetic is consistent with the case list: reverting field.tsx can only redden the FieldContainer required case, the guard's offender case and pin 1's rewritten case (3; the optional case stays green because the utility was gated by required &&); text-input.tsx the text_input required case, the guard and pin 2's new case (3); form/input.tsx the input required case and the guard (2), since no pin renders the input renderer with required. Not re-run here.
  • (e) The types docblock. packages/types/src/form.ts CheckboxSchema.required: the old text cited checkbox.tsx:45 and :49 and quoted the pseudo-element utility; the new text carries no line address and cites by content. Both citations are true at head: required={schema.required} on the Radix Checkbox is checkbox.tsx line 48, and the label carries schema.required && "text-destructive" (line 52) with the aria-hidden * span bearing data-required-marker (lines 61-63). The zod mirror's describe text for the same key ("sets required on the Radix Checkbox and gates the label's * marker") is untouched and still true.
  • (f) Changeset truth. .changeset/10368-required-marker-real-span.md declares '@object-ui/components': patch. Every sentence holds at head: the six sites drew the marker as an ::after utility on the label (base grep); the name computation includes generated content and a pseudo-element cannot carry aria-hidden (spec facts; the browser measurement is the dev's); the span shape matches the form renderer's FormLabel marker in form.tsx (span, data-required-marker="true", aria-hidden="true"); colour and spacing tokens are the same classes; every control keeps its required state (c); validation is untouched; the select label is unassociated so its name never held the asterisk (g); the utility is no longer in any Tailwind-scanned source (a); the Testing Library sentence is true by reading of getByLabelText's matcher, which compares the label's whole text (Title* now) under an exact string, while getByRole with name computes the accessible name and honours aria-hidden. No line-address citation in the file. @object-ui/types is not declared; acceptable, since the presence gate needs one declaration per change (an empty frontmatter counts), the fixed group versions all packages together, and the types edit is docblock prose. Pending changesets naming touched files, spot-checked at head: 6150-undeclared-but-consumed-keys.md ("drives the * marker": still true), 8478-zod-pins-form-layout.md (past-tense drift measurement and the content citation "gates the label's * marker": still true), 8478-zod-pins-complex.md (historical: still true), record-picker-label-association-5771.md (the Label primitive element:text_input uses: still true), text-input-description-aria-describedby-5735.md (the label half wired by htmlFor: still true), 6938-checkbox-wrapper-class.md (its checkbox.tsx:36 address still resolves to the wrapperClass line, because this PR adds lines only below line 52: still true).
  • (g) The dev's out-of-scope finding is real, and this PR does not worsen it. At head select.tsx renders Label with no htmlFor (line 51) and SelectTrigger with no id (line 75); nothing sets aria-label or aria-labelledby. The trigger is a button with role combobox, a role that does not take its name from content, so the placeholder or value text does not name it: a labelled ui:select combobox has an empty accessible name. This PR leaves the label unassociated exactly as on base and only changes the marker's shape; because the new span carries aria-hidden, a later association fix (the record-picker-label-association-5771 shape: htmlFor/id on the trigger) would yield a clean name. The new test deliberately does not pin the association either way.

② Semver level

patch on @object-ui/components is right; @object-ui/types is not declared and need not be. The rule, objectui AGENTS.md "版本号策略": "minor/patch 独立演进 —— objectstack 没动时不必跟发;objectui 自己的改动照常用 changeset 推进" and "changeset 里不要声明 major —— fixed 组任一 major 都会把全组推上去、脱离 objectstack 的节奏 …。objectui 自身的破坏性变更也标 minor(在正文里写清 breaking 语义即可);唯一例外是跟随 objectstack 跨 major 的那一次同步升级" (enforced by scripts/check-changeset-no-major.mjs). So the only question is breaking (minor) versus not (patch). Not breaking in the rule's sense: no export, prop, schema key, type or registered input moves (Clause-②: no, confirmed by the diff); the painted output is the same classes; the accessible-name change is the fix itself. DOM markup consumers: a label element gains one child span and loses three after: utility classes. Snapshot drift is ordinary patch-level churn; a CSS selector on an after: utility class is not a supported surface; and data-required-marker has been the package's published locator since the FormLabel span, which the components CHANGELOG records under a "Patch Changes" heading. The one downstream effect (an exact-string getByLabelText no longer matching a required field) is disclosed in the changeset and already applied to every FormLabel field. Precedent inside the same package for the same shape is patch.

③ Boundary flags

  • Closing keywords: Fixes #10368 is the only closing-keyword reference in the body; the title and all four commit messages carry none. The body's other issue mentions (objectui#3299, objectui#10361) are prose without a keyword.
  • Model identifiers: none in the PR title, the body, or any of the four commit messages or trailers. Trailers are a Co-authored-by line naming only Claude with a noreply address, and a Claude-Session URL.
  • File surface: 11 files, matching the claim exactly: the six sites, packages/types/src/form.ts, the two pins, one new test beside them (__tests__/required-marker-markup.test.tsx), and one .changeset/10368-required-marker-real-span.md. Commit chain: 304f68f82 to 8d744575d changes only the new test (5 lines), 8d744575d to the head changes only the changeset (the Testing Library paragraph), as the dev states.
  • Serial: all 16 other open PRs' file lists read (the release PR chore: release packages #5400's 1732 files walked in full). One overlap: docs(types): the content-channel tombstones state the BaseSchema premise in the tense it is true (objectui#9933) #10417 (docs(types), objectui#9933, not a draft) touches packages/types/src/form.ts, but its 28 hunks sit on the body/children tombstone docblocks (nearest ranges 377-388 and 496-507) and none reaches the CheckboxSchema.required docblock (453-463): no textual conflict, and no semantic overlap. No other open PR touches any of the 11 files.
  • mergeable_state: behind on the first read (branch merge-base 2fc2a2439 behind main), clean on the final read after the checks completed; mergeable: true both times. The PR is a draft.
  • Check-runs on the head: 43 total after three foreground polls at 60 s (all completed by the third poll at 00:52 UTC): 40 success, 3 skipped, 0 failed, 0 in progress. The three non-success are all skips: Test (coverage), Test (coverage shard ${{ matrix.shard }}/4) (the unexpanded matrix placeholder), and dependabot. Combined commit status: success.

Implemented-by: claude/issue-10368-required-marker-real-span
Reviewed-by: session_01BP8CMtACxTdLjqR6rhd33C

VERDICT: PASS


Generated by Claude Code

@os-litant
os-litant marked this pull request as ready for review September 25, 2026 00:59
@os-litant
os-litant added this pull request to the merge queue Sep 25, 2026
Merged via the queue into main with commit 0961d5e Sep 25, 2026
45 checks passed
@os-litant
os-litant deleted the claude/issue-10368-required-marker-real-span branch September 25, 2026 01:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants