Skip to content

refactor(fields): build the published stylesheet with the shared builder - #6440

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-6405-shared-stylesheet-builder
Aug 26, 2026
Merged

refactor(fields): build the published stylesheet with the shared builder#6440
os-warren merged 1 commit into
mainfrom
claude/issue-6405-shared-stylesheet-builder

Conversation

@os-warren

@os-warren os-warren commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes #6405

packages/fields/scripts/build-css.mjs carried its own copy of the components-sheet
subtraction — the var() fallback normalisation, the at-rule context keys, the class
extraction and the write-time assertions — which PR #6403 generalised over packageRoot
into scripts/build-plugin-stylesheet.mjs for @object-ui/plugin-grid and
@object-ui/plugin-kanban. Fields now runs that shared builder, so one implementation of
the subtraction exists and a fix to it reaches every package that ships a supplement
sheet.

The acceptance gate: byte identity of the published artifact

@object-ui/fields ships this CSS to consumers, so the whole safety argument is that the
emitted file does not change — not that the code looks equivalent. Measured, not eyeballed:

git hash-object packages/fields/dist/index.css bytes builder's own line
before (dd76dc3e0, this file untouched) f9cd6504a9e3a9603836230aea27c5a3a7e4e431 22761 22.22 kB — 167 rules kept (163 classes), 1358 rules + 75 at-rules already in @object-ui/components' sheet
after (12eea094d) f9cd6504a9e3a9603836230aea27c5a3a7e4e431 22761 identical, character for character

cmp against a snapshot of the pre-change build reports no difference, and sha256sum
agrees (5b250603e846f0798bc62a7b2bdcadd15bfeb5d7dc965f98695428488ebdb326 both ways). The
dist/index.css was deleted before each rebuild, so the second reading is a file the new
code actually wrote rather than a stale artifact.

Reproduced through the real build script (pnpm --filter @object-ui/fields build), not
only the CSS step in isolation.

The gate is capable of failing — verified by ablation

A byte check nobody has seen go red is not a measurement. Dropping the one fields-specific
input this change adds (header: HEADER in buildOptions, so fields inherits the shared
default banner) and rebuilding:

  • artifact hash moves to 83163c2b1e436abde5bcda679b2f0ef0ab4d74e9caught;
  • the builder's console line stays 22.22 kB — 167 rules kept (163 classes)blind.

The size/rule reading the card names as "the reading to reproduce" does not move for a
three-line banner difference in a published file. That is the concrete case for comparing
by hash. The mutation was confirmed on disk before the build (subject blob
2bb3e0ba…b92b5bf8…, removed-text grep count 0, surviving-anchor count 1) and the
restore confirmed after (blob back to 2bb3e0ba…, git diff HEAD empty).

The two sibling sheets are unaffected

This PR edits shared code that plugin-grid and plugin-kanban also run, so their output
was checked the same way: built with this branch's module, then rebuilt with the module
restored from dd76dc3e0.

  • plugin-grid284dca92590193b39858dbc8e7b9425c5806658d both ways (16.30 kB, 128 rules / 126 classes)
  • plugin-kanban8cebeb40d04c7504a19479ec77c5037143ddb599 both ways (11.41 kB, 52 rules / 50 classes)

What fields does that the shared module did not model

Four things. None of them is a retained second copy — each went into the shared module or
into the wrapper as package-specific input.

  1. Its stylesheet banner. The shared header() names a different producing script and
    words the "supplement, not standalone" note differently. Those exact bytes have been at
    the top of a published file since @object-ui/fields declares a ./style.css export but nothing builds dist/index.css for it — the subpath quick-start teaches looks unresolvable #4059. → a documented per-package hook:
    build({ header }) on the shared module, defaulted at the destructure to
    defaultHeader(packageName), with the reason for the hook's existence written above
    defaultHeader. Fields passes HEADER; a package with no such history passes nothing
    and inherits the default.
  2. MUST_SURVIVE and CLASS_CEILING 600, with their rationale — six named specimens
    spanning both reasons a rule can be fields-only, and a leak ceiling carrying the
    main@59df371f7 measurement (157 correct, 1923 with the source(none) pin absent). →
    stay in the wrapper as this package's inputs, exactly as the two plugins hold theirs.
    Not flattened into shared defaults.
  3. The objectui#4059 rationale — what was wrong, why the export could not simply be
    deleted (155 fields-only classes, 17 of them resolving unpublished @theme tokens), the
    ~180 kB duplication the narrow shape avoids, and the coupling statement. → stays in the
    wrapper. What was removed from it is only the prose describing the algorithm that no
    longer lives in this file; that description now has one home, next to the code.
  4. postcss.process(css, { to: output }) and no base. The shared compile passes
    base: packageRoot and no to. Both differences were measured before the rewrite, by
    compiling fields' entry three ways: fields' current call, the shared call, and the
    shared call based at the repo root. All three produced 182026 identical bytes — the
    cwd-independence src/index.css's source(none) comment claims, now also pinned by the
    base argument. So there was nothing to carry over, and the sheet is now provably the
    same from any working directory (node packages/fields/scripts/build-css.mjs run from
    the repo root emits the same hash).

Two smaller alignments, both inside the shared module rather than beside it: the builder's
failure messages cited only objectui#4929, which is the wrong card to send a fields build
to, so they now cite objectui#4059 alongside it; and the module's "why fields is not on
this module" section was describing a state that no longer exists.

The wrapper also adopts the sibling shape — isEntrypoint-guarded CLI half with
PACKAGE_ROOT / MUST_SURVIVE / CLASS_CEILING / builder / buildOptions exported —
so the three build scripts are the same file with different constants.

Changeset: declared as no release, deliberately

.changeset/6405-fields-shared-stylesheet-builder.md has empty frontmatter.

The gate does not demand one at all — check-changeset-presence.mjs reports "0 of them
published source of a package the release covers"
, because a package's scripts/
directory is neither under its src/ nor in fields' files list. The declaration is there
anyway, because the question a reader will have is not whether the gate fired but whether a
build-script change to a published package ships something, and silence answers that by
inference. It ships nothing: the artifact consumers install is byte-identical, so there is
no behaviour to release, and a bump would put a version in the changelog of all 39
fixed-group packages describing a file that did not change.

Verification

Run on 12eea094d, the final commit:

  • pnpm exec vitest run --maxWorkers=2 packages/fields/Test Files 113 passed (113), Tests 1850 passed (1850)
  • pnpm exec vitest run --maxWorkers=2 scripts/__tests__/Test Files 81 passed (81), Tests 2317 passed (2317)
  • pnpm --filter @object-ui/fields build — full tsc && vite build && node scripts/build-css.mjs, emitting the hash above
  • pnpm --filter @object-ui/fields type-check — clean
  • pnpm type-check:scripts — clean, after it caught a real regression: with header
    taking no default, the inferred options type made it required, and both existing
    callers went red. Defaulting it at the destructure is what keeps it optional.
  • pnpm exec eslint scripts/build-plugin-stylesheet.mjs packages/fields/scripts/build-css.mjs — clean
  • node scripts/check-changeset-presence.mjs✅ No source of a released package changed in this range

Worth stating plainly: pnpm --filter @object-ui/fields type-check says nothing about
the file this PR rewrites — packages/fields/tsconfig.json has "include": ["src"]. What
actually types scripts/build-plugin-stylesheet.mjs is pnpm type-check:scripts, through
the pin test's import, which is why the red above appeared there and only there.

Out of scope

scripts/__tests__/plugin-published-stylesheet.test.ts pins the two plugin sheets and not
@object-ui/fields — the one that has actually been published since #4059. Newly cheap to
close now that fields exports the same module shape, but a different change with a
different review. Filed separately as #6438 (unassigned).


Generated by Claude Code

`packages/fields/scripts/build-css.mjs` carried its own copy of the
components-sheet subtraction — the `var()` fallback normalisation, the
at-rule context keys and the write-time assertions — which
`scripts/build-plugin-stylesheet.mjs` generalised over `packageRoot` for
`@object-ui/plugin-grid` and `@object-ui/plugin-kanban`. Two implementations
of one subtraction meant a fix to either could silently not reach the other.

Fields now runs the shared builder. It keeps only what is specific to it:
its `MUST_SURVIVE` sentinels, its `CLASS_CEILING` of 600, the objectui#4059
rationale, and its stylesheet banner — the last through a new documented
`build({ header })` hook on the shared module rather than a retained second
copy of the builder.

`@object-ui/fields` ships this CSS, so the acceptance gate was byte identity
of the emitted artifact, not equivalent-looking code. `packages/fields/dist/
index.css` is `git hash-object f9cd6504a9e3a9603836230aea27c5a3a7e4e431`,
22761 bytes, `167 rules kept (163 classes)`, before and after — and now from
any working directory, since the shared `compile` pins Tailwind's source
detection to `packageRoot` instead of the process cwd.

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

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 52 chunks) 3233.6 KB 3266.6 KB
Main entry chunk (gzip) 157.4 KB 350 KB
Entry file index-BFd8XNRw.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) 11.30KB 4.28KB
app-shell (runtime-config.js) 18.10KB 6.51KB
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) 505.86KB 114.58KB
core (index.js) 5.30KB 2.13KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 173.10KB 47.96KB
fields (index.js) 238.89KB 60.02KB
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.95KB 10.97KB
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) 9.53KB 3.38KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 4.64KB 1.50KB
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) 1.93KB 0.88KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.66KB 12.84KB
plugin-charts (index.js) 64.66KB 18.32KB
plugin-chatbot (index.js) 188.60KB 44.82KB
plugin-dashboard (index.js) 133.48KB 34.49KB
plugin-designer (index.js) 211.90KB 42.74KB
plugin-detail (index.js) 245.10KB 62.31KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 131.78KB 32.19KB
plugin-gantt (index.js) 164.14KB 39.87KB
plugin-grid (index.js) 201.79KB 54.60KB
plugin-kanban (index.js) 53.16KB 14.65KB
plugin-list (index.js) 112.63KB 27.45KB
plugin-map (index.js) 20.09KB 6.62KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 43.49KB 11.93KB
plugin-timeline (index.js) 26.72KB 7.71KB
plugin-tree (index.js) 9.26KB 3.13KB
plugin-view (index.js) 84.55KB 20.74KB
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) 54.84KB 18.43KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.35KB 0.70KB
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) 12.13KB 3.65KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 7.54KB 2.63KB
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.72KB 2.24KB
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) 3.40KB 1.71KB
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

@os-warren
os-warren marked this pull request as ready for review August 26, 2026 00:45
@os-warren
os-warren added this pull request to the merge queue Aug 26, 2026
Merged via the queue into main with commit d378110 Aug 26, 2026
29 checks passed
@os-warren
os-warren deleted the claude/issue-6405-shared-stylesheet-builder branch August 26, 2026 00:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

packages/fields/scripts/build-css.mjs duplicates the shared plugin stylesheet builder added for #4929

2 participants