Skip to content

catalog: author layout props as props, and rename spacing to gap (#4891, #4890) - #5691

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-4891-catalog-layout-props-sweep
Aug 22, 2026
Merged

catalog: author layout props as props, and rename spacing to gap (#4891, #4890)#5691
os-sales merged 1 commit into
mainfrom
claude/issue-4891-catalog-layout-props-sweep

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#4891
Fixes#4890

Two cards, one sweep, per triage (comment 5335896302): the same 39 catalog files, the same anti-pattern family, and the same ratchet landing spot.

Direction was fixed by triage and is followed literally: producer-side rename first, then delete the (schema as any).spacing consumer leg.spacing is not legalised into StackSchema; gap is already there, and a second name for one thing is what leaves AI authors guessing.

Re-measured on origin/main before touching anything

The counts are inherited from cards written days ago, so they were re-derived rather than trusted. They reproduced exactly — this was the byte-identical outcome, not the drifted one:

cardfigure as filedre-measured at 3b147a367
#4891140 nodes, 33 files140 nodes, 33 files
#4891token hits flex 221 / container 7 / stack 3flex 221 / container 7 / stack 3 (grid 0) — 231 total
#4890135 nodes, 39 files135 nodes, 39 files
triage"33/39 overlapping files"all 33 of #4891's files are among #4890's 39; 39 files touched in total

Measured by parsing all 423 catalog JSON documents and walking the trees, not by grep — the shapes being counted are multi-line object literals and multi-token class strings, which a line-oriented grep mis-reads in both directions. Three secondary readings that shaped the work:

  • Every one of the 135 spacing values is in {0, 1, 2, 3, 4, 6}, all steps stack.tsx actually maps, so the rename is render-neutral for every node. "spacing" occurs 135 times in the catalog as raw text and 135 times as a parsed node key — the two agreeing is what rules out a spacing hiding inside a string value.
  • On origin/main, of 153 catalog stack nodes, 18 already said gap and 135 said spacing; none said neither. So "all 153 declare a gap" is a complete statement of the rename having landed, and it is asserted.
  • No node anywhere carried both keys, and no node's existing prop disagreed with the class being extracted — so nothing in this sweep had to choose a winner.

What is left in className, deliberately

Breakpoint- and state-prefixed tokens (md:items-start, hover:bg-accent/50) stay, because the props are not responsive and flattening one would silently discard the breakpoint. So does everything decorative (border-b, bg-muted), everything belonging to a different type (max-w-md on a flex — a flex has no maxWidth prop, so the class is the only way to say it), and everything describing the node as a flex item rather than as a container (flex-shrink-0, space-x-2).

Gap steps outside a renderer's ladder are also not offenders: flex.tsx has no gap === 9 arm, so moving a gap-9 out of className would delete the spacing rather than declare it. The ratchet only demands extraction where extraction is lossless.

The one deliberate rendering change

gap and padding render a mobile-first laddergap: 3 is gap-2 sm:gap-3, not gap-3. Below sm that is genuinely different from the hand-written single value, and it is the correct direction: the ladder is what the prop means, and giving it up is half of what these 140 nodes were giving up. #4003 pinned the same delta for the same reason. Every such case is sampled and pinned on both sides (see Verification).

container padding gets a second, smaller improvement: a container writing p-4 in className rendered p-4 sm:p-3 md:p-4 — the default ladder with only its base step overridden, so padding shrank between 640px and 768px. As padding: 4 it is the clean p-2 sm:p-3 md:p-4.

Per-node itemisation — #4891, 140 nodes / 33 files / 231 tokens

Grouped by distinct (type, before, after) conversion; 44 conversions, every one of the 140 nodes listed exactly once under the conversion that produced it. n is the node count per row and the column sums to 140.

ntypebefore classNameafter落点
14flexitems-center gap-4 px-4 py-3 border-b text-smalign: "center"gap: 4 + className: "px-4 py-3 border-b text-sm"plugin-grid/product-inventory-grid.children[1].children[1] · plugin-grid/product-inventory-grid.children[1].children[2] · plugin-grid/product-inventory-grid.children[1].children[3] · plugin-grid/product-inventory-grid.children[1].children[4] · plugin-grid/team-members-grid.children[1].children[1] · plugin-grid/team-members-grid.children[1].children[2] · plugin-grid/team-members-grid.children[1].children[3] · plugin-view/grid-view-mode.children[1].children[1] · plugin-view/grid-view-mode.children[1].children[2] · plugin-view/grid-view-mode.children[1].children[3] · report/report-breakdown-table.children[1].children[1] · report/report-breakdown-table.children[1].children[2] · report/report-breakdown-table.children[1].children[3] · report/report-breakdown-table.children[1].children[4]
12flexitems-centeralign: "center"(className dropped)dashboard/dashboard-overview.children[2].children[1].children[0].children[1].children[0] · marketing/pricing-table.children[1].children[0].children[0].children[4].children[0] · marketing/pricing-table.children[1].children[0].children[0].children[4].children[1] · marketing/pricing-table.children[1].children[0].children[0].children[4].children[2] · marketing/pricing-table.children[1].children[1].children[0].children[4].children[0] · marketing/pricing-table.children[1].children[1].children[0].children[4].children[1] · marketing/pricing-table.children[1].children[1].children[0].children[4].children[2] · marketing/pricing-table.children[1].children[1].children[0].children[4].children[3] · marketing/pricing-table.children[1].children[2].children[0].children[4].children[0] · marketing/pricing-table.children[1].children[2].children[0].children[4].children[1] · marketing/pricing-table.children[1].children[2].children[0].children[4].children[2] · marketing/pricing-table.children[1].children[2].children[0].children[4].children[3]
9flexitems-center gap-2align: "center"gap: 2(className dropped)app/application-header.children[1] · marketing/call-to-action.children[0].children[3].children[0] · marketing/call-to-action.children[0].children[3].children[1] · marketing/call-to-action.children[0].children[3].children[2] · plugin-view/detail-view-mode.children[0].children[0] · plugin-view/detail-view-mode.children[0].children[1] · plugin-view/detail-view-mode.children[0].children[2] · plugin-view/detail-view-mode.children[0].children[3] · report/report-scheduling.header[0]
8flexitems-center justify-betweenalign: "center"justify: "between"(className dropped)auth/login-simple.children[0].children[2] · blocks-gallery/block-gallery-stats-card.children[0].children[0] · dashboard/dashboard-overview.children[0] · dashboard/recent-activity-card.children[0].children[1].children[4] · ecommerce/shopping-cart.children[0].children[0] · forms/settings-form.children[1].children[0].children[2].children[1] · forms/settings-form.children[1].children[0].children[2].children[2] · report/report-header-with-kpis.children[0]
8flexjustify-between text-smjustify: "between" + className: "text-sm"ecommerce/order-summary.children[2].children[0].children[1].children[0] · ecommerce/order-summary.children[2].children[0].children[1].children[1] · ecommerce/order-summary.children[2].children[0].children[3].children[0] · ecommerce/order-summary.children[2].children[0].children[3].children[1] · ecommerce/order-summary.children[2].children[0].children[3].children[2] · ecommerce/shopping-cart.children[0].children[4].children[0] · ecommerce/shopping-cart.children[0].children[4].children[1] · ecommerce/shopping-cart.children[0].children[4].children[2]
6flexitems-center gap-3align: "center"gap: 3(className dropped)app/application-header.children[0] · ecommerce/order-summary.children[1].children[1].children[0].children[1] · marketing/testimonials.children[1].children[0].children[0].children[2] · marketing/testimonials.children[1].children[1].children[0].children[2] · marketing/testimonials.children[1].children[2].children[0].children[2] · plugin-view/detail-view-mode.header[0]
5flexitems-center gap-2 px-3 py-2 rounded-md hover:bg-accent/50align: "center"gap: 2 + className: "px-3 py-2 rounded-md hover:bg-accent/50"app/sidebar-navigation.children[0].children[1] · app/sidebar-navigation.children[0].children[2] · app/sidebar-navigation.children[0].children[5] · app/sidebar-navigation.children[0].children[6] · app/sidebar-navigation.children[0].children[8]
5flexitems-center justify-between mb-4align: "center"justify: "between" + className: "mb-4"dashboard/recent-activity-card.children[0].children[0] · plugin-grid/product-inventory-grid.children[0] · plugin-grid/team-members-grid.children[0] · plugin-view/grid-view-mode.children[0] · report/report-breakdown-table.children[0]
5flexitems-center space-x-4align: "center" + className: "space-x-4"dashboard/recent-activity-card.children[0].children[1].children[0].children[0] · dashboard/recent-activity-card.children[0].children[1].children[1].children[0] · dashboard/recent-activity-card.children[0].children[1].children[2].children[0] · dashboard/recent-activity-card.children[0].children[1].children[3].children[0] · dashboard/recent-activity-card.children[0].children[1].children[4].children[0]
4flexgap-2 flex-wrapgap: 2wrap: true(className dropped)actions/action-button-variants.children[0] · block-schema/block-marketplace-listing.children[1].children[2] · theme/theme-aware-ui-elements.children[0] · theme/theme-aware-ui-elements.children[1]
4containerp-4padding: 4(className dropped)dashboard/dashboard-overview.children[1].children[0].children[0] · dashboard/dashboard-overview.children[1].children[1].children[0] · dashboard/dashboard-overview.children[1].children[2].children[0] · dashboard/dashboard-overview.children[1].children[3].children[0]
4flexitems-center justify-between pb-4 border-balign: "center"justify: "between" + className: "pb-4 border-b"dashboard/recent-activity-card.children[0].children[1].children[0] · dashboard/recent-activity-card.children[0].children[1].children[1] · dashboard/recent-activity-card.children[0].children[1].children[2] · dashboard/recent-activity-card.children[0].children[1].children[3]
4flexitems-center gap-1align: "center"gap: 1(className dropped)ecommerce/product-card.children[1].children[1] · marketing/testimonials.children[1].children[0].children[0].children[0] · marketing/testimonials.children[1].children[1].children[0].children[0] · marketing/testimonials.children[1].children[2].children[0].children[0]
4flexgap-2gap: 2(className dropped)ecommerce/product-card.children[1].children[3] · plugin-grid/product-inventory-grid.children[0].children[1] · report/report-header-with-kpis.children[0].children[1] · report/report-scheduling.children[0].children[3]
4flexitems-center gap-4 px-4 py-2 bg-muted text-xs font-medium text-muted-foreground border-balign: "center"gap: 4 + className: "px-4 py-2 bg-muted text-xs font-medium text-muted-foreground border-b"plugin-grid/product-inventory-grid.children[1].children[0] · plugin-grid/team-members-grid.children[1].children[0] · plugin-view/grid-view-mode.children[1].children[0] · report/report-breakdown-table.children[1].children[0]
4flexitems-center gap-4 px-4 py-3 text-smalign: "center"gap: 4 + className: "px-4 py-3 text-sm"plugin-grid/product-inventory-grid.children[1].children[5] · plugin-grid/team-members-grid.children[1].children[4] · plugin-view/grid-view-mode.children[1].children[4] · report/report-breakdown-table.children[1].children[5]
3stackitems-center text-center p-4align: "center" + className: "text-center p-4"block-schema/block-with-variable-overrides-analytics-feature.children[0] · block-schema/block-with-variable-overrides-security-feature.children[0] · block-schema/feature-card-block.children[0]
3containerp-6padding: 6(className dropped)dashboard/dashboard-overview.children[2].children[0].children[0] · dashboard/dashboard-overview.children[2].children[1].children[0] · dashboard/recent-activity-card.children[0]
3flexitems-baselinealign: "baseline"(className dropped)marketing/pricing-table.children[1].children[0].children[0].children[1] · marketing/pricing-table.children[1].children[1].children[0].children[1] · marketing/pricing-table.children[1].children[2].children[0].children[1]
2flexgap-2 items-centergap: 2align: "center"(className dropped)actions/action-toolbar.children[1].children[0] · actions/action-toolbar.children[1].children[1]
2flexjustify-end gap-2 pt-4 border-t mt-4justify: "end"gap: 2 + className: "pt-4 border-t mt-4"actions/action-toolbar.children[2] · plugin-view/detail-view-mode.children[1]
2flexitems-start space-x-2align: "start" + className: "space-x-2"auth/signup.children[0].children[3] · forms/newsletter-signup.children[0].children[2]
2flexgap-4gap: 4(className dropped)ecommerce/shopping-cart.children[0].children[2].children[0] · ecommerce/shopping-cart.children[0].children[2].children[2]
2flexitems-center gap-2 mt-2align: "center"gap: 2 + className: "mt-2"ecommerce/shopping-cart.children[0].children[2].children[0].children[1].children[2] · ecommerce/shopping-cart.children[0].children[2].children[2].children[1].children[2]
2flexjustify-end gap-2 pt-2justify: "end"gap: 2 + className: "pt-2"plugin-view/form-view-mode.children[0].children[3] · report/report-scheduling.children[0].children[4]
1flexitems-center justify-between border-b pb-3 mb-4align: "center"justify: "between" + className: "border-b pb-3 mb-4"actions/action-toolbar.children[0]
1flexjustify-end gap-2 pt-4justify: "end"gap: 2 + className: "pt-4"actions/confirmation-dialog.children[0]
1flexitems-center justify-between p-3 border rounded-lg bg-backgroundalign: "center"justify: "between" + className: "p-3 border rounded-lg bg-background"app/application-header
1flexitems-center gap-2 ml-2 pl-2 border-lalign: "center"gap: 2 + className: "ml-2 pl-2 border-l"app/application-header.children[1].children[2]
1flexitems-center gap-2 px-3 py-2 rounded-md bg-accent text-accent-foregroundalign: "center"gap: 2 + className: "px-3 py-2 rounded-md bg-accent text-accent-foreground"app/sidebar-navigation.children[0].children[0]
1flexitems-center space-x-2align: "center" + className: "space-x-2"auth/login-simple.children[0].children[2].children[0]
1flexitems-start justify-betweenalign: "start"justify: "between"(className dropped)block-schema/block-marketplace-listing.children[1].children[0]
1flexitems-center justify-between pt-2 border-talign: "center"justify: "between" + className: "pt-2 border-t"block-schema/block-marketplace-listing.children[1].children[3]
1flexitems-start gap-3 p-4 border rounded-lg max-w-mdalign: "start"gap: 3 + className: "p-4 border rounded-lg max-w-md"blocks-gallery/block-gallery-notification-item
1flexh-[300px] items-center justify-center text-muted-foregroundalign: "center"justify: "center" + className: "h-[300px] text-muted-foreground"dashboard/dashboard-overview.children[2].children[0].children[0].children[1]
1flexjustify-between font-bold text-lgjustify: "between" + className: "font-bold text-lg"ecommerce/order-summary.children[2].children[0].children[3].children[4]
1flexgap-3gap: 3(className dropped)ecommerce/order-summary.children[3]
1flexitems-baseline gap-2align: "baseline"gap: 2(className dropped)ecommerce/product-card.children[1].children[2]
1flexitems-center justify-between mb-6align: "center"justify: "between" + className: "mb-6"ecommerce/product-grid.children[0]
1flexjustify-between text-lg font-boldjustify: "between" + className: "text-lg font-bold"ecommerce/shopping-cart.children[0].children[4].children[4]
1flexjustify-end space-x-2justify: "end" + className: "space-x-2"forms/settings-form.children[1].children[0].children[3]
1flexitems-center justify-center gap-4align: "center"justify: "center"gap: 4(className dropped)marketing/call-to-action.children[0].children[2]
1flexitems-center justify-center gap-8 text-white/80 text-smalign: "center"justify: "center"gap: 8 + className: "text-white/80 text-sm"marketing/call-to-action.children[0].children[3]
1flexitems-center justify-between mb-1align: "center"justify: "between" + className: "mb-1"marketing/pricing-table.children[1].children[1].children[0].children[0].children[0]

Distribution of what was written: align: "center" ×98, gap: 2 ×34, justify: "between" ×33, gap: 4 ×25, gap: 3 ×8, justify: "end" ×6, wrap: true ×4, align: "start" ×4, padding: 4 ×4, gap: 1 ×4, align: "baseline" ×4, padding: 6 ×3, justify: "center" ×3, gap: 8 ×1 — 231, the token count.

65 of the 140 nodes had nothing left to say afterwards and lost their className key entirely.

Per-node itemisation — #4890, 135 nodes / 39 files

Uniform conversion: spacing: N becomes gap: N, same value, on the node itself. Grouped by value; the n column sums to 135.

nbeforeafter落点
5spacing: 0gap: 0app/application-header.children[1].children[2].children[1] · plugin-grid/product-inventory-grid.children[1] · plugin-grid/team-members-grid.children[1] · plugin-view/grid-view-mode.children[1] · report/report-breakdown-table.children[1]
13spacing: 1gap: 1app/sidebar-navigation.children[0] · block-schema/block-marketplace-listing.children[1].children[0].children[0] · blocks-gallery/block-gallery-login-card.header[0] · blocks-gallery/block-gallery-notification-item.children[1] · dashboard/dashboard-overview.children[2].children[1].children[0].children[1].children[0].children[1] · ecommerce/order-summary.children[1].children[0].children[0].children[1] · ecommerce/product-card.children[1].children[0] · ecommerce/shopping-cart.children[0].children[2].children[0].children[1] · ecommerce/shopping-cart.children[0].children[2].children[2].children[1] · forms/settings-form.children[1].children[0].children[2].children[1].children[0] · forms/settings-form.children[1].children[0].children[2].children[2].children[0] · plugin-view/detail-view-mode.header[0].children[1] · report/report-header-with-kpis.children[0].children[0]
68spacing: 2gap: 2actions/confirmation-dialog.header[0] · auth/forgot-password.header[0] · auth/forgot-password.children[0].children[0] · auth/login-simple.header[0] · auth/login-simple.children[0].children[0] · auth/login-simple.children[0].children[1] · auth/signup.header[0] · auth/signup.children[0].children[0].children[0] · auth/signup.children[0].children[0].children[1] · auth/signup.children[0].children[1] · auth/signup.children[0].children[2] · auth/two-factor.header[0] · auth/two-factor.children[0].children[0] · blocks-gallery/block-gallery-login-card.children[0].children[0] · blocks-gallery/block-gallery-login-card.children[0].children[1] · blocks-gallery/block-gallery-stats-card.children[0] · dashboard/recent-activity-card.children[0].children[1].children[0].children[0].children[1] · dashboard/recent-activity-card.children[0].children[1].children[1].children[0].children[1] · dashboard/recent-activity-card.children[0].children[1].children[2].children[0].children[1] · dashboard/recent-activity-card.children[0].children[1].children[3].children[0].children[1] · dashboard/recent-activity-card.children[0].children[1].children[4].children[0].children[1] · ecommerce/order-summary.children[2].children[0].children[3] · ecommerce/product-grid.children[1].children[0].children[1] · ecommerce/product-grid.children[1].children[1].children[1] · ecommerce/product-grid.children[1].children[2].children[1] · ecommerce/product-grid.children[1].children[3].children[1] · ecommerce/shopping-cart.children[0].children[2].children[0].children[2] · ecommerce/shopping-cart.children[0].children[2].children[2].children[2] · ecommerce/shopping-cart.children[0].children[4] · forms/contact-form.header[0] · forms/contact-form.children[0].children[0].children[0] · forms/contact-form.children[0].children[0].children[1] · forms/contact-form.children[0].children[1] · forms/contact-form.children[0].children[2] · forms/contact-form.children[0].children[3] · forms/newsletter-signup.children[0].children[0] · forms/newsletter-signup.children[0].children[1] · forms/payment-form.children[0].children[0] · forms/payment-form.children[0].children[1].children[0] · forms/payment-form.children[0].children[1].children[1] · forms/payment-form.children[0].children[1].children[2].children[0] · forms/payment-form.children[0].children[1].children[2].children[1] · forms/payment-form.children[0].children[1].children[4] · forms/payment-form.children[0].children[1].children[4].children[1].children[0] · forms/payment-form.children[0].children[1].children[4].children[1].children[1] · forms/settings-form.children[0] · forms/settings-form.children[1].children[0].children[0].children[0] · forms/settings-form.children[1].children[0].children[0].children[1] · forms/settings-form.children[1].children[0].children[0].children[2] · forms/settings-form.children[1].children[0].children[0].children[3] · forms/settings-form.children[1].children[0].children[2].children[0] · forms/settings-form.children[1].children[0].children[2].children[3] · marketing/pricing-table.children[1].children[0].children[0].children[0] · marketing/pricing-table.children[1].children[0].children[0].children[4] · marketing/pricing-table.children[1].children[1].children[0].children[0] · marketing/pricing-table.children[1].children[1].children[0].children[4] · marketing/pricing-table.children[1].children[2].children[0].children[0] · marketing/pricing-table.children[1].children[2].children[0].children[4] · marketing/testimonials.children[1].children[0].children[0].children[2].children[1] · marketing/testimonials.children[1].children[1].children[0].children[2].children[1] · marketing/testimonials.children[1].children[2].children[0].children[2].children[1] · plugin-view/form-view-mode.children[0].children[0].children[0] · plugin-view/form-view-mode.children[0].children[0].children[1] · plugin-view/form-view-mode.children[0].children[1] · plugin-view/form-view-mode.children[0].children[2] · report/report-scheduling.children[0].children[0] · report/report-scheduling.children[0].children[1] · report/report-scheduling.children[0].children[2]
14spacing: 3gap: 3actions/action-toolbar.children[1] · block-schema/block-marketplace-listing.children[1] · block-schema/block-with-variable-overrides-analytics-feature.children[0] · block-schema/block-with-variable-overrides-security-feature.children[0] · blocks-gallery/block-gallery-login-card.children[0] · ecommerce/order-summary.children[2].children[0].children[1] · ecommerce/product-card.children[1] · marketing/features-grid.children[1].children[0].children[0] · marketing/features-grid.children[1].children[1].children[0] · marketing/features-grid.children[1].children[2].children[0] · marketing/features-grid.children[1].children[3].children[0] · marketing/features-grid.children[1].children[4].children[0] · marketing/features-grid.children[1].children[5].children[0] · plugin-view/detail-view-mode.children[0]
28spacing: 4gap: 4actions/action-button-variants · auth/forgot-password.children[0] · auth/login-simple.children[0] · auth/signup.children[0] · auth/two-factor.children[0] · block-schema/feature-card-block.children[0] · dashboard/dashboard-overview.children[2].children[1].children[0].children[1] · dashboard/recent-activity-card.children[0].children[1] · ecommerce/order-summary.children[1].children[0].children[0] · ecommerce/order-summary.children[1].children[1].children[0] · ecommerce/order-summary.children[2].children[0] · ecommerce/shopping-cart.children[0].children[2] · forms/contact-form.children[0] · forms/newsletter-signup.children[0] · forms/payment-form.children[0].children[1] · forms/payment-form.children[0].children[1].children[4].children[1] · forms/settings-form.children[1].children[0].children[0] · forms/settings-form.children[1].children[0].children[2] · marketing/pricing-table.children[1].children[0].children[0] · marketing/pricing-table.children[1].children[1].children[0] · marketing/pricing-table.children[1].children[2].children[0] · marketing/testimonials.children[1].children[0].children[0] · marketing/testimonials.children[1].children[1].children[0] · marketing/testimonials.children[1].children[2].children[0] · plugin-view/form-view-mode.children[0] · report/report-header-with-kpis · report/report-scheduling.children[0] · theme/theme-aware-ui-elements
7spacing: 6gap: 6dashboard/dashboard-overview · ecommerce/order-summary · ecommerce/shopping-cart.children[0] · forms/payment-form.children[0] · forms/settings-form · forms/settings-form.children[1].children[0] · marketing/call-to-action.children[0]

Three nodes appear in both tables (block-schema/feature-card-block.children[0] and its two block-with-variable-overrides-* siblings) — they were carrying spacingand an items-center. 140 + 135 − 3 = 272 distinct nodes changed.

How the table and the diff were checked against each other

The risk a sweep actually carries is a node changed but not listed, or listed but not changed. Both directions were measured rather than eyeballed: every one of the 423 catalog documents was parsed at HEAD and in the working tree, each object reduced to a signature of its scalar keys, and the two walks compared pointer by pointer.

nodes the diff actually changed : 272
nodes the per-node list covers : 272 (140 + 135, overlap 3)
CHANGED BUT NOT LISTED : 0
LISTED BUT NOT CHANGED : 0
nodes added/removed : 0

Set equality, both directions. Two further post-state checks: "spacing" now occurs 0 times in the catalog, and each of the 272 rows was re-read from disk and its props and surviving className compared against what the table claims.

The edits are applied by offset into the original bytes, never by re-serialising the JSON — 12 catalog files carry hand-compacted single-line objects that a JSON.stringify(…, null, 2) round-trip would reflow, which is exactly the "changes outside the listed nodes" this had to avoid. git diff on those 12 shows only the intended lines.

Verification

All commands from the repo root, per AGENTS.md §怎么跑测试. Union re-run at the final commit 953c58820 (the HEAD printed by the run itself), not at an earlier tree:

pnpm exec vitest run examples/schema-catalog/ \
packages/components/src/__tests__/stack-spacing-alias-removed.test.tsx \
packages/components/src/__tests__/container-max-width-false.test.tsx \
packages/components/src/__tests__/div-deprecation-provenance.test.tsx \
packages/components/src/__tests__/div-deprecation-warn-once.test.tsx \
packages/plugin-dashboard/src/__tests__/DashboardGridLayout.legacyRetired.test.tsx \
scripts/__tests__/catalog-index-regenerable-4633.test.ts \
--reporter=verbose --maxWorkers=2
-> Test Files 15 passed (15) Tests 1636 passed (1636)
pnpm exec turbo run type-check --filter=@object-ui/components \
--filter=@object-ui/example-schema-catalog --concurrency=2
-> Tasks: 30 successful, 30 total (both packages' `tsc --noEmit && tsc -p tsconfig.test.json` echoed)
node scripts/check-control-bytes.mjs
-> check-control-bytes: OK (scanned 4734 tracked text file(s); skipped 85 binary)
node scripts/check-changeset-fixed.mjs -> All workspace packages are in the changeset fixed group.
node scripts/check-changeset-no-major.mjs -> No changeset declares a `major` bump.

Exit codes were captured before any pipe, in a wrapper that propagates them, and each line above quotes the tool's own verdict rather than a $? a tail would have written. Heavy runs went through scripts/pm/os-verify-lock.sh.

Why this set is a provable superset of what the diff can affect, rather than a guess:

  1. The catalog JSON is reachable only through @object-ui/example-schema-catalog. Its in-repo dependents are apps/site (a package.json dependency; excluded from every root vitest project by config, and owned by CI — no apps/site or content/docs path is touched here) plus four test files, all of which are in the run above alongside the catalog's own nine test files.
  2. The stack.tsx change is one expression: schema.gap ?? (schema as any).spacing ?? 2 becomes schema.gap ?? 2. For any node with gap defined the two are identical; with neither key, both give 2. The only input whose output moves is a node with spacing and no gap — and a repo-wide search finds no such node outside the test that authors one deliberately. The observable surface is therefore empty except where it is asserted.

Lint, narrowed and declared.pnpm lint (the whole-tree eslint .) is CI's run, not re-done here. The narrowing is a measurement, with all three legs: the population comes from eslint's own config resolution rather than a guess about which files count; the file count is read from --format json; and the invariance claim is that eslint.config.js declares no parserOptions.project / projectService and no rule in eslint-rules/ reads another file (no readFileSync / globSync anywhere in it), so every rule here is single-file syntactic and this diff cannot move the verdict of a file it did not touch. Reading:

pnpm exec eslint --no-inline-config --format json <the 43 changed files> -> exit 0
files handed to eslint : 43
files eslint actually linted: 3 (stack.tsx + the two test files)
files outside its population: 40 (the JSON and the changeset — no config block matches them)
errors: 0

The two warnings on stack.tsx are pre-existing and untouched (react-refresh/only-export-components, and the no-explicit-any on the forwardRef parameter's index signature at line 20 — the objectui#4422 shape). This diff removes one any from that file and adds none.

Reverse-verification

Three legs, each with its direction predicted before running. Neither leg needs a build: the root vitest config aliases @object-ui/components to packages/components/src (vitest.config.mts:264) and the catalog test imports allExamples from '../src/index.js' (line 54), so there is no dist anywhere in the resolution path and a source mutation takes effect directly. Every leg mutated, proved the mutation on disk, ran, then restored from the commit under trap … EXIT INT TERM; the restore is verified by sha256, and all three files came back byte-identical.

legmutationpredictedobserved
Arevert one #4891 conversion (actions/action-toolbar$.children[0]) to its shipped classNameRED — the #4891 ratchet (2 offenders) and the pinned equivalence case, whose class no longer matches any nodeexactly those two: Tests 2 failed | 26 passed
Bput spacing: 2 back on auth/login-simple$.header[0]RED — the spacing ban (1 offender) and the value census (a stack with no numeric gap)exactly those two: Tests 2 failed | 26 passed, reporting expected 1 to be +0
Crestore ?? (schema as any).spacing in stack.tsxRED — only "a stack authoring spacing renders the DEFAULT gap"; the flex, gap and gap: 0 cases unaffectedexactly that one: Tests 1 failed | 3 passed

Leg B's first attempt was void and is reported rather than quietly retried. The mutation landed, but the on-disk confirmation used grep -F with a multi-line pattern, which grep treats as several patterns and counts matching lines for — so the "this text is gone" probe returned 3 instead of 0 and the guard declared the leg unrun. The reading was discarded and the leg re-run with single-line counting probes ("gap": 2, 3 → 2, "spacing": 2, 0 → 1). The row above is the second, valid run. The guard firing on a bad probe rather than on a bad mutation is the outcome it exists for; a leg that "looks fine" is the one worth distrusting.

Leg C is what makes the new renderer pin non-phantom: spacing: 6 was chosen precisely because 6 is a mapped ladder step, so a still-live alias renders md:gap-6 and fails loudly instead of coinciding with the default.

What is ratcheted, and where

  • examples/schema-catalog/test/layout-props-conversion.test.tsx — extended, per the card, from "no div carries layout intent" to "no flex/stack/container/grid hand-writes its own props". Also carries the spacing ban, the value census, and the sampled equivalence (2+ cases each for flex, stack, container; grid has none because the sweep converted no grid node — its unprefixed tokens re-measured to mb-6 and p-4, neither of which a grid declares). Equivalence is asserted on the class token set, not the string: extraction moves a token from the end of cn()'s arguments to the front, tailwind-merge has already resolved every same-property conflict by then, and asserting the string would be asserting cn()'s argument order. The post-sweep string is still pinned literally per case.
  • packages/components/src/__tests__/stack-spacing-alias-removed.test.tsx — new; the renderer-side half. An undeclared key is inert, stack and flex now answer identically for it (the trap stack 渲染器读一个从未声明的 spacing 键,catalog 里 135 个节点照着它写 #4890 named), gap still drives the ladder, and gap: 0 stays reachable.

Out of scope, filed separately

Fenced-out paths were not touched: no content/docs/**, no apps/site/**, and none of the four paths held by concurrent cards this round.


Generated by Claude Code

…4891, #4890)
Two cards, one sweep over the same 39 catalog files.
#4891 — 140 nodes across 33 files were already `flex` / `stack` / `container`
and still hand-wrote, in `className`, props their own type declares: 231 tokens
in all (`flex` 221, `container` 7, `stack` 3, `grid` 0 — re-measured on
`origin/main`, reproducing the card's figures exactly). `items-center` becomes
`align`, `justify-between` becomes `justify`, `gap-2` becomes `gap`,
`flex-wrap` becomes `wrap`, `p-4` becomes a container's `padding`. Breakpoint-
prefixed overrides and everything decorative stay in `className`, because the
props are not responsive.
#4890 — 135 `stack` nodes across 39 files authored `spacing`, a key nothing
declares. Producer first: the nodes now author `gap`, carrying the same value.
Then the consumer leg goes, so the alias cannot come back through the renderer.
Nothing outside those 272 nodes changed: the diff and the per-node list in the
PR body are the same set, verified by comparing every node of all 423 catalog
files against `HEAD` (0 changed-but-unlisted, 0 listed-but-unchanged).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012u2pRjcqAYtoEjgr3wwhnK
@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Erratum, mechanical: the body's lint command lost a placeholder to GitHub's body sanitizer. I wrote --format json followed by an angle-bracket-delimited placeholder naming the file list; the sanitizer ate the run, inside a fenced code block, leaving a double space where it was. That is the AGENTS.md "GitHub mutates body BYTES" behaviour, and #5574's body documents the same thing happening to it. Everything else in the body came back byte-intact — both per-node tables and the attribution footer.

Re-spelled here, where a comment preserves it. The file list is the 43 changed files of this PR:

pnpm exec eslint --no-inline-config --format json $(git diff --name-only main...HEAD)

Reading is unchanged: exit 0, 43 files handed in, 3 inside eslint's population (stack.tsx plus the two test files), 40 outside it (the catalog JSON and the changeset — no config block matches them), 0 errors.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3912.9 KB3990.2 KB
Main entry chunk (gzip)151.7 KB350 KB
Entry fileindex-C-G_wvIj.js
StatusPASS

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

PackageSizeGzipped
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)12.80KB4.47KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.83KB113.78KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)160.15KB44.52KB
fields (index.js)238.85KB60.13KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.93KB0.88KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.65KB18.32KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.33KB32.93KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.18KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.74KB27.18KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.54KB20.69KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.59KB1.79KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — reviewed by the domain:ui execution seat

Session session_012u2pRjcqAYtoEjgr3wwhnK. Both cards in one PR as triage ruled, the fixed order followed literally, spacing not legalised into StackSchema. No open questions.

The figures re-measured to the digit — and you said which outcome that was

140 nodes / 33 files for #4891 (flex 221, container 7, stack 3, grid 0 = 231 token hits); 135 nodes / 39 files for #4890; all 33 inside the 39, so 39 files and 272 distinct nodes, with 3 nodes in both halves. That arithmetic is checkable and it checks out.

What makes it worth more than the numbers: my brief warned these were inherited and could be wrong in either direction, and you reported which you found — "this lane's byte-identical outcome, not the drifted one". A card whose count reproduces exactly and a card whose count drifted are different situations, and naming which one you're in is the difference between a verified premise and a lucky one.

Two method choices that a lesser sweep would have got wrong

You parsed all 423 catalog documents instead of grepping, and edited by byte offset instead of re-serialising — because:

12 catalog files carry hand-compacted single-line objects that a JSON.stringify round-trip would have reflowed

That is precisely the out-of-scope churn a sweep must not produce, and it is the kind that gets waved through in review as "just formatting" while making the real diff unreadable. Anticipating it before the first edit, rather than discovering it in the diff, is why this PR is reviewable at 272 nodes.

The diff-vs-list cross-check is exactly the artefact I asked for, done better than I specified. I asked you to make the per-node table checkable against the diff. You did it as set equality in both directions:

changed 272, listed 272, CHANGED-BUT-NOT-LISTED 0, LISTED-BUT-NOT-CHANGED 0, nodes added/removed 0

A sweep's two real risks are a node you changed but didn't list, and a node you listed but didn't change. Both are now zero by measurement rather than by care. And spacing occurs 0 times in the catalog afterwards.

The void leg is the trap from your own brief, caught by your own guard

Leg B's first attempt used grep -F with a multi-line pattern — which grep treats as several patterns and counts matching lines for — so the "this text is gone" probe returned 3 instead of 0. You declared the reading void, discarded it, and re-ran with single-line counting probes.

The brief named that trap. What matters is that your on-disk guard caught it independently, rather than the mutation quietly proceeding on a confirmation that meant something other than you thought. A probe whose number doesn't mean what you believe is worse than no probe, and this is the eighth void leg this session reported rather than silently retried.

Leg C's mutation value is the subtlest thing in the report:

spacing: 6 was chosen because 6 is a mapped ladder step, so a live alias renders md:gap-6 and fails loudly instead of coinciding with the default

A mutation that happens to reproduce the default output is a leg that cannot discriminate. Choosing the value so the two outcomes are distinguishable is the same discipline as picking an assertion that can fail — applied one level down, to the input.

The consequence you surfaced, and my ruling on it

The measurement you posted to #5574 rather than filing fresh — correct, it was already open — carries something I want stated plainly rather than buried in an out-of-scope note:

662 stray attributes … 231 of those are new as of this sweep, because declared props are what leak

So this PR increases DOM-attribute leakage by 231, from ~431 to 662. That is a real, measured cost and I am accepting it deliberately, not overlooking it:

Your control on that probe is what makes the 662 believable: grid reads zero, because #5573 already routed it through toDomProps — a probe that reports leaks everywhere including the fixed case is a broken probe. I'm carrying the 231 delta up as sizing input for #5574 and the #5632 burn-down parent.

#5690, and why the flex figure was exact

Filing the nine space-x-* flex nodes separately is right, and the reason is the good part:

a margin on a sibling selector, not the flexbox gap, so it is not a mechanical rename and a conversion moves rendering

A sweep must not contain a node whose conversion changes what renders. Excluding them is also why the 221-token flex figure re-measured exactly — the exclusion and the count are the same decision. And space-y-* on a stack measured 0, so the sibling case was checked rather than assumed absent.

Scope and lock

Changeset minor on @object-ui/components is right: an undeclared key stopped being honoured, which is a behaviour removal for anyone who authored it, and the repo's no-major rule caps it there. The superset argument is sound — catalog JSON is reachable only through @object-ui/example-schema-catalog, whose in-repo dependents are apps/site (excluded from every root vitest project, and no apps/site or content/docs path touched, so #5668 does not apply) plus four test files, all included — and the stack.tsx delta is one expression that moves output only for a node with spacing and no gap, of which zero remain.

The sanitizer note is documented behaviour, not a new defect, and you re-spelled rather than re-posting 28k characters. Reading the authoritative report at comment 5379795157.

Landing

Reading the named set once past the shard window, then flipping ready and enqueuing at 19 success + the 3 always-skipped no-ops. CI's Bundle Analysis already shows components506.84 → 506.83 KB, the small shrink the removed alias leg should produce.


Generated by Claude Code

@os-sales
os-sales marked this pull request as ready for review August 22, 2026 10:44
@os-sales
os-sales added this pull request to the merge queueAug 22, 2026
Merged via the queue into main with commit dd19463Aug 22, 2026
23 checks passed
@os-sales
os-sales deleted the claude/issue-4891-catalog-layout-props-sweep branch August 22, 2026 10:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants

@os-sales@claude