Skip to content

feat(components): make ui:text honour the published variant and align enums - #7445

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-6942-text-variant-enforce
Sep 3, 2026
Merged

os-project-manager merged 1 commit into
mainfrom
claude/issue-6942-text-variant-enforce

Conversation

@claude

@claude claude Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Fixes #6942

The ui:text renderer now reads TextSchema.variant and TextSchema.align. The published enums do not move — packages/types/src/zod/layout.zod.ts is untouched — the renderer catches up to them. Authority: the director-seat ruling in comment 5509027843 (summon #8, 2026-09-02, maintainer verbatim 「7189 A 其他同意」), option B1, ENFORCE. Clause-② no.

The class map, and what it was modelled on

ElementTextRenderer already carries this concept for the element:text namespace: packages/components/src/renderers/basic/elements.tsx, VARIANT_CLASS and ALIGN_CLASS (lines 100-112), consumed by ElementTextRenderer at line 114. The new map follows it as closely as the two vocabularies allow — three entries are its entries verbatim:

ui:text value class element provenance
h1 text-4xl font-semibold tracking-tight h1 the element:text heading idiom, one Tailwind step up
h2 text-3xl font-semibold tracking-tight h2 same idiom
h3 text-2xl font-semibold tracking-tight h3 verbatim element:text's heading
h4 text-xl font-semibold tracking-tight h4 same idiom
h5 text-lg font-semibold tracking-tight h5 same idiom (its subheading size, heading weight)
h6 text-base font-semibold tracking-tight h6 same idiom
body text-sm text-foreground span verbatim element:text's body
caption text-xs text-muted-foreground span verbatim element:text's caption
overline text-xs font-medium uppercase tracking-widest text-muted-foreground span the only published value with no element:text sibling — conventional small-caps label treatment

align: left / center / right / justify map to block text-left … block text-justify. Four values, not ElementTextRenderer's three, because TextSchema publishes justify as well.

⛔ The two vocabularies are not reconciled here and element:text is not touched — that drift is the follow-up card the ruling reserves.

Four judgement calls a reviewer should look at directly

  1. Absence is not body. The Zod mirror declares .default('body'), which materialises on a parsed document; the renderer is handed the authored node. Synthesising the default here would put text-sm text-foreground on the ~690 corpus text nodes that never asked for a variant — a corpus-wide restyle, not the repair that was scoped. A node authoring neither key renders byte-identically to before.
  2. block is paired with every align class. Three of the nine variants render an inline span, and text-center on an inline box centres nothing — declaring align and shipping it inert would re-enter this card's own failure mode one layer down. On the heading elements block is a no-op.
  3. h1-h6 render the heading element they name. A value that says h1 and paints a span would deliver the size and withhold the semantics. body / caption / overline keep the span this renderer has always wrapped with, so nothing about inline usage changes for them.
  4. inputs declares defaultValue: 'body' for variant. That restates the published default for a designer seeding a new node; it is not a claim about the absence path (1).

The five catalog entries — per-entry decision

entry old value decision why
components-basic-text/paragraph p → body (file kept) shadcn p is body copy and body is the published spelling of exactly that. The only exact match of the five.
components-basic-text/small small → caption, renamed caption.json shadcn small is the small de-emphasised step; caption is the published value for that role. Content re-authored "Caption text" so the tile names what it demonstrates.
components-basic-text/lead lead → h4, renamed heading-4.json No published value means "large muted intro paragraph". lead is the larger of the two intermediate shadcn steps (text-xl against large's text-lg), so it takes the higher remaining published step.
components-basic-text/large large → h5, renamed heading-5.json shadcn large is text-lg font-semibold — the next step down, and what the published h5 step renders here.
components-basic-text/muted muted deleted muted names a COLOUR, not a typographic step, so no published value expresses what it meant. Re-spelling it caption would demonstrate the same rendering as the re-authored small.json — the duplicate this must not produce — and components-basic-text/text-with-colors already demonstrates text-muted-foreground on a text node (its third child is literally "Muted Text").

No published value is demonstrated twice. The category now authors h1, h2, h3, h4, h5, body and caption across seven tiles, plus simple-text (no variant) and text-alignment (three alignments, which now paint). Two published values, h6 and overline, are demonstrated by no catalog entry — closing that would mean inventing two new entries rather than re-authoring one of the five, which is outside this card's fence. Both are covered by the renderer pin, and they are the obvious content for a follow-up if the seat wants full catalog coverage.

Repairs beyond the five, each forced by the change and named here

  • examples/schema-catalog/src/schemas/components-feedback-loading/with-text.json — a sixth node in the same shadcn scale (variant: "muted"), sitting outside the category anyone was counting. Same defect class, one published value available for a spinner's small de-emphasised label (caption), no other claim on the file, same test suite. Repaired with the five and pinned by a corpus-wide sweep, so a seventh cannot arrive unnoticed.
  • content/docs/components/basic/text.mdx — forced twice over: the page named the deleted and renamed ids (getExample throws on an unknown id, so a stale id is a build failure, not a cosmetic one), and its schema block taught a fifth vocabulary — h1-h4, p, lead, large, small, muted — that the validator refuses. Now the published nine.
  • examples/schema-catalog/test/layout-dom-leak-5574.test.tsx — NODE_CENSUS for text moves 702 -> 701 rendered and 176 -> 162 no-element. Arithmetic, in the comment: one node fewer (muted.json deleted) and 13 nodes leaving the no-element class because a node that authors variant or align now needs an element to carry the class — the 7 variant-carrying components-basic-text entries, the 3 text-alignment children, the with-text label, and the h3 headings in basic-container and basic-stack. The census moves in the SAFE direction: fewer phantom-clean nodes, more elements actually read.
  • examples/schema-catalog/test/catalog-gallery-render.test.tsx — a comment whose example ("components-basic-text/* render a bare text node … 'Heading 1' is a direct child of the wrapper") is now false. Re-pointed at simple-text, which still takes that path. Comment only; the helper's logic is unchanged.

Tests

New pins, both derive the vocabulary from the Zod mirror at runtime rather than restating it:

  • packages/components/src/__tests__/text-variant-align-6942.test.tsx (57 tests) — one rendered element per published value; the nine class strings asserted pairwise distinct; the heading elements; align mapped for all four values and the four distinct; variant+align composition; authored className survives; the 53 in-repo schema files carry a registered ObjectUI type but fail safeValidateSchema #6318 triage table now discriminating (absent, body, h1 are three different renderings, where the finding measured them byte-identical); and the not-C control — the five shadcn spellings are still refused by safeValidateSchema, every published value validates, and an off-enum value is not quietly styled either.
  • examples/schema-catalog/test/text-variant-corpus-6942.test.tsx (24 tests) — the corpus half: a sweep over every text node in all 432 catalog entries asserting each authored variant/align is a published value (named, not counted, on failure), the per-entry re-authoring, every components-basic-text entry validating, and the seven variant-carrying tiles rendering seven classes no sibling renders.

Red-then-green ablation

Each mutation proved on disk before its run (anchored grep -cF of both the removed and the injected text, plus a blob hash differing from HEAD's); each restore proved by state — git diff HEAD empty and git hash-object equal to the HEAD blob — never by an exit code; trap ... EXIT INT TERM with absolute paths throughout. No rebuild leg: vitest aliases @object-ui/components to packages/components/src (vitest.config.mts:415) and the component pin imports ../renderers relatively, so neither pin resolves through dist.

leg mutation on-disk proof result
A the variant read replaced with undefined (the pre-card state) removed-text count 0, injected 1, blob 2a6bcd0e vs HEAD 1a66d9e7 23 failed / 58 passed
B the align read replaced with undefined removed 0, injected 1, blob 1eaeee82 7 failed / 74 passed — exactly the align pins and the corpus alignment tile
C h5's class set equal to h4's removed 0, injected 1, blob 6941a340 2 failed / 79 passed — only the two distinctness pins, so distinctness is not vacuous
green restored git diff HEAD empty, blob 1a66d9e7 = HEAD 81 passed

The union, on 56b3edd15

The tree these ran on is byte-identical to the commit (git status --porcelain empty; the ablation's green leg re-ran both pins after the commit).

  • pnpm exec vitest run --maxWorkers=2 packages/components/ — 226 files, 2129 tests passed
  • pnpm exec vitest run --maxWorkers=2 examples/schema-catalog/ — 20 files, 1945 tests passed
  • turbo run lint --filter=@object-ui/components --filter=@object-ui/example-schema-catalog — 3 tasks successful, 0 errors (950 pre-existing warnings, none in a file this PR adds or edits)
  • turbo run type-check for both packages — 31 tasks successful. Verified it actually covers the new files: tsc -p tsconfig.test.json --listFiles lists text-variant-align-6942.test.tsx, text-variant-corpus-6942.test.tsx and the edited renderers/basic/text.tsx.
  • Gates: check:control-bytes, check:doc-types, check:doc-fences, check:doc-snippets (after the build its own preflight names — its first run reported PRECONDITION NOT MET, which is not a verdict), regenerate-catalog-index.py --check (index up to date, 432 entries), check-changeset-presence / -no-major / -fixed / -overwrite — all green.
  • pnpm check (the CLI's own corpus scan): the "carries a registered ObjectUI component type but did not validate" bucket now lists 23 files and no components-basic-text or components-feedback-loading entry is among them. The safe-validate-corpus-6318 docblock recorded 28 in that bucket when it was written; the five entries this PR re-authors are the difference.

Changeset: @object-ui/components, patch. Catalog entries and docs carry none.

Handed on, not fixed here

  • finding(examples,docs): the color key three catalog text nodes author — and the docs page teaches — is declared by nothing and read by nothing #7444 (filed by this seat) — the same catalog category authors color on three text nodes and the docs page teaches it, while TextSchema declares no such key and nothing reads it. Different key, not published at all, so honouring it would be a vocabulary change rather than a renderer repair.
  • packages/cli/src/__tests__/check-validity-recogniser.test.ts:157 carries a comment describing its inline fixture as "copied verbatim from …/components-basic-text/small.json: real corpus content". The assertion is unaffected — variant: 'small' is still refused, deliberately — but that provenance is now historical rather than current. Left untouched: editing it would pull packages/cli into this PR's verification surface for a comment.

Behaviour note, so it is not read as a regression

An external author who wrote variant: 'h1' and saw nothing will now see a heading. That is the declared behaviour arriving, as the ruling states. A node that never authored variant or align renders exactly as it did before.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC


Generated by Claude Code

…align` enums

`TextSchema.variant` is a nine-value enum (`h1`…`h6`, `body`, `caption`,
`overline`) and `TextSchema.align` a four-value one, and the `ui:text` renderer
read neither: `variant: 'small'`, `variant: 'body'`, `variant: 'h1'` and the key
absent all produced byte-identical DOM, while the enum still refused five
`examples/schema-catalog` entries written in the shadcn scale. Declared,
published, validated against — and unread.

The maintainer ruling of 2026-09-02 took ENFORCE over retiring the key, over
replacing the enum with the shadcn scale, and over widening it without
implementing it. So the enum does not move; the renderer catches up to it:

  - every published variant maps to a typographic class no other value carries,
    modelled on `ElementTextRenderer`'s existing map (`h3` is its `heading`
    verbatim, `body` and `caption` are its own entries);
  - `h1`…`h6` render the heading element they name; `body`, `caption` and
    `overline` keep the inline span this renderer has always wrapped with;
  - all four alignments reach the DOM, each paired with `block` so alignment is
    visible on the inline arm rather than declared and inert;
  - the registration's `inputs` declare both keys, with the published enums;
  - a node authoring neither key renders exactly as before — the Zod mirror's
    `.default('body')` materialises on a PARSED document, and synthesising it
    here would restyle every corpus text node that never asked for a variant.

Corpus: `lead` → `heading-4` (`h4`), `large` → `heading-5` (`h5`), `small` →
`caption` (`caption`), `paragraph` → `body`, and `muted` deleted — it names a
colour, not a typographic step, and `text-with-colors` already demonstrates it.
A sixth node in the same shadcn scale, `components-feedback-loading/with-text`,
is repaired with them and pinned by a corpus-wide sweep.

Fixes #6942

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

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 48 chunks) 3178.8 KB 3191.4 KB
Main entry chunk (gzip) 143.2 KB 350 KB
Entry file index-Brm1fWPZ.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) 15.67KB 5.75KB
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) 5.13KB 2.35KB
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) 516.24KB 117.82KB
core (index.js) 5.80KB 2.32KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 178.20KB 49.60KB
fields (index.js) 244.08KB 61.71KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.44KB 1.39KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 33.40KB 8.71KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
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.55KB 0.62KB
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 (useResponsiveConfig.js) 1.37KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
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) 5.12KB 1.74KB
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) 15.75KB 3.80KB
plugin-calendar (index.js) 47.14KB 13.02KB
plugin-charts (index.js) 70.31KB 19.55KB
plugin-chatbot (index.js) 196.19KB 46.43KB
plugin-dashboard (index.js) 132.79KB 34.62KB
plugin-designer (index.js) 212.87KB 43.19KB
plugin-detail (index.js) 251.27KB 64.20KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.87KB 32.66KB
plugin-gantt (index.js) 167.18KB 40.94KB
plugin-grid (index.js) 209.25KB 56.71KB
plugin-kanban (index.js) 53.21KB 14.66KB
plugin-list (index.js) 113.53KB 27.66KB
plugin-map (index.js) 20.20KB 6.66KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.51KB 11.94KB
plugin-timeline (index.js) 30.21KB 8.66KB
plugin-tree (index.js) 8.98KB 3.08KB
plugin-view (index.js) 85.90KB 21.12KB
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) 3.11KB 1.48KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
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) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
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 (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) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
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

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.

2 participants