Skip to content

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption - #7013

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption
Aug 31, 2026
Merged

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption#7013
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#6887

Declaration-only. metadata-viewer.tsx hand-wrote its own shape for a select option — four of the five keys the spec declares, with the fifth dropped by silence. It is now derived from the spec, with the drop named. Zero behaviour change: StateMachineView's labelOf / colorOf / initial logic, and which keys it reads, are untouched.


Must-answer 1 — where the authoritative declaration lives

Package@objectstack/spec, subpath export @objectstack/spec/data
ExportSelectOption (export type SelectOption = z.input.of.SelectOptionSchema, packages/spec/src/data/field.zod.ts:1905; the schema itself at :217)
Settled?Yes — settled, and this card is 照抄.

Verified on disk, not on trust — three independent checks:

  1. The installed package really exports it.node_modules/@objectstack/spec@17.2.0 re-exports SelectOption and SelectOptionParsed from dist/data/index.d.mts, and the bundled declaration carries exactly five keys:

    label: z.ZodString // required
    value: z.ZodString // required (SystemIdentifierSchema)
    color: z.ZodOptional.of.ZodString
    default: z.ZodOptional.of.ZodBoolean
    visibleWhen: z.ZodOptional.of.ExpressionInput
    
  2. This repo has already sanctioned it as the authority for THIS contract.packages/app-shell/src/views/metadata-admin/form-spec.ts:91 imports type SelectOption from @objectstack/spec/data and derives at :159 in the Omit-with-named-narrowings form. That convergence is objectui#6247 / PR Honour per-option visibleWhen in the metadata-admin renderer #6618 — so the authority for the select option was settled there, not by [app-shell] metadata-admin 的 FormFieldSpec 没有声明 dependsOn,但两个 widget 都读它 —— 类型化的 form spec 写不出这两个 widget 的必备配置 #5040 / [finding] A THIRD inline copy of the form-field authoring contract lives in apps/console FormPage.tsx — objectui#5040 converged only the app-shell two #5542 (those converged FormFieldSpec itself; A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it #6247 converged its options element type, which was the last hand-written position in that file).

  3. A second, independent binding to the same authority already exists.packages/types/src/zod/form.zod.ts's SelectOptionSchema is a spec derivation, pinned by packages/types/src/__tests__/select-option-spec-parity.test.ts — which asserts the local schema carries every spec key and extends it by exactly two documented UI-only keys.

Two consumers already deriving from the same export, one of them test-pinned, is what makes this settled rather than a choice I made. ⛔ Nothing here derives the fifth copy from the fourth.

What changed

packages/components/src/renderers/basic/metadata-viewer.tsx — the module-local interface SelectOption is replaced by a derivation, and the local binding is renamed so a narrowed shape no longer sits under the spec's own export name (the planted-premise hazard check-spec-symbol-derivation.mjs exists to stop):

importtype{SelectOptionasSpecSelectOption}from'@objectstack/spec/data';typeStateOption=Omit<SpecSelectOption,'visibleWhen'>;

Each narrowing is now named, with its reason next to it:

  • visibleWhen is DROPPED, and now says so. This view draws the declared state set — every option the field declares becomes a chip, and the graph comes from the state_machine rule. Per-option visibleWhen withdraws an option from a picker for one record and one current_user (ADR-0068); a diagram of the machine has no such subject, so there is nothing here to evaluate it against.
  • default is KEPT, and is read at initial. That is the whole reason this is a spec derivation and not a narrower local shape: default on the object-field face is ruled enforce (objectstack#7246, implemented by PR #7388), so the state this view calls "initial" is the one the platform actually writes.
  • SelectOption (z.input) rather than SelectOptionParsed, even though this is a read model: on the four retained keys the two are the same type — none of label / value / color / default carries a .default() or a transform — so the entire input/output difference lives on the omitted key.

Structural equivalence — one difference, stated precisely

The derived type is not structurally identical to the hand-written one. It admits one shape less:

keyhand-writtenderivedeffect
labellabel?: stringlabel: string (required)derived rejects{ value } with no label
valuevalue: stringvalue: stringidentical
colorcolor?: stringcolor?: stringidentical
defaultdefault?: booleandefault?: booleanidentical

The hand copy had relaxed the spec's requiredlabel to optional — by silence, the same way it dropped visibleWhen. It is the same divergence objectui#5899's census recorded for AppLike / ObjectLike / RemoteTable / FlowRuntimeState (each relaxing a spec-required key) and for FlowNode (which also makes label optional where the spec requires it).

Nothing turned on the relaxation, and no behaviour follows from removing it:

  • The value reaching this annotation comes from useMetadataItem as any (getField returns any), so nothing is type-checked against this declaration in either direction; tsc is green.
  • labelOf falls back to the raw value for any state the option list does not describe at all — transition sources and targets are not required to be declared options — so the fallback is load-bearing for a case that has nothing to do with a missing label, and is unchanged.
  • The type is module-local, so no released surface moves.

Must-answer 2 — the enumeration: a sixth copy exists, and a seventh

"The fifth" was the card's self-report. Measured now: 8 further hand-written descriptions of this contract remain, in three tiers. ⛔ None is touched by this PR.

Tier 1 — named types describing the same contract (3)

#pathlineshapedrift measurable today
1packages/types/src/field-types.ts290SelectOptionMetadatalabel, value, color?, icon?, disabled?, visibleWhen?Yes — default is absent. This is the object-definition field face, the very face metadata-viewer reads, and it cannot describe the ruled-enforce key. Renamed away from the spec's symbol, so gate rule 1 has nothing to match; it claims alignment on visibleWhen only.
2packages/types/src/form.ts266SelectOption — adds disabled / icon, widens valueNo. Declared under the spec's own export name, but it is the SDUI component dialect, and its Zod twin is a spec derivation pinned by select-option-spec-parity.test.ts + zod-mirror-parity.test.ts. Hand-written TS, transitively backed.
3packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx68interface Option { value: string; label?: string; color?: string }Same relaxed-label divergence this PR just removed, plus default and visibleWhen both absent. No claim comment, no pin — and this one is the object-field options editor, i.e. an authoring surface, unlike the read model here. The closest sibling to this card; the strongest candidate to fold next.

Tier 2 — anonymous inline restatements of the same triple (5)

#pathlineshape
4packages/app-shell/src/services/MetadataService.ts112options?: Array.of.{ label: string; value: string; color?: string } on FieldMetadataPayload
5packages/types/src/data-display.ts415options?: Array.of.{ value: any; label: string; color?: string }
6packages/types/src/reports.ts124options?: Array.of.{ value: string | number; label: string; color?: string }
7packages/plugin-dashboard/src/recordFields.tsx216options?: Array.of.{ value: any; label: string; color?: string } on FieldMeta
8packages/plugin-list/src/UserFilters.tsx31ResolvedOption { label; value; color?; count? } (adds a resolved-only count)

Tier 3 — declared-deliberate, already derived downstream (1, listed for completeness)

packages/core/src/evaluator/optionRules.ts:40OptionLike { label; value; visibleWhen? }, documented in place as a deliberately minimal reader shape kept structurally assignable-from the richer option types. packages/fields/src/widgets/MultiSelectField.tsx:10, RadioField.tsx:10 and CheckboxesField.tsx:10 derive from it (extends / alias) rather than restating it — the behaviour this class wants.

Two conduits deliberately excluded (both already reasoned in place, both carrying a [key: string]: unknown catch-all so they do not restate the vocabulary): ActionParamOption (packages/core/src/actions/ActionRunner.ts:582) and RawActionParamOption (packages/app-shell/src/utils/resolveActionParams.ts:69). Likewise excluded: RadioOption / ComboboxOption (distinct component vocabularies with their own spec schemas) and the various FieldOption shapes in plugin-tree / plugin-view (field pickers — value is a field name, a different concept).

One measured negative worth recording: after this PR, packages/types/src/form.ts:299 is the only declaration left in the tree that carries a select option's default key. The object-definition face (SelectOptionMetadata, #1 above) does not, and neither does the authoring editor (#3).

Gate readings — before and after, unchanged, and why

node scripts/check-spec-symbol-derivation.mjs, both runs exit 0:

before: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages
after: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages

Identical, as expected — this fix is invisible to the gate, and the card's account of why is half the story. The card noted the declaration carries no alignment claim, so rule 2 has nothing to match on. The other half is in the script's own header: both rules skip any declaration without an export modifier (hasExportModifier, once per scanner). This declaration is module-local, so rule 1 could not see it either — even though it was named after a spec export and had drifted, which is exactly rule 1's trigger. objectui#5899 measured that hole: forcing the filter true takes rule 1 from 18 findings to 47, of which 22 of 30 additional sites are real mirrors and 12 carry a divergence measurable today. This declaration was one of that population; it no longer is.

⇒ The gate numbers not moving is the correct outcome, not a missing step. Closing the class the gate cannot see is the card's third candidate direction and is left to the PM.

Verification

All at 2843b62e7, the final commit.

checkcommandresult
dependency closure buildpnpm --filter '@object-ui/components^...' buildexit 0
type-check (both legs)pnpm --filter @object-ui/components type-check (= tsc --noEmit && tsc -p tsconfig.test.json)exit 0
tests, unchangedpnpm vitest run packages/components/src/__tests__/metadata-viewer.test.tsx (root-relative)7 passed / 7, exit 0
eslint (plain form)npx eslint packages/components/src/renderers/basic/metadata-viewer.tsx --format json0 errors; 7 pre-existing no-explicit-any warnings, none on a line this diff adds (the diff adds zero any)
spec symbol derivationpnpm check:spec-symbolsexit 0 (see above)
control bytespnpm check:control-bytesexit 0, 5819 files
phantom depspnpm check:phantom-depsexit 0 — @objectstack/spec is already a declared dependency of @object-ui/components
self-import / esm specifierspnpm check:self-import, pnpm check:esm-specifiersexit 0
changeset presencenode scripts/check-changeset-presence.mjsexit 0 — 1 changeset, empty frontmatter (the explicit "releases nothing" declaration)
changeset no-majornode scripts/check-changeset-no-major.mjsexit 0

Coverage is measured, not assumed.packages/components/tsconfig.jsonexcludessrc/__tests__ and **/*.test.tsx, so the first tsc leg says nothing about test files; tsc --listFiles confirms the edited file is in the main project (1 hit) and the test file is in tsconfig.test.json (1 hit). The eslint run is a declared narrowing to the one changed file: the flat config uses tseslint.configs.recommended with no parserOptions.project and no projectService, so type-aware linting is off and this diff cannot move the verdict on any file it does not touch; the repo-wide pass belongs to CI.

Reverse verification — the derivation is really bound to the spec

Run from the committed state, so the restore leg has a real reference point. Two probes injected at the type alias; predicted direction red, observed red:

mutated blob 3cddbe88 (HEAD blob 35a8621d) — probe greps 1/1, so the mutation reached disk
metadata-viewer.tsx(156,54): error TS2353: Object literal may only specify known
properties, and 'visibleWhen' does not exist in type 'StateOption'.
metadata-viewer.tsx(158,7): error TS2741: Property 'label' is missing in type
'{ value: string; }' but required in type 'StateOption'.
total diagnostics: 2 (tsc exit 2)

TS2353 proves the Omit really removed the key and that the alias resolves to a real object type out of the spec's .d.mts rather than collapsing to any. TS2741 independently confirms the label divergence documented above. Restore was then proven, not assumed: git checkout HEAD -- ABSOLUTE_PATH, git diff HEAD empty, restored blob 35a8621d equal to the HEAD blob, probe residue 0 — and tsc back to exit 0 on the restored tree.

Out of scope, deliberately

  • ⛔ The object-field per-option default (enforce, objectstack#7246 / PR #7388) and the alias table (isDefault / selected) are untouched. This PR is about where a shape is declared, never about what a surface honours.
  • ⛔ The 8 remaining copies enumerated above are listed, not fixed — for the PM to file or fold.

Generated by Claude Code

…spec
`metadata-viewer.tsx` declared a module-local `interface SelectOption` — four
of the five keys `@objectstack/spec/data`'s `SelectOption` declares, with
`visibleWhen` dropped by silence: no `Omit` naming the narrowing, no comment
saying the drop was deliberate. It is the same defect class this repo has been
closing for several cards running (one contract, several hand-written
descriptions, each only ever reconciled against itself).
Derived now, as `type StateOption = Omit<SpecSelectOption, 'visibleWhen'>` —
the `Omit`-with-named-narrowings form `metadata-admin/form-spec.ts` already
uses for `FormFieldSpec.options`. The dropped key and the retained `default`
are each written out beside their reason.
Zero behaviour change: the type is module-local, the value it annotates arrives
from `useMetadataItem` as `any`, and `StateMachineView`'s `labelOf` / `colorOf`
/ `initial` logic is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3180.2 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-BsvtHAQI.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 (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
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)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
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.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
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)512.08KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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 (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 5c09ccaAug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6887-derive-selectoption branch August 31, 2026 13:34
Sign up for freeto 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.

finding(components): metadata-viewer hand-writes a fifth copy of the select-option shape instead of deriving it

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
refactor(components): derive metadata-viewer's option shape from the spec's SelectOption by claude[bot] · Pull Request #7013 · objectstack-ai/objectui · GitHub
Skip to content

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption - #7013

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption
Aug 31, 2026
Merged

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption#7013
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#6887

Declaration-only. metadata-viewer.tsx hand-wrote its own shape for a select option — four of the five keys the spec declares, with the fifth dropped by silence. It is now derived from the spec, with the drop named. Zero behaviour change: StateMachineView's labelOf / colorOf / initial logic, and which keys it reads, are untouched.


Must-answer 1 — where the authoritative declaration lives

Package@objectstack/spec, subpath export @objectstack/spec/data
ExportSelectOption (export type SelectOption = z.input.of.SelectOptionSchema, packages/spec/src/data/field.zod.ts:1905; the schema itself at :217)
Settled?Yes — settled, and this card is 照抄.

Verified on disk, not on trust — three independent checks:

  1. The installed package really exports it.node_modules/@objectstack/spec@17.2.0 re-exports SelectOption and SelectOptionParsed from dist/data/index.d.mts, and the bundled declaration carries exactly five keys:

    label: z.ZodString // required
    value: z.ZodString // required (SystemIdentifierSchema)
    color: z.ZodOptional.of.ZodString
    default: z.ZodOptional.of.ZodBoolean
    visibleWhen: z.ZodOptional.of.ExpressionInput
    
  2. This repo has already sanctioned it as the authority for THIS contract.packages/app-shell/src/views/metadata-admin/form-spec.ts:91 imports type SelectOption from @objectstack/spec/data and derives at :159 in the Omit-with-named-narrowings form. That convergence is objectui#6247 / PR Honour per-option visibleWhen in the metadata-admin renderer #6618 — so the authority for the select option was settled there, not by [app-shell] metadata-admin 的 FormFieldSpec 没有声明 dependsOn,但两个 widget 都读它 —— 类型化的 form spec 写不出这两个 widget 的必备配置 #5040 / [finding] A THIRD inline copy of the form-field authoring contract lives in apps/console FormPage.tsx — objectui#5040 converged only the app-shell two #5542 (those converged FormFieldSpec itself; A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it #6247 converged its options element type, which was the last hand-written position in that file).

  3. A second, independent binding to the same authority already exists.packages/types/src/zod/form.zod.ts's SelectOptionSchema is a spec derivation, pinned by packages/types/src/__tests__/select-option-spec-parity.test.ts — which asserts the local schema carries every spec key and extends it by exactly two documented UI-only keys.

Two consumers already deriving from the same export, one of them test-pinned, is what makes this settled rather than a choice I made. ⛔ Nothing here derives the fifth copy from the fourth.

What changed

packages/components/src/renderers/basic/metadata-viewer.tsx — the module-local interface SelectOption is replaced by a derivation, and the local binding is renamed so a narrowed shape no longer sits under the spec's own export name (the planted-premise hazard check-spec-symbol-derivation.mjs exists to stop):

importtype{SelectOptionasSpecSelectOption}from'@objectstack/spec/data';typeStateOption=Omit<SpecSelectOption,'visibleWhen'>;

Each narrowing is now named, with its reason next to it:

  • visibleWhen is DROPPED, and now says so. This view draws the declared state set — every option the field declares becomes a chip, and the graph comes from the state_machine rule. Per-option visibleWhen withdraws an option from a picker for one record and one current_user (ADR-0068); a diagram of the machine has no such subject, so there is nothing here to evaluate it against.
  • default is KEPT, and is read at initial. That is the whole reason this is a spec derivation and not a narrower local shape: default on the object-field face is ruled enforce (objectstack#7246, implemented by PR #7388), so the state this view calls "initial" is the one the platform actually writes.
  • SelectOption (z.input) rather than SelectOptionParsed, even though this is a read model: on the four retained keys the two are the same type — none of label / value / color / default carries a .default() or a transform — so the entire input/output difference lives on the omitted key.

Structural equivalence — one difference, stated precisely

The derived type is not structurally identical to the hand-written one. It admits one shape less:

keyhand-writtenderivedeffect
labellabel?: stringlabel: string (required)derived rejects{ value } with no label
valuevalue: stringvalue: stringidentical
colorcolor?: stringcolor?: stringidentical
defaultdefault?: booleandefault?: booleanidentical

The hand copy had relaxed the spec's requiredlabel to optional — by silence, the same way it dropped visibleWhen. It is the same divergence objectui#5899's census recorded for AppLike / ObjectLike / RemoteTable / FlowRuntimeState (each relaxing a spec-required key) and for FlowNode (which also makes label optional where the spec requires it).

Nothing turned on the relaxation, and no behaviour follows from removing it:

  • The value reaching this annotation comes from useMetadataItem as any (getField returns any), so nothing is type-checked against this declaration in either direction; tsc is green.
  • labelOf falls back to the raw value for any state the option list does not describe at all — transition sources and targets are not required to be declared options — so the fallback is load-bearing for a case that has nothing to do with a missing label, and is unchanged.
  • The type is module-local, so no released surface moves.

Must-answer 2 — the enumeration: a sixth copy exists, and a seventh

"The fifth" was the card's self-report. Measured now: 8 further hand-written descriptions of this contract remain, in three tiers. ⛔ None is touched by this PR.

Tier 1 — named types describing the same contract (3)

#pathlineshapedrift measurable today
1packages/types/src/field-types.ts290SelectOptionMetadatalabel, value, color?, icon?, disabled?, visibleWhen?Yes — default is absent. This is the object-definition field face, the very face metadata-viewer reads, and it cannot describe the ruled-enforce key. Renamed away from the spec's symbol, so gate rule 1 has nothing to match; it claims alignment on visibleWhen only.
2packages/types/src/form.ts266SelectOption — adds disabled / icon, widens valueNo. Declared under the spec's own export name, but it is the SDUI component dialect, and its Zod twin is a spec derivation pinned by select-option-spec-parity.test.ts + zod-mirror-parity.test.ts. Hand-written TS, transitively backed.
3packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx68interface Option { value: string; label?: string; color?: string }Same relaxed-label divergence this PR just removed, plus default and visibleWhen both absent. No claim comment, no pin — and this one is the object-field options editor, i.e. an authoring surface, unlike the read model here. The closest sibling to this card; the strongest candidate to fold next.

Tier 2 — anonymous inline restatements of the same triple (5)

#pathlineshape
4packages/app-shell/src/services/MetadataService.ts112options?: Array.of.{ label: string; value: string; color?: string } on FieldMetadataPayload
5packages/types/src/data-display.ts415options?: Array.of.{ value: any; label: string; color?: string }
6packages/types/src/reports.ts124options?: Array.of.{ value: string | number; label: string; color?: string }
7packages/plugin-dashboard/src/recordFields.tsx216options?: Array.of.{ value: any; label: string; color?: string } on FieldMeta
8packages/plugin-list/src/UserFilters.tsx31ResolvedOption { label; value; color?; count? } (adds a resolved-only count)

Tier 3 — declared-deliberate, already derived downstream (1, listed for completeness)

packages/core/src/evaluator/optionRules.ts:40OptionLike { label; value; visibleWhen? }, documented in place as a deliberately minimal reader shape kept structurally assignable-from the richer option types. packages/fields/src/widgets/MultiSelectField.tsx:10, RadioField.tsx:10 and CheckboxesField.tsx:10 derive from it (extends / alias) rather than restating it — the behaviour this class wants.

Two conduits deliberately excluded (both already reasoned in place, both carrying a [key: string]: unknown catch-all so they do not restate the vocabulary): ActionParamOption (packages/core/src/actions/ActionRunner.ts:582) and RawActionParamOption (packages/app-shell/src/utils/resolveActionParams.ts:69). Likewise excluded: RadioOption / ComboboxOption (distinct component vocabularies with their own spec schemas) and the various FieldOption shapes in plugin-tree / plugin-view (field pickers — value is a field name, a different concept).

One measured negative worth recording: after this PR, packages/types/src/form.ts:299 is the only declaration left in the tree that carries a select option's default key. The object-definition face (SelectOptionMetadata, #1 above) does not, and neither does the authoring editor (#3).

Gate readings — before and after, unchanged, and why

node scripts/check-spec-symbol-derivation.mjs, both runs exit 0:

before: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages
after: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages

Identical, as expected — this fix is invisible to the gate, and the card's account of why is half the story. The card noted the declaration carries no alignment claim, so rule 2 has nothing to match on. The other half is in the script's own header: both rules skip any declaration without an export modifier (hasExportModifier, once per scanner). This declaration is module-local, so rule 1 could not see it either — even though it was named after a spec export and had drifted, which is exactly rule 1's trigger. objectui#5899 measured that hole: forcing the filter true takes rule 1 from 18 findings to 47, of which 22 of 30 additional sites are real mirrors and 12 carry a divergence measurable today. This declaration was one of that population; it no longer is.

⇒ The gate numbers not moving is the correct outcome, not a missing step. Closing the class the gate cannot see is the card's third candidate direction and is left to the PM.

Verification

All at 2843b62e7, the final commit.

checkcommandresult
dependency closure buildpnpm --filter '@object-ui/components^...' buildexit 0
type-check (both legs)pnpm --filter @object-ui/components type-check (= tsc --noEmit && tsc -p tsconfig.test.json)exit 0
tests, unchangedpnpm vitest run packages/components/src/__tests__/metadata-viewer.test.tsx (root-relative)7 passed / 7, exit 0
eslint (plain form)npx eslint packages/components/src/renderers/basic/metadata-viewer.tsx --format json0 errors; 7 pre-existing no-explicit-any warnings, none on a line this diff adds (the diff adds zero any)
spec symbol derivationpnpm check:spec-symbolsexit 0 (see above)
control bytespnpm check:control-bytesexit 0, 5819 files
phantom depspnpm check:phantom-depsexit 0 — @objectstack/spec is already a declared dependency of @object-ui/components
self-import / esm specifierspnpm check:self-import, pnpm check:esm-specifiersexit 0
changeset presencenode scripts/check-changeset-presence.mjsexit 0 — 1 changeset, empty frontmatter (the explicit "releases nothing" declaration)
changeset no-majornode scripts/check-changeset-no-major.mjsexit 0

Coverage is measured, not assumed.packages/components/tsconfig.jsonexcludessrc/__tests__ and **/*.test.tsx, so the first tsc leg says nothing about test files; tsc --listFiles confirms the edited file is in the main project (1 hit) and the test file is in tsconfig.test.json (1 hit). The eslint run is a declared narrowing to the one changed file: the flat config uses tseslint.configs.recommended with no parserOptions.project and no projectService, so type-aware linting is off and this diff cannot move the verdict on any file it does not touch; the repo-wide pass belongs to CI.

Reverse verification — the derivation is really bound to the spec

Run from the committed state, so the restore leg has a real reference point. Two probes injected at the type alias; predicted direction red, observed red:

mutated blob 3cddbe88 (HEAD blob 35a8621d) — probe greps 1/1, so the mutation reached disk
metadata-viewer.tsx(156,54): error TS2353: Object literal may only specify known
properties, and 'visibleWhen' does not exist in type 'StateOption'.
metadata-viewer.tsx(158,7): error TS2741: Property 'label' is missing in type
'{ value: string; }' but required in type 'StateOption'.
total diagnostics: 2 (tsc exit 2)

TS2353 proves the Omit really removed the key and that the alias resolves to a real object type out of the spec's .d.mts rather than collapsing to any. TS2741 independently confirms the label divergence documented above. Restore was then proven, not assumed: git checkout HEAD -- ABSOLUTE_PATH, git diff HEAD empty, restored blob 35a8621d equal to the HEAD blob, probe residue 0 — and tsc back to exit 0 on the restored tree.

Out of scope, deliberately

  • ⛔ The object-field per-option default (enforce, objectstack#7246 / PR #7388) and the alias table (isDefault / selected) are untouched. This PR is about where a shape is declared, never about what a surface honours.
  • ⛔ The 8 remaining copies enumerated above are listed, not fixed — for the PM to file or fold.

Generated by Claude Code

…spec
`metadata-viewer.tsx` declared a module-local `interface SelectOption` — four
of the five keys `@objectstack/spec/data`'s `SelectOption` declares, with
`visibleWhen` dropped by silence: no `Omit` naming the narrowing, no comment
saying the drop was deliberate. It is the same defect class this repo has been
closing for several cards running (one contract, several hand-written
descriptions, each only ever reconciled against itself).
Derived now, as `type StateOption = Omit<SpecSelectOption, 'visibleWhen'>` —
the `Omit`-with-named-narrowings form `metadata-admin/form-spec.ts` already
uses for `FormFieldSpec.options`. The dropped key and the retained `default`
are each written out beside their reason.
Zero behaviour change: the type is module-local, the value it annotates arrives
from `useMetadataItem` as `any`, and `StateMachineView`'s `labelOf` / `colorOf`
/ `initial` logic is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3180.2 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-BsvtHAQI.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 (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
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)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
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.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
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)512.08KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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 (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 5c09ccaAug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6887-derive-selectoption branch August 31, 2026 13:34
Sign up for freeto 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.

finding(components): metadata-viewer hand-writes a fifth copy of the select-option shape instead of deriving it

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(components): derive metadata-viewer's option shape from the spec's SelectOption by claude[bot] · Pull Request #7013 · objectstack-ai/objectui · GitHub
Skip to content

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption - #7013

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption
Aug 31, 2026
Merged

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption#7013
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#6887

Declaration-only. metadata-viewer.tsx hand-wrote its own shape for a select option — four of the five keys the spec declares, with the fifth dropped by silence. It is now derived from the spec, with the drop named. Zero behaviour change: StateMachineView's labelOf / colorOf / initial logic, and which keys it reads, are untouched.


Must-answer 1 — where the authoritative declaration lives

Package@objectstack/spec, subpath export @objectstack/spec/data
ExportSelectOption (export type SelectOption = z.input.of.SelectOptionSchema, packages/spec/src/data/field.zod.ts:1905; the schema itself at :217)
Settled?Yes — settled, and this card is 照抄.

Verified on disk, not on trust — three independent checks:

  1. The installed package really exports it.node_modules/@objectstack/spec@17.2.0 re-exports SelectOption and SelectOptionParsed from dist/data/index.d.mts, and the bundled declaration carries exactly five keys:

    label: z.ZodString // required
    value: z.ZodString // required (SystemIdentifierSchema)
    color: z.ZodOptional.of.ZodString
    default: z.ZodOptional.of.ZodBoolean
    visibleWhen: z.ZodOptional.of.ExpressionInput
    
  2. This repo has already sanctioned it as the authority for THIS contract.packages/app-shell/src/views/metadata-admin/form-spec.ts:91 imports type SelectOption from @objectstack/spec/data and derives at :159 in the Omit-with-named-narrowings form. That convergence is objectui#6247 / PR Honour per-option visibleWhen in the metadata-admin renderer #6618 — so the authority for the select option was settled there, not by [app-shell] metadata-admin 的 FormFieldSpec 没有声明 dependsOn,但两个 widget 都读它 —— 类型化的 form spec 写不出这两个 widget 的必备配置 #5040 / [finding] A THIRD inline copy of the form-field authoring contract lives in apps/console FormPage.tsx — objectui#5040 converged only the app-shell two #5542 (those converged FormFieldSpec itself; A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it #6247 converged its options element type, which was the last hand-written position in that file).

  3. A second, independent binding to the same authority already exists.packages/types/src/zod/form.zod.ts's SelectOptionSchema is a spec derivation, pinned by packages/types/src/__tests__/select-option-spec-parity.test.ts — which asserts the local schema carries every spec key and extends it by exactly two documented UI-only keys.

Two consumers already deriving from the same export, one of them test-pinned, is what makes this settled rather than a choice I made. ⛔ Nothing here derives the fifth copy from the fourth.

What changed

packages/components/src/renderers/basic/metadata-viewer.tsx — the module-local interface SelectOption is replaced by a derivation, and the local binding is renamed so a narrowed shape no longer sits under the spec's own export name (the planted-premise hazard check-spec-symbol-derivation.mjs exists to stop):

importtype{SelectOptionasSpecSelectOption}from'@objectstack/spec/data';typeStateOption=Omit<SpecSelectOption,'visibleWhen'>;

Each narrowing is now named, with its reason next to it:

  • visibleWhen is DROPPED, and now says so. This view draws the declared state set — every option the field declares becomes a chip, and the graph comes from the state_machine rule. Per-option visibleWhen withdraws an option from a picker for one record and one current_user (ADR-0068); a diagram of the machine has no such subject, so there is nothing here to evaluate it against.
  • default is KEPT, and is read at initial. That is the whole reason this is a spec derivation and not a narrower local shape: default on the object-field face is ruled enforce (objectstack#7246, implemented by PR #7388), so the state this view calls "initial" is the one the platform actually writes.
  • SelectOption (z.input) rather than SelectOptionParsed, even though this is a read model: on the four retained keys the two are the same type — none of label / value / color / default carries a .default() or a transform — so the entire input/output difference lives on the omitted key.

Structural equivalence — one difference, stated precisely

The derived type is not structurally identical to the hand-written one. It admits one shape less:

keyhand-writtenderivedeffect
labellabel?: stringlabel: string (required)derived rejects{ value } with no label
valuevalue: stringvalue: stringidentical
colorcolor?: stringcolor?: stringidentical
defaultdefault?: booleandefault?: booleanidentical

The hand copy had relaxed the spec's requiredlabel to optional — by silence, the same way it dropped visibleWhen. It is the same divergence objectui#5899's census recorded for AppLike / ObjectLike / RemoteTable / FlowRuntimeState (each relaxing a spec-required key) and for FlowNode (which also makes label optional where the spec requires it).

Nothing turned on the relaxation, and no behaviour follows from removing it:

  • The value reaching this annotation comes from useMetadataItem as any (getField returns any), so nothing is type-checked against this declaration in either direction; tsc is green.
  • labelOf falls back to the raw value for any state the option list does not describe at all — transition sources and targets are not required to be declared options — so the fallback is load-bearing for a case that has nothing to do with a missing label, and is unchanged.
  • The type is module-local, so no released surface moves.

Must-answer 2 — the enumeration: a sixth copy exists, and a seventh

"The fifth" was the card's self-report. Measured now: 8 further hand-written descriptions of this contract remain, in three tiers. ⛔ None is touched by this PR.

Tier 1 — named types describing the same contract (3)

#pathlineshapedrift measurable today
1packages/types/src/field-types.ts290SelectOptionMetadatalabel, value, color?, icon?, disabled?, visibleWhen?Yes — default is absent. This is the object-definition field face, the very face metadata-viewer reads, and it cannot describe the ruled-enforce key. Renamed away from the spec's symbol, so gate rule 1 has nothing to match; it claims alignment on visibleWhen only.
2packages/types/src/form.ts266SelectOption — adds disabled / icon, widens valueNo. Declared under the spec's own export name, but it is the SDUI component dialect, and its Zod twin is a spec derivation pinned by select-option-spec-parity.test.ts + zod-mirror-parity.test.ts. Hand-written TS, transitively backed.
3packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx68interface Option { value: string; label?: string; color?: string }Same relaxed-label divergence this PR just removed, plus default and visibleWhen both absent. No claim comment, no pin — and this one is the object-field options editor, i.e. an authoring surface, unlike the read model here. The closest sibling to this card; the strongest candidate to fold next.

Tier 2 — anonymous inline restatements of the same triple (5)

#pathlineshape
4packages/app-shell/src/services/MetadataService.ts112options?: Array.of.{ label: string; value: string; color?: string } on FieldMetadataPayload
5packages/types/src/data-display.ts415options?: Array.of.{ value: any; label: string; color?: string }
6packages/types/src/reports.ts124options?: Array.of.{ value: string | number; label: string; color?: string }
7packages/plugin-dashboard/src/recordFields.tsx216options?: Array.of.{ value: any; label: string; color?: string } on FieldMeta
8packages/plugin-list/src/UserFilters.tsx31ResolvedOption { label; value; color?; count? } (adds a resolved-only count)

Tier 3 — declared-deliberate, already derived downstream (1, listed for completeness)

packages/core/src/evaluator/optionRules.ts:40OptionLike { label; value; visibleWhen? }, documented in place as a deliberately minimal reader shape kept structurally assignable-from the richer option types. packages/fields/src/widgets/MultiSelectField.tsx:10, RadioField.tsx:10 and CheckboxesField.tsx:10 derive from it (extends / alias) rather than restating it — the behaviour this class wants.

Two conduits deliberately excluded (both already reasoned in place, both carrying a [key: string]: unknown catch-all so they do not restate the vocabulary): ActionParamOption (packages/core/src/actions/ActionRunner.ts:582) and RawActionParamOption (packages/app-shell/src/utils/resolveActionParams.ts:69). Likewise excluded: RadioOption / ComboboxOption (distinct component vocabularies with their own spec schemas) and the various FieldOption shapes in plugin-tree / plugin-view (field pickers — value is a field name, a different concept).

One measured negative worth recording: after this PR, packages/types/src/form.ts:299 is the only declaration left in the tree that carries a select option's default key. The object-definition face (SelectOptionMetadata, #1 above) does not, and neither does the authoring editor (#3).

Gate readings — before and after, unchanged, and why

node scripts/check-spec-symbol-derivation.mjs, both runs exit 0:

before: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages
after: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages

Identical, as expected — this fix is invisible to the gate, and the card's account of why is half the story. The card noted the declaration carries no alignment claim, so rule 2 has nothing to match on. The other half is in the script's own header: both rules skip any declaration without an export modifier (hasExportModifier, once per scanner). This declaration is module-local, so rule 1 could not see it either — even though it was named after a spec export and had drifted, which is exactly rule 1's trigger. objectui#5899 measured that hole: forcing the filter true takes rule 1 from 18 findings to 47, of which 22 of 30 additional sites are real mirrors and 12 carry a divergence measurable today. This declaration was one of that population; it no longer is.

⇒ The gate numbers not moving is the correct outcome, not a missing step. Closing the class the gate cannot see is the card's third candidate direction and is left to the PM.

Verification

All at 2843b62e7, the final commit.

checkcommandresult
dependency closure buildpnpm --filter '@object-ui/components^...' buildexit 0
type-check (both legs)pnpm --filter @object-ui/components type-check (= tsc --noEmit && tsc -p tsconfig.test.json)exit 0
tests, unchangedpnpm vitest run packages/components/src/__tests__/metadata-viewer.test.tsx (root-relative)7 passed / 7, exit 0
eslint (plain form)npx eslint packages/components/src/renderers/basic/metadata-viewer.tsx --format json0 errors; 7 pre-existing no-explicit-any warnings, none on a line this diff adds (the diff adds zero any)
spec symbol derivationpnpm check:spec-symbolsexit 0 (see above)
control bytespnpm check:control-bytesexit 0, 5819 files
phantom depspnpm check:phantom-depsexit 0 — @objectstack/spec is already a declared dependency of @object-ui/components
self-import / esm specifierspnpm check:self-import, pnpm check:esm-specifiersexit 0
changeset presencenode scripts/check-changeset-presence.mjsexit 0 — 1 changeset, empty frontmatter (the explicit "releases nothing" declaration)
changeset no-majornode scripts/check-changeset-no-major.mjsexit 0

Coverage is measured, not assumed.packages/components/tsconfig.jsonexcludessrc/__tests__ and **/*.test.tsx, so the first tsc leg says nothing about test files; tsc --listFiles confirms the edited file is in the main project (1 hit) and the test file is in tsconfig.test.json (1 hit). The eslint run is a declared narrowing to the one changed file: the flat config uses tseslint.configs.recommended with no parserOptions.project and no projectService, so type-aware linting is off and this diff cannot move the verdict on any file it does not touch; the repo-wide pass belongs to CI.

Reverse verification — the derivation is really bound to the spec

Run from the committed state, so the restore leg has a real reference point. Two probes injected at the type alias; predicted direction red, observed red:

mutated blob 3cddbe88 (HEAD blob 35a8621d) — probe greps 1/1, so the mutation reached disk
metadata-viewer.tsx(156,54): error TS2353: Object literal may only specify known
properties, and 'visibleWhen' does not exist in type 'StateOption'.
metadata-viewer.tsx(158,7): error TS2741: Property 'label' is missing in type
'{ value: string; }' but required in type 'StateOption'.
total diagnostics: 2 (tsc exit 2)

TS2353 proves the Omit really removed the key and that the alias resolves to a real object type out of the spec's .d.mts rather than collapsing to any. TS2741 independently confirms the label divergence documented above. Restore was then proven, not assumed: git checkout HEAD -- ABSOLUTE_PATH, git diff HEAD empty, restored blob 35a8621d equal to the HEAD blob, probe residue 0 — and tsc back to exit 0 on the restored tree.

Out of scope, deliberately

  • ⛔ The object-field per-option default (enforce, objectstack#7246 / PR #7388) and the alias table (isDefault / selected) are untouched. This PR is about where a shape is declared, never about what a surface honours.
  • ⛔ The 8 remaining copies enumerated above are listed, not fixed — for the PM to file or fold.

Generated by Claude Code

…spec
`metadata-viewer.tsx` declared a module-local `interface SelectOption` — four
of the five keys `@objectstack/spec/data`'s `SelectOption` declares, with
`visibleWhen` dropped by silence: no `Omit` naming the narrowing, no comment
saying the drop was deliberate. It is the same defect class this repo has been
closing for several cards running (one contract, several hand-written
descriptions, each only ever reconciled against itself).
Derived now, as `type StateOption = Omit<SpecSelectOption, 'visibleWhen'>` —
the `Omit`-with-named-narrowings form `metadata-admin/form-spec.ts` already
uses for `FormFieldSpec.options`. The dropped key and the retained `default`
are each written out beside their reason.
Zero behaviour change: the type is module-local, the value it annotates arrives
from `useMetadataItem` as `any`, and `StateMachineView`'s `labelOf` / `colorOf`
/ `initial` logic is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3180.2 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-BsvtHAQI.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 (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
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)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
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.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
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)512.08KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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 (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 5c09ccaAug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6887-derive-selectoption branch August 31, 2026 13:34
Sign up for freeto 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.

finding(components): metadata-viewer hand-writes a fifth copy of the select-option shape instead of deriving it

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(components): derive metadata-viewer's option shape from the spec's SelectOption by claude[bot] · Pull Request #7013 · objectstack-ai/objectui · GitHub
Skip to content

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption - #7013

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption
Aug 31, 2026
Merged

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption#7013
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#6887

Declaration-only. metadata-viewer.tsx hand-wrote its own shape for a select option — four of the five keys the spec declares, with the fifth dropped by silence. It is now derived from the spec, with the drop named. Zero behaviour change: StateMachineView's labelOf / colorOf / initial logic, and which keys it reads, are untouched.


Must-answer 1 — where the authoritative declaration lives

Package@objectstack/spec, subpath export @objectstack/spec/data
ExportSelectOption (export type SelectOption = z.input.of.SelectOptionSchema, packages/spec/src/data/field.zod.ts:1905; the schema itself at :217)
Settled?Yes — settled, and this card is 照抄.

Verified on disk, not on trust — three independent checks:

  1. The installed package really exports it.node_modules/@objectstack/spec@17.2.0 re-exports SelectOption and SelectOptionParsed from dist/data/index.d.mts, and the bundled declaration carries exactly five keys:

    label: z.ZodString // required
    value: z.ZodString // required (SystemIdentifierSchema)
    color: z.ZodOptional.of.ZodString
    default: z.ZodOptional.of.ZodBoolean
    visibleWhen: z.ZodOptional.of.ExpressionInput
    
  2. This repo has already sanctioned it as the authority for THIS contract.packages/app-shell/src/views/metadata-admin/form-spec.ts:91 imports type SelectOption from @objectstack/spec/data and derives at :159 in the Omit-with-named-narrowings form. That convergence is objectui#6247 / PR Honour per-option visibleWhen in the metadata-admin renderer #6618 — so the authority for the select option was settled there, not by [app-shell] metadata-admin 的 FormFieldSpec 没有声明 dependsOn,但两个 widget 都读它 —— 类型化的 form spec 写不出这两个 widget 的必备配置 #5040 / [finding] A THIRD inline copy of the form-field authoring contract lives in apps/console FormPage.tsx — objectui#5040 converged only the app-shell two #5542 (those converged FormFieldSpec itself; A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it #6247 converged its options element type, which was the last hand-written position in that file).

  3. A second, independent binding to the same authority already exists.packages/types/src/zod/form.zod.ts's SelectOptionSchema is a spec derivation, pinned by packages/types/src/__tests__/select-option-spec-parity.test.ts — which asserts the local schema carries every spec key and extends it by exactly two documented UI-only keys.

Two consumers already deriving from the same export, one of them test-pinned, is what makes this settled rather than a choice I made. ⛔ Nothing here derives the fifth copy from the fourth.

What changed

packages/components/src/renderers/basic/metadata-viewer.tsx — the module-local interface SelectOption is replaced by a derivation, and the local binding is renamed so a narrowed shape no longer sits under the spec's own export name (the planted-premise hazard check-spec-symbol-derivation.mjs exists to stop):

importtype{SelectOptionasSpecSelectOption}from'@objectstack/spec/data';typeStateOption=Omit<SpecSelectOption,'visibleWhen'>;

Each narrowing is now named, with its reason next to it:

  • visibleWhen is DROPPED, and now says so. This view draws the declared state set — every option the field declares becomes a chip, and the graph comes from the state_machine rule. Per-option visibleWhen withdraws an option from a picker for one record and one current_user (ADR-0068); a diagram of the machine has no such subject, so there is nothing here to evaluate it against.
  • default is KEPT, and is read at initial. That is the whole reason this is a spec derivation and not a narrower local shape: default on the object-field face is ruled enforce (objectstack#7246, implemented by PR #7388), so the state this view calls "initial" is the one the platform actually writes.
  • SelectOption (z.input) rather than SelectOptionParsed, even though this is a read model: on the four retained keys the two are the same type — none of label / value / color / default carries a .default() or a transform — so the entire input/output difference lives on the omitted key.

Structural equivalence — one difference, stated precisely

The derived type is not structurally identical to the hand-written one. It admits one shape less:

keyhand-writtenderivedeffect
labellabel?: stringlabel: string (required)derived rejects{ value } with no label
valuevalue: stringvalue: stringidentical
colorcolor?: stringcolor?: stringidentical
defaultdefault?: booleandefault?: booleanidentical

The hand copy had relaxed the spec's requiredlabel to optional — by silence, the same way it dropped visibleWhen. It is the same divergence objectui#5899's census recorded for AppLike / ObjectLike / RemoteTable / FlowRuntimeState (each relaxing a spec-required key) and for FlowNode (which also makes label optional where the spec requires it).

Nothing turned on the relaxation, and no behaviour follows from removing it:

  • The value reaching this annotation comes from useMetadataItem as any (getField returns any), so nothing is type-checked against this declaration in either direction; tsc is green.
  • labelOf falls back to the raw value for any state the option list does not describe at all — transition sources and targets are not required to be declared options — so the fallback is load-bearing for a case that has nothing to do with a missing label, and is unchanged.
  • The type is module-local, so no released surface moves.

Must-answer 2 — the enumeration: a sixth copy exists, and a seventh

"The fifth" was the card's self-report. Measured now: 8 further hand-written descriptions of this contract remain, in three tiers. ⛔ None is touched by this PR.

Tier 1 — named types describing the same contract (3)

#pathlineshapedrift measurable today
1packages/types/src/field-types.ts290SelectOptionMetadatalabel, value, color?, icon?, disabled?, visibleWhen?Yes — default is absent. This is the object-definition field face, the very face metadata-viewer reads, and it cannot describe the ruled-enforce key. Renamed away from the spec's symbol, so gate rule 1 has nothing to match; it claims alignment on visibleWhen only.
2packages/types/src/form.ts266SelectOption — adds disabled / icon, widens valueNo. Declared under the spec's own export name, but it is the SDUI component dialect, and its Zod twin is a spec derivation pinned by select-option-spec-parity.test.ts + zod-mirror-parity.test.ts. Hand-written TS, transitively backed.
3packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx68interface Option { value: string; label?: string; color?: string }Same relaxed-label divergence this PR just removed, plus default and visibleWhen both absent. No claim comment, no pin — and this one is the object-field options editor, i.e. an authoring surface, unlike the read model here. The closest sibling to this card; the strongest candidate to fold next.

Tier 2 — anonymous inline restatements of the same triple (5)

#pathlineshape
4packages/app-shell/src/services/MetadataService.ts112options?: Array.of.{ label: string; value: string; color?: string } on FieldMetadataPayload
5packages/types/src/data-display.ts415options?: Array.of.{ value: any; label: string; color?: string }
6packages/types/src/reports.ts124options?: Array.of.{ value: string | number; label: string; color?: string }
7packages/plugin-dashboard/src/recordFields.tsx216options?: Array.of.{ value: any; label: string; color?: string } on FieldMeta
8packages/plugin-list/src/UserFilters.tsx31ResolvedOption { label; value; color?; count? } (adds a resolved-only count)

Tier 3 — declared-deliberate, already derived downstream (1, listed for completeness)

packages/core/src/evaluator/optionRules.ts:40OptionLike { label; value; visibleWhen? }, documented in place as a deliberately minimal reader shape kept structurally assignable-from the richer option types. packages/fields/src/widgets/MultiSelectField.tsx:10, RadioField.tsx:10 and CheckboxesField.tsx:10 derive from it (extends / alias) rather than restating it — the behaviour this class wants.

Two conduits deliberately excluded (both already reasoned in place, both carrying a [key: string]: unknown catch-all so they do not restate the vocabulary): ActionParamOption (packages/core/src/actions/ActionRunner.ts:582) and RawActionParamOption (packages/app-shell/src/utils/resolveActionParams.ts:69). Likewise excluded: RadioOption / ComboboxOption (distinct component vocabularies with their own spec schemas) and the various FieldOption shapes in plugin-tree / plugin-view (field pickers — value is a field name, a different concept).

One measured negative worth recording: after this PR, packages/types/src/form.ts:299 is the only declaration left in the tree that carries a select option's default key. The object-definition face (SelectOptionMetadata, #1 above) does not, and neither does the authoring editor (#3).

Gate readings — before and after, unchanged, and why

node scripts/check-spec-symbol-derivation.mjs, both runs exit 0:

before: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages
after: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages

Identical, as expected — this fix is invisible to the gate, and the card's account of why is half the story. The card noted the declaration carries no alignment claim, so rule 2 has nothing to match on. The other half is in the script's own header: both rules skip any declaration without an export modifier (hasExportModifier, once per scanner). This declaration is module-local, so rule 1 could not see it either — even though it was named after a spec export and had drifted, which is exactly rule 1's trigger. objectui#5899 measured that hole: forcing the filter true takes rule 1 from 18 findings to 47, of which 22 of 30 additional sites are real mirrors and 12 carry a divergence measurable today. This declaration was one of that population; it no longer is.

⇒ The gate numbers not moving is the correct outcome, not a missing step. Closing the class the gate cannot see is the card's third candidate direction and is left to the PM.

Verification

All at 2843b62e7, the final commit.

checkcommandresult
dependency closure buildpnpm --filter '@object-ui/components^...' buildexit 0
type-check (both legs)pnpm --filter @object-ui/components type-check (= tsc --noEmit && tsc -p tsconfig.test.json)exit 0
tests, unchangedpnpm vitest run packages/components/src/__tests__/metadata-viewer.test.tsx (root-relative)7 passed / 7, exit 0
eslint (plain form)npx eslint packages/components/src/renderers/basic/metadata-viewer.tsx --format json0 errors; 7 pre-existing no-explicit-any warnings, none on a line this diff adds (the diff adds zero any)
spec symbol derivationpnpm check:spec-symbolsexit 0 (see above)
control bytespnpm check:control-bytesexit 0, 5819 files
phantom depspnpm check:phantom-depsexit 0 — @objectstack/spec is already a declared dependency of @object-ui/components
self-import / esm specifierspnpm check:self-import, pnpm check:esm-specifiersexit 0
changeset presencenode scripts/check-changeset-presence.mjsexit 0 — 1 changeset, empty frontmatter (the explicit "releases nothing" declaration)
changeset no-majornode scripts/check-changeset-no-major.mjsexit 0

Coverage is measured, not assumed.packages/components/tsconfig.jsonexcludessrc/__tests__ and **/*.test.tsx, so the first tsc leg says nothing about test files; tsc --listFiles confirms the edited file is in the main project (1 hit) and the test file is in tsconfig.test.json (1 hit). The eslint run is a declared narrowing to the one changed file: the flat config uses tseslint.configs.recommended with no parserOptions.project and no projectService, so type-aware linting is off and this diff cannot move the verdict on any file it does not touch; the repo-wide pass belongs to CI.

Reverse verification — the derivation is really bound to the spec

Run from the committed state, so the restore leg has a real reference point. Two probes injected at the type alias; predicted direction red, observed red:

mutated blob 3cddbe88 (HEAD blob 35a8621d) — probe greps 1/1, so the mutation reached disk
metadata-viewer.tsx(156,54): error TS2353: Object literal may only specify known
properties, and 'visibleWhen' does not exist in type 'StateOption'.
metadata-viewer.tsx(158,7): error TS2741: Property 'label' is missing in type
'{ value: string; }' but required in type 'StateOption'.
total diagnostics: 2 (tsc exit 2)

TS2353 proves the Omit really removed the key and that the alias resolves to a real object type out of the spec's .d.mts rather than collapsing to any. TS2741 independently confirms the label divergence documented above. Restore was then proven, not assumed: git checkout HEAD -- ABSOLUTE_PATH, git diff HEAD empty, restored blob 35a8621d equal to the HEAD blob, probe residue 0 — and tsc back to exit 0 on the restored tree.

Out of scope, deliberately

  • ⛔ The object-field per-option default (enforce, objectstack#7246 / PR #7388) and the alias table (isDefault / selected) are untouched. This PR is about where a shape is declared, never about what a surface honours.
  • ⛔ The 8 remaining copies enumerated above are listed, not fixed — for the PM to file or fold.

Generated by Claude Code

…spec
`metadata-viewer.tsx` declared a module-local `interface SelectOption` — four
of the five keys `@objectstack/spec/data`'s `SelectOption` declares, with
`visibleWhen` dropped by silence: no `Omit` naming the narrowing, no comment
saying the drop was deliberate. It is the same defect class this repo has been
closing for several cards running (one contract, several hand-written
descriptions, each only ever reconciled against itself).
Derived now, as `type StateOption = Omit<SpecSelectOption, 'visibleWhen'>` —
the `Omit`-with-named-narrowings form `metadata-admin/form-spec.ts` already
uses for `FormFieldSpec.options`. The dropped key and the retained `default`
are each written out beside their reason.
Zero behaviour change: the type is module-local, the value it annotates arrives
from `useMetadataItem` as `any`, and `StateMachineView`'s `labelOf` / `colorOf`
/ `initial` logic is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3180.2 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-BsvtHAQI.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 (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
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)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
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.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
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)512.08KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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 (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 5c09ccaAug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6887-derive-selectoption branch August 31, 2026 13:34
Sign up for freeto 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.

finding(components): metadata-viewer hand-writes a fifth copy of the select-option shape instead of deriving it

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' refactor(components): derive metadata-viewer's option shape from the spec's SelectOption by claude[bot] · Pull Request #7013 · objectstack-ai/objectui · GitHub
Skip to content

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption - #7013

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption
Aug 31, 2026
Merged

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption#7013
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#6887

Declaration-only. metadata-viewer.tsx hand-wrote its own shape for a select option — four of the five keys the spec declares, with the fifth dropped by silence. It is now derived from the spec, with the drop named. Zero behaviour change: StateMachineView's labelOf / colorOf / initial logic, and which keys it reads, are untouched.


Must-answer 1 — where the authoritative declaration lives

Package@objectstack/spec, subpath export @objectstack/spec/data
ExportSelectOption (export type SelectOption = z.input.of.SelectOptionSchema, packages/spec/src/data/field.zod.ts:1905; the schema itself at :217)
Settled?Yes — settled, and this card is 照抄.

Verified on disk, not on trust — three independent checks:

  1. The installed package really exports it.node_modules/@objectstack/spec@17.2.0 re-exports SelectOption and SelectOptionParsed from dist/data/index.d.mts, and the bundled declaration carries exactly five keys:

    label: z.ZodString // required
    value: z.ZodString // required (SystemIdentifierSchema)
    color: z.ZodOptional.of.ZodString
    default: z.ZodOptional.of.ZodBoolean
    visibleWhen: z.ZodOptional.of.ExpressionInput
    
  2. This repo has already sanctioned it as the authority for THIS contract.packages/app-shell/src/views/metadata-admin/form-spec.ts:91 imports type SelectOption from @objectstack/spec/data and derives at :159 in the Omit-with-named-narrowings form. That convergence is objectui#6247 / PR Honour per-option visibleWhen in the metadata-admin renderer #6618 — so the authority for the select option was settled there, not by [app-shell] metadata-admin 的 FormFieldSpec 没有声明 dependsOn,但两个 widget 都读它 —— 类型化的 form spec 写不出这两个 widget 的必备配置 #5040 / [finding] A THIRD inline copy of the form-field authoring contract lives in apps/console FormPage.tsx — objectui#5040 converged only the app-shell two #5542 (those converged FormFieldSpec itself; A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it #6247 converged its options element type, which was the last hand-written position in that file).

  3. A second, independent binding to the same authority already exists.packages/types/src/zod/form.zod.ts's SelectOptionSchema is a spec derivation, pinned by packages/types/src/__tests__/select-option-spec-parity.test.ts — which asserts the local schema carries every spec key and extends it by exactly two documented UI-only keys.

Two consumers already deriving from the same export, one of them test-pinned, is what makes this settled rather than a choice I made. ⛔ Nothing here derives the fifth copy from the fourth.

What changed

packages/components/src/renderers/basic/metadata-viewer.tsx — the module-local interface SelectOption is replaced by a derivation, and the local binding is renamed so a narrowed shape no longer sits under the spec's own export name (the planted-premise hazard check-spec-symbol-derivation.mjs exists to stop):

importtype{SelectOptionasSpecSelectOption}from'@objectstack/spec/data';typeStateOption=Omit<SpecSelectOption,'visibleWhen'>;

Each narrowing is now named, with its reason next to it:

  • visibleWhen is DROPPED, and now says so. This view draws the declared state set — every option the field declares becomes a chip, and the graph comes from the state_machine rule. Per-option visibleWhen withdraws an option from a picker for one record and one current_user (ADR-0068); a diagram of the machine has no such subject, so there is nothing here to evaluate it against.
  • default is KEPT, and is read at initial. That is the whole reason this is a spec derivation and not a narrower local shape: default on the object-field face is ruled enforce (objectstack#7246, implemented by PR #7388), so the state this view calls "initial" is the one the platform actually writes.
  • SelectOption (z.input) rather than SelectOptionParsed, even though this is a read model: on the four retained keys the two are the same type — none of label / value / color / default carries a .default() or a transform — so the entire input/output difference lives on the omitted key.

Structural equivalence — one difference, stated precisely

The derived type is not structurally identical to the hand-written one. It admits one shape less:

keyhand-writtenderivedeffect
labellabel?: stringlabel: string (required)derived rejects{ value } with no label
valuevalue: stringvalue: stringidentical
colorcolor?: stringcolor?: stringidentical
defaultdefault?: booleandefault?: booleanidentical

The hand copy had relaxed the spec's requiredlabel to optional — by silence, the same way it dropped visibleWhen. It is the same divergence objectui#5899's census recorded for AppLike / ObjectLike / RemoteTable / FlowRuntimeState (each relaxing a spec-required key) and for FlowNode (which also makes label optional where the spec requires it).

Nothing turned on the relaxation, and no behaviour follows from removing it:

  • The value reaching this annotation comes from useMetadataItem as any (getField returns any), so nothing is type-checked against this declaration in either direction; tsc is green.
  • labelOf falls back to the raw value for any state the option list does not describe at all — transition sources and targets are not required to be declared options — so the fallback is load-bearing for a case that has nothing to do with a missing label, and is unchanged.
  • The type is module-local, so no released surface moves.

Must-answer 2 — the enumeration: a sixth copy exists, and a seventh

"The fifth" was the card's self-report. Measured now: 8 further hand-written descriptions of this contract remain, in three tiers. ⛔ None is touched by this PR.

Tier 1 — named types describing the same contract (3)

#pathlineshapedrift measurable today
1packages/types/src/field-types.ts290SelectOptionMetadatalabel, value, color?, icon?, disabled?, visibleWhen?Yes — default is absent. This is the object-definition field face, the very face metadata-viewer reads, and it cannot describe the ruled-enforce key. Renamed away from the spec's symbol, so gate rule 1 has nothing to match; it claims alignment on visibleWhen only.
2packages/types/src/form.ts266SelectOption — adds disabled / icon, widens valueNo. Declared under the spec's own export name, but it is the SDUI component dialect, and its Zod twin is a spec derivation pinned by select-option-spec-parity.test.ts + zod-mirror-parity.test.ts. Hand-written TS, transitively backed.
3packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx68interface Option { value: string; label?: string; color?: string }Same relaxed-label divergence this PR just removed, plus default and visibleWhen both absent. No claim comment, no pin — and this one is the object-field options editor, i.e. an authoring surface, unlike the read model here. The closest sibling to this card; the strongest candidate to fold next.

Tier 2 — anonymous inline restatements of the same triple (5)

#pathlineshape
4packages/app-shell/src/services/MetadataService.ts112options?: Array.of.{ label: string; value: string; color?: string } on FieldMetadataPayload
5packages/types/src/data-display.ts415options?: Array.of.{ value: any; label: string; color?: string }
6packages/types/src/reports.ts124options?: Array.of.{ value: string | number; label: string; color?: string }
7packages/plugin-dashboard/src/recordFields.tsx216options?: Array.of.{ value: any; label: string; color?: string } on FieldMeta
8packages/plugin-list/src/UserFilters.tsx31ResolvedOption { label; value; color?; count? } (adds a resolved-only count)

Tier 3 — declared-deliberate, already derived downstream (1, listed for completeness)

packages/core/src/evaluator/optionRules.ts:40OptionLike { label; value; visibleWhen? }, documented in place as a deliberately minimal reader shape kept structurally assignable-from the richer option types. packages/fields/src/widgets/MultiSelectField.tsx:10, RadioField.tsx:10 and CheckboxesField.tsx:10 derive from it (extends / alias) rather than restating it — the behaviour this class wants.

Two conduits deliberately excluded (both already reasoned in place, both carrying a [key: string]: unknown catch-all so they do not restate the vocabulary): ActionParamOption (packages/core/src/actions/ActionRunner.ts:582) and RawActionParamOption (packages/app-shell/src/utils/resolveActionParams.ts:69). Likewise excluded: RadioOption / ComboboxOption (distinct component vocabularies with their own spec schemas) and the various FieldOption shapes in plugin-tree / plugin-view (field pickers — value is a field name, a different concept).

One measured negative worth recording: after this PR, packages/types/src/form.ts:299 is the only declaration left in the tree that carries a select option's default key. The object-definition face (SelectOptionMetadata, #1 above) does not, and neither does the authoring editor (#3).

Gate readings — before and after, unchanged, and why

node scripts/check-spec-symbol-derivation.mjs, both runs exit 0:

before: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages
after: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages

Identical, as expected — this fix is invisible to the gate, and the card's account of why is half the story. The card noted the declaration carries no alignment claim, so rule 2 has nothing to match on. The other half is in the script's own header: both rules skip any declaration without an export modifier (hasExportModifier, once per scanner). This declaration is module-local, so rule 1 could not see it either — even though it was named after a spec export and had drifted, which is exactly rule 1's trigger. objectui#5899 measured that hole: forcing the filter true takes rule 1 from 18 findings to 47, of which 22 of 30 additional sites are real mirrors and 12 carry a divergence measurable today. This declaration was one of that population; it no longer is.

⇒ The gate numbers not moving is the correct outcome, not a missing step. Closing the class the gate cannot see is the card's third candidate direction and is left to the PM.

Verification

All at 2843b62e7, the final commit.

checkcommandresult
dependency closure buildpnpm --filter '@object-ui/components^...' buildexit 0
type-check (both legs)pnpm --filter @object-ui/components type-check (= tsc --noEmit && tsc -p tsconfig.test.json)exit 0
tests, unchangedpnpm vitest run packages/components/src/__tests__/metadata-viewer.test.tsx (root-relative)7 passed / 7, exit 0
eslint (plain form)npx eslint packages/components/src/renderers/basic/metadata-viewer.tsx --format json0 errors; 7 pre-existing no-explicit-any warnings, none on a line this diff adds (the diff adds zero any)
spec symbol derivationpnpm check:spec-symbolsexit 0 (see above)
control bytespnpm check:control-bytesexit 0, 5819 files
phantom depspnpm check:phantom-depsexit 0 — @objectstack/spec is already a declared dependency of @object-ui/components
self-import / esm specifierspnpm check:self-import, pnpm check:esm-specifiersexit 0
changeset presencenode scripts/check-changeset-presence.mjsexit 0 — 1 changeset, empty frontmatter (the explicit "releases nothing" declaration)
changeset no-majornode scripts/check-changeset-no-major.mjsexit 0

Coverage is measured, not assumed.packages/components/tsconfig.jsonexcludessrc/__tests__ and **/*.test.tsx, so the first tsc leg says nothing about test files; tsc --listFiles confirms the edited file is in the main project (1 hit) and the test file is in tsconfig.test.json (1 hit). The eslint run is a declared narrowing to the one changed file: the flat config uses tseslint.configs.recommended with no parserOptions.project and no projectService, so type-aware linting is off and this diff cannot move the verdict on any file it does not touch; the repo-wide pass belongs to CI.

Reverse verification — the derivation is really bound to the spec

Run from the committed state, so the restore leg has a real reference point. Two probes injected at the type alias; predicted direction red, observed red:

mutated blob 3cddbe88 (HEAD blob 35a8621d) — probe greps 1/1, so the mutation reached disk
metadata-viewer.tsx(156,54): error TS2353: Object literal may only specify known
properties, and 'visibleWhen' does not exist in type 'StateOption'.
metadata-viewer.tsx(158,7): error TS2741: Property 'label' is missing in type
'{ value: string; }' but required in type 'StateOption'.
total diagnostics: 2 (tsc exit 2)

TS2353 proves the Omit really removed the key and that the alias resolves to a real object type out of the spec's .d.mts rather than collapsing to any. TS2741 independently confirms the label divergence documented above. Restore was then proven, not assumed: git checkout HEAD -- ABSOLUTE_PATH, git diff HEAD empty, restored blob 35a8621d equal to the HEAD blob, probe residue 0 — and tsc back to exit 0 on the restored tree.

Out of scope, deliberately

  • ⛔ The object-field per-option default (enforce, objectstack#7246 / PR #7388) and the alias table (isDefault / selected) are untouched. This PR is about where a shape is declared, never about what a surface honours.
  • ⛔ The 8 remaining copies enumerated above are listed, not fixed — for the PM to file or fold.

Generated by Claude Code

…spec
`metadata-viewer.tsx` declared a module-local `interface SelectOption` — four
of the five keys `@objectstack/spec/data`'s `SelectOption` declares, with
`visibleWhen` dropped by silence: no `Omit` naming the narrowing, no comment
saying the drop was deliberate. It is the same defect class this repo has been
closing for several cards running (one contract, several hand-written
descriptions, each only ever reconciled against itself).
Derived now, as `type StateOption = Omit<SpecSelectOption, 'visibleWhen'>` —
the `Omit`-with-named-narrowings form `metadata-admin/form-spec.ts` already
uses for `FormFieldSpec.options`. The dropped key and the retained `default`
are each written out beside their reason.
Zero behaviour change: the type is module-local, the value it annotates arrives
from `useMetadataItem` as `any`, and `StateMachineView`'s `labelOf` / `colorOf`
/ `initial` logic is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3180.2 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-BsvtHAQI.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 (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
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)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
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.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
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)512.08KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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 (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 5c09ccaAug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6887-derive-selectoption branch August 31, 2026 13:34
Sign up for freeto 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.

finding(components): metadata-viewer hand-writes a fifth copy of the select-option shape instead of deriving it

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(components): derive metadata-viewer's option shape from the spec's SelectOption by claude[bot] · Pull Request #7013 · objectstack-ai/objectui · GitHub
Skip to content

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption - #7013

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption
Aug 31, 2026
Merged

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption#7013
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#6887

Declaration-only. metadata-viewer.tsx hand-wrote its own shape for a select option — four of the five keys the spec declares, with the fifth dropped by silence. It is now derived from the spec, with the drop named. Zero behaviour change: StateMachineView's labelOf / colorOf / initial logic, and which keys it reads, are untouched.


Must-answer 1 — where the authoritative declaration lives

Package@objectstack/spec, subpath export @objectstack/spec/data
ExportSelectOption (export type SelectOption = z.input.of.SelectOptionSchema, packages/spec/src/data/field.zod.ts:1905; the schema itself at :217)
Settled?Yes — settled, and this card is 照抄.

Verified on disk, not on trust — three independent checks:

  1. The installed package really exports it.node_modules/@objectstack/spec@17.2.0 re-exports SelectOption and SelectOptionParsed from dist/data/index.d.mts, and the bundled declaration carries exactly five keys:

    label: z.ZodString // required
    value: z.ZodString // required (SystemIdentifierSchema)
    color: z.ZodOptional.of.ZodString
    default: z.ZodOptional.of.ZodBoolean
    visibleWhen: z.ZodOptional.of.ExpressionInput
    
  2. This repo has already sanctioned it as the authority for THIS contract.packages/app-shell/src/views/metadata-admin/form-spec.ts:91 imports type SelectOption from @objectstack/spec/data and derives at :159 in the Omit-with-named-narrowings form. That convergence is objectui#6247 / PR Honour per-option visibleWhen in the metadata-admin renderer #6618 — so the authority for the select option was settled there, not by [app-shell] metadata-admin 的 FormFieldSpec 没有声明 dependsOn,但两个 widget 都读它 —— 类型化的 form spec 写不出这两个 widget 的必备配置 #5040 / [finding] A THIRD inline copy of the form-field authoring contract lives in apps/console FormPage.tsx — objectui#5040 converged only the app-shell two #5542 (those converged FormFieldSpec itself; A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it #6247 converged its options element type, which was the last hand-written position in that file).

  3. A second, independent binding to the same authority already exists.packages/types/src/zod/form.zod.ts's SelectOptionSchema is a spec derivation, pinned by packages/types/src/__tests__/select-option-spec-parity.test.ts — which asserts the local schema carries every spec key and extends it by exactly two documented UI-only keys.

Two consumers already deriving from the same export, one of them test-pinned, is what makes this settled rather than a choice I made. ⛔ Nothing here derives the fifth copy from the fourth.

What changed

packages/components/src/renderers/basic/metadata-viewer.tsx — the module-local interface SelectOption is replaced by a derivation, and the local binding is renamed so a narrowed shape no longer sits under the spec's own export name (the planted-premise hazard check-spec-symbol-derivation.mjs exists to stop):

importtype{SelectOptionasSpecSelectOption}from'@objectstack/spec/data';typeStateOption=Omit<SpecSelectOption,'visibleWhen'>;

Each narrowing is now named, with its reason next to it:

  • visibleWhen is DROPPED, and now says so. This view draws the declared state set — every option the field declares becomes a chip, and the graph comes from the state_machine rule. Per-option visibleWhen withdraws an option from a picker for one record and one current_user (ADR-0068); a diagram of the machine has no such subject, so there is nothing here to evaluate it against.
  • default is KEPT, and is read at initial. That is the whole reason this is a spec derivation and not a narrower local shape: default on the object-field face is ruled enforce (objectstack#7246, implemented by PR #7388), so the state this view calls "initial" is the one the platform actually writes.
  • SelectOption (z.input) rather than SelectOptionParsed, even though this is a read model: on the four retained keys the two are the same type — none of label / value / color / default carries a .default() or a transform — so the entire input/output difference lives on the omitted key.

Structural equivalence — one difference, stated precisely

The derived type is not structurally identical to the hand-written one. It admits one shape less:

keyhand-writtenderivedeffect
labellabel?: stringlabel: string (required)derived rejects{ value } with no label
valuevalue: stringvalue: stringidentical
colorcolor?: stringcolor?: stringidentical
defaultdefault?: booleandefault?: booleanidentical

The hand copy had relaxed the spec's requiredlabel to optional — by silence, the same way it dropped visibleWhen. It is the same divergence objectui#5899's census recorded for AppLike / ObjectLike / RemoteTable / FlowRuntimeState (each relaxing a spec-required key) and for FlowNode (which also makes label optional where the spec requires it).

Nothing turned on the relaxation, and no behaviour follows from removing it:

  • The value reaching this annotation comes from useMetadataItem as any (getField returns any), so nothing is type-checked against this declaration in either direction; tsc is green.
  • labelOf falls back to the raw value for any state the option list does not describe at all — transition sources and targets are not required to be declared options — so the fallback is load-bearing for a case that has nothing to do with a missing label, and is unchanged.
  • The type is module-local, so no released surface moves.

Must-answer 2 — the enumeration: a sixth copy exists, and a seventh

"The fifth" was the card's self-report. Measured now: 8 further hand-written descriptions of this contract remain, in three tiers. ⛔ None is touched by this PR.

Tier 1 — named types describing the same contract (3)

#pathlineshapedrift measurable today
1packages/types/src/field-types.ts290SelectOptionMetadatalabel, value, color?, icon?, disabled?, visibleWhen?Yes — default is absent. This is the object-definition field face, the very face metadata-viewer reads, and it cannot describe the ruled-enforce key. Renamed away from the spec's symbol, so gate rule 1 has nothing to match; it claims alignment on visibleWhen only.
2packages/types/src/form.ts266SelectOption — adds disabled / icon, widens valueNo. Declared under the spec's own export name, but it is the SDUI component dialect, and its Zod twin is a spec derivation pinned by select-option-spec-parity.test.ts + zod-mirror-parity.test.ts. Hand-written TS, transitively backed.
3packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx68interface Option { value: string; label?: string; color?: string }Same relaxed-label divergence this PR just removed, plus default and visibleWhen both absent. No claim comment, no pin — and this one is the object-field options editor, i.e. an authoring surface, unlike the read model here. The closest sibling to this card; the strongest candidate to fold next.

Tier 2 — anonymous inline restatements of the same triple (5)

#pathlineshape
4packages/app-shell/src/services/MetadataService.ts112options?: Array.of.{ label: string; value: string; color?: string } on FieldMetadataPayload
5packages/types/src/data-display.ts415options?: Array.of.{ value: any; label: string; color?: string }
6packages/types/src/reports.ts124options?: Array.of.{ value: string | number; label: string; color?: string }
7packages/plugin-dashboard/src/recordFields.tsx216options?: Array.of.{ value: any; label: string; color?: string } on FieldMeta
8packages/plugin-list/src/UserFilters.tsx31ResolvedOption { label; value; color?; count? } (adds a resolved-only count)

Tier 3 — declared-deliberate, already derived downstream (1, listed for completeness)

packages/core/src/evaluator/optionRules.ts:40OptionLike { label; value; visibleWhen? }, documented in place as a deliberately minimal reader shape kept structurally assignable-from the richer option types. packages/fields/src/widgets/MultiSelectField.tsx:10, RadioField.tsx:10 and CheckboxesField.tsx:10 derive from it (extends / alias) rather than restating it — the behaviour this class wants.

Two conduits deliberately excluded (both already reasoned in place, both carrying a [key: string]: unknown catch-all so they do not restate the vocabulary): ActionParamOption (packages/core/src/actions/ActionRunner.ts:582) and RawActionParamOption (packages/app-shell/src/utils/resolveActionParams.ts:69). Likewise excluded: RadioOption / ComboboxOption (distinct component vocabularies with their own spec schemas) and the various FieldOption shapes in plugin-tree / plugin-view (field pickers — value is a field name, a different concept).

One measured negative worth recording: after this PR, packages/types/src/form.ts:299 is the only declaration left in the tree that carries a select option's default key. The object-definition face (SelectOptionMetadata, #1 above) does not, and neither does the authoring editor (#3).

Gate readings — before and after, unchanged, and why

node scripts/check-spec-symbol-derivation.mjs, both runs exit 0:

before: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages
after: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages

Identical, as expected — this fix is invisible to the gate, and the card's account of why is half the story. The card noted the declaration carries no alignment claim, so rule 2 has nothing to match on. The other half is in the script's own header: both rules skip any declaration without an export modifier (hasExportModifier, once per scanner). This declaration is module-local, so rule 1 could not see it either — even though it was named after a spec export and had drifted, which is exactly rule 1's trigger. objectui#5899 measured that hole: forcing the filter true takes rule 1 from 18 findings to 47, of which 22 of 30 additional sites are real mirrors and 12 carry a divergence measurable today. This declaration was one of that population; it no longer is.

⇒ The gate numbers not moving is the correct outcome, not a missing step. Closing the class the gate cannot see is the card's third candidate direction and is left to the PM.

Verification

All at 2843b62e7, the final commit.

checkcommandresult
dependency closure buildpnpm --filter '@object-ui/components^...' buildexit 0
type-check (both legs)pnpm --filter @object-ui/components type-check (= tsc --noEmit && tsc -p tsconfig.test.json)exit 0
tests, unchangedpnpm vitest run packages/components/src/__tests__/metadata-viewer.test.tsx (root-relative)7 passed / 7, exit 0
eslint (plain form)npx eslint packages/components/src/renderers/basic/metadata-viewer.tsx --format json0 errors; 7 pre-existing no-explicit-any warnings, none on a line this diff adds (the diff adds zero any)
spec symbol derivationpnpm check:spec-symbolsexit 0 (see above)
control bytespnpm check:control-bytesexit 0, 5819 files
phantom depspnpm check:phantom-depsexit 0 — @objectstack/spec is already a declared dependency of @object-ui/components
self-import / esm specifierspnpm check:self-import, pnpm check:esm-specifiersexit 0
changeset presencenode scripts/check-changeset-presence.mjsexit 0 — 1 changeset, empty frontmatter (the explicit "releases nothing" declaration)
changeset no-majornode scripts/check-changeset-no-major.mjsexit 0

Coverage is measured, not assumed.packages/components/tsconfig.jsonexcludessrc/__tests__ and **/*.test.tsx, so the first tsc leg says nothing about test files; tsc --listFiles confirms the edited file is in the main project (1 hit) and the test file is in tsconfig.test.json (1 hit). The eslint run is a declared narrowing to the one changed file: the flat config uses tseslint.configs.recommended with no parserOptions.project and no projectService, so type-aware linting is off and this diff cannot move the verdict on any file it does not touch; the repo-wide pass belongs to CI.

Reverse verification — the derivation is really bound to the spec

Run from the committed state, so the restore leg has a real reference point. Two probes injected at the type alias; predicted direction red, observed red:

mutated blob 3cddbe88 (HEAD blob 35a8621d) — probe greps 1/1, so the mutation reached disk
metadata-viewer.tsx(156,54): error TS2353: Object literal may only specify known
properties, and 'visibleWhen' does not exist in type 'StateOption'.
metadata-viewer.tsx(158,7): error TS2741: Property 'label' is missing in type
'{ value: string; }' but required in type 'StateOption'.
total diagnostics: 2 (tsc exit 2)

TS2353 proves the Omit really removed the key and that the alias resolves to a real object type out of the spec's .d.mts rather than collapsing to any. TS2741 independently confirms the label divergence documented above. Restore was then proven, not assumed: git checkout HEAD -- ABSOLUTE_PATH, git diff HEAD empty, restored blob 35a8621d equal to the HEAD blob, probe residue 0 — and tsc back to exit 0 on the restored tree.

Out of scope, deliberately

  • ⛔ The object-field per-option default (enforce, objectstack#7246 / PR #7388) and the alias table (isDefault / selected) are untouched. This PR is about where a shape is declared, never about what a surface honours.
  • ⛔ The 8 remaining copies enumerated above are listed, not fixed — for the PM to file or fold.

Generated by Claude Code

…spec
`metadata-viewer.tsx` declared a module-local `interface SelectOption` — four
of the five keys `@objectstack/spec/data`'s `SelectOption` declares, with
`visibleWhen` dropped by silence: no `Omit` naming the narrowing, no comment
saying the drop was deliberate. It is the same defect class this repo has been
closing for several cards running (one contract, several hand-written
descriptions, each only ever reconciled against itself).
Derived now, as `type StateOption = Omit<SpecSelectOption, 'visibleWhen'>` —
the `Omit`-with-named-narrowings form `metadata-admin/form-spec.ts` already
uses for `FormFieldSpec.options`. The dropped key and the retained `default`
are each written out beside their reason.
Zero behaviour change: the type is module-local, the value it annotates arrives
from `useMetadataItem` as `any`, and `StateMachineView`'s `labelOf` / `colorOf`
/ `initial` logic is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3180.2 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-BsvtHAQI.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 (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
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)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
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.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
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)512.08KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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 (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 5c09ccaAug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6887-derive-selectoption branch August 31, 2026 13:34
Sign up for freeto 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.

finding(components): metadata-viewer hand-writes a fifth copy of the select-option shape instead of deriving it

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' refactor(components): derive metadata-viewer's option shape from the spec's SelectOption by claude[bot] · Pull Request #7013 · objectstack-ai/objectui · GitHub
Skip to content

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption - #7013

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption
Aug 31, 2026
Merged

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption#7013
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#6887

Declaration-only. metadata-viewer.tsx hand-wrote its own shape for a select option — four of the five keys the spec declares, with the fifth dropped by silence. It is now derived from the spec, with the drop named. Zero behaviour change: StateMachineView's labelOf / colorOf / initial logic, and which keys it reads, are untouched.


Must-answer 1 — where the authoritative declaration lives

Package@objectstack/spec, subpath export @objectstack/spec/data
ExportSelectOption (export type SelectOption = z.input.of.SelectOptionSchema, packages/spec/src/data/field.zod.ts:1905; the schema itself at :217)
Settled?Yes — settled, and this card is 照抄.

Verified on disk, not on trust — three independent checks:

  1. The installed package really exports it.node_modules/@objectstack/spec@17.2.0 re-exports SelectOption and SelectOptionParsed from dist/data/index.d.mts, and the bundled declaration carries exactly five keys:

    label: z.ZodString // required
    value: z.ZodString // required (SystemIdentifierSchema)
    color: z.ZodOptional.of.ZodString
    default: z.ZodOptional.of.ZodBoolean
    visibleWhen: z.ZodOptional.of.ExpressionInput
    
  2. This repo has already sanctioned it as the authority for THIS contract.packages/app-shell/src/views/metadata-admin/form-spec.ts:91 imports type SelectOption from @objectstack/spec/data and derives at :159 in the Omit-with-named-narrowings form. That convergence is objectui#6247 / PR Honour per-option visibleWhen in the metadata-admin renderer #6618 — so the authority for the select option was settled there, not by [app-shell] metadata-admin 的 FormFieldSpec 没有声明 dependsOn,但两个 widget 都读它 —— 类型化的 form spec 写不出这两个 widget 的必备配置 #5040 / [finding] A THIRD inline copy of the form-field authoring contract lives in apps/console FormPage.tsx — objectui#5040 converged only the app-shell two #5542 (those converged FormFieldSpec itself; A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it #6247 converged its options element type, which was the last hand-written position in that file).

  3. A second, independent binding to the same authority already exists.packages/types/src/zod/form.zod.ts's SelectOptionSchema is a spec derivation, pinned by packages/types/src/__tests__/select-option-spec-parity.test.ts — which asserts the local schema carries every spec key and extends it by exactly two documented UI-only keys.

Two consumers already deriving from the same export, one of them test-pinned, is what makes this settled rather than a choice I made. ⛔ Nothing here derives the fifth copy from the fourth.

What changed

packages/components/src/renderers/basic/metadata-viewer.tsx — the module-local interface SelectOption is replaced by a derivation, and the local binding is renamed so a narrowed shape no longer sits under the spec's own export name (the planted-premise hazard check-spec-symbol-derivation.mjs exists to stop):

importtype{SelectOptionasSpecSelectOption}from'@objectstack/spec/data';typeStateOption=Omit<SpecSelectOption,'visibleWhen'>;

Each narrowing is now named, with its reason next to it:

  • visibleWhen is DROPPED, and now says so. This view draws the declared state set — every option the field declares becomes a chip, and the graph comes from the state_machine rule. Per-option visibleWhen withdraws an option from a picker for one record and one current_user (ADR-0068); a diagram of the machine has no such subject, so there is nothing here to evaluate it against.
  • default is KEPT, and is read at initial. That is the whole reason this is a spec derivation and not a narrower local shape: default on the object-field face is ruled enforce (objectstack#7246, implemented by PR #7388), so the state this view calls "initial" is the one the platform actually writes.
  • SelectOption (z.input) rather than SelectOptionParsed, even though this is a read model: on the four retained keys the two are the same type — none of label / value / color / default carries a .default() or a transform — so the entire input/output difference lives on the omitted key.

Structural equivalence — one difference, stated precisely

The derived type is not structurally identical to the hand-written one. It admits one shape less:

keyhand-writtenderivedeffect
labellabel?: stringlabel: string (required)derived rejects{ value } with no label
valuevalue: stringvalue: stringidentical
colorcolor?: stringcolor?: stringidentical
defaultdefault?: booleandefault?: booleanidentical

The hand copy had relaxed the spec's requiredlabel to optional — by silence, the same way it dropped visibleWhen. It is the same divergence objectui#5899's census recorded for AppLike / ObjectLike / RemoteTable / FlowRuntimeState (each relaxing a spec-required key) and for FlowNode (which also makes label optional where the spec requires it).

Nothing turned on the relaxation, and no behaviour follows from removing it:

  • The value reaching this annotation comes from useMetadataItem as any (getField returns any), so nothing is type-checked against this declaration in either direction; tsc is green.
  • labelOf falls back to the raw value for any state the option list does not describe at all — transition sources and targets are not required to be declared options — so the fallback is load-bearing for a case that has nothing to do with a missing label, and is unchanged.
  • The type is module-local, so no released surface moves.

Must-answer 2 — the enumeration: a sixth copy exists, and a seventh

"The fifth" was the card's self-report. Measured now: 8 further hand-written descriptions of this contract remain, in three tiers. ⛔ None is touched by this PR.

Tier 1 — named types describing the same contract (3)

#pathlineshapedrift measurable today
1packages/types/src/field-types.ts290SelectOptionMetadatalabel, value, color?, icon?, disabled?, visibleWhen?Yes — default is absent. This is the object-definition field face, the very face metadata-viewer reads, and it cannot describe the ruled-enforce key. Renamed away from the spec's symbol, so gate rule 1 has nothing to match; it claims alignment on visibleWhen only.
2packages/types/src/form.ts266SelectOption — adds disabled / icon, widens valueNo. Declared under the spec's own export name, but it is the SDUI component dialect, and its Zod twin is a spec derivation pinned by select-option-spec-parity.test.ts + zod-mirror-parity.test.ts. Hand-written TS, transitively backed.
3packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx68interface Option { value: string; label?: string; color?: string }Same relaxed-label divergence this PR just removed, plus default and visibleWhen both absent. No claim comment, no pin — and this one is the object-field options editor, i.e. an authoring surface, unlike the read model here. The closest sibling to this card; the strongest candidate to fold next.

Tier 2 — anonymous inline restatements of the same triple (5)

#pathlineshape
4packages/app-shell/src/services/MetadataService.ts112options?: Array.of.{ label: string; value: string; color?: string } on FieldMetadataPayload
5packages/types/src/data-display.ts415options?: Array.of.{ value: any; label: string; color?: string }
6packages/types/src/reports.ts124options?: Array.of.{ value: string | number; label: string; color?: string }
7packages/plugin-dashboard/src/recordFields.tsx216options?: Array.of.{ value: any; label: string; color?: string } on FieldMeta
8packages/plugin-list/src/UserFilters.tsx31ResolvedOption { label; value; color?; count? } (adds a resolved-only count)

Tier 3 — declared-deliberate, already derived downstream (1, listed for completeness)

packages/core/src/evaluator/optionRules.ts:40OptionLike { label; value; visibleWhen? }, documented in place as a deliberately minimal reader shape kept structurally assignable-from the richer option types. packages/fields/src/widgets/MultiSelectField.tsx:10, RadioField.tsx:10 and CheckboxesField.tsx:10 derive from it (extends / alias) rather than restating it — the behaviour this class wants.

Two conduits deliberately excluded (both already reasoned in place, both carrying a [key: string]: unknown catch-all so they do not restate the vocabulary): ActionParamOption (packages/core/src/actions/ActionRunner.ts:582) and RawActionParamOption (packages/app-shell/src/utils/resolveActionParams.ts:69). Likewise excluded: RadioOption / ComboboxOption (distinct component vocabularies with their own spec schemas) and the various FieldOption shapes in plugin-tree / plugin-view (field pickers — value is a field name, a different concept).

One measured negative worth recording: after this PR, packages/types/src/form.ts:299 is the only declaration left in the tree that carries a select option's default key. The object-definition face (SelectOptionMetadata, #1 above) does not, and neither does the authoring editor (#3).

Gate readings — before and after, unchanged, and why

node scripts/check-spec-symbol-derivation.mjs, both runs exit 0:

before: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages
after: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages

Identical, as expected — this fix is invisible to the gate, and the card's account of why is half the story. The card noted the declaration carries no alignment claim, so rule 2 has nothing to match on. The other half is in the script's own header: both rules skip any declaration without an export modifier (hasExportModifier, once per scanner). This declaration is module-local, so rule 1 could not see it either — even though it was named after a spec export and had drifted, which is exactly rule 1's trigger. objectui#5899 measured that hole: forcing the filter true takes rule 1 from 18 findings to 47, of which 22 of 30 additional sites are real mirrors and 12 carry a divergence measurable today. This declaration was one of that population; it no longer is.

⇒ The gate numbers not moving is the correct outcome, not a missing step. Closing the class the gate cannot see is the card's third candidate direction and is left to the PM.

Verification

All at 2843b62e7, the final commit.

checkcommandresult
dependency closure buildpnpm --filter '@object-ui/components^...' buildexit 0
type-check (both legs)pnpm --filter @object-ui/components type-check (= tsc --noEmit && tsc -p tsconfig.test.json)exit 0
tests, unchangedpnpm vitest run packages/components/src/__tests__/metadata-viewer.test.tsx (root-relative)7 passed / 7, exit 0
eslint (plain form)npx eslint packages/components/src/renderers/basic/metadata-viewer.tsx --format json0 errors; 7 pre-existing no-explicit-any warnings, none on a line this diff adds (the diff adds zero any)
spec symbol derivationpnpm check:spec-symbolsexit 0 (see above)
control bytespnpm check:control-bytesexit 0, 5819 files
phantom depspnpm check:phantom-depsexit 0 — @objectstack/spec is already a declared dependency of @object-ui/components
self-import / esm specifierspnpm check:self-import, pnpm check:esm-specifiersexit 0
changeset presencenode scripts/check-changeset-presence.mjsexit 0 — 1 changeset, empty frontmatter (the explicit "releases nothing" declaration)
changeset no-majornode scripts/check-changeset-no-major.mjsexit 0

Coverage is measured, not assumed.packages/components/tsconfig.jsonexcludessrc/__tests__ and **/*.test.tsx, so the first tsc leg says nothing about test files; tsc --listFiles confirms the edited file is in the main project (1 hit) and the test file is in tsconfig.test.json (1 hit). The eslint run is a declared narrowing to the one changed file: the flat config uses tseslint.configs.recommended with no parserOptions.project and no projectService, so type-aware linting is off and this diff cannot move the verdict on any file it does not touch; the repo-wide pass belongs to CI.

Reverse verification — the derivation is really bound to the spec

Run from the committed state, so the restore leg has a real reference point. Two probes injected at the type alias; predicted direction red, observed red:

mutated blob 3cddbe88 (HEAD blob 35a8621d) — probe greps 1/1, so the mutation reached disk
metadata-viewer.tsx(156,54): error TS2353: Object literal may only specify known
properties, and 'visibleWhen' does not exist in type 'StateOption'.
metadata-viewer.tsx(158,7): error TS2741: Property 'label' is missing in type
'{ value: string; }' but required in type 'StateOption'.
total diagnostics: 2 (tsc exit 2)

TS2353 proves the Omit really removed the key and that the alias resolves to a real object type out of the spec's .d.mts rather than collapsing to any. TS2741 independently confirms the label divergence documented above. Restore was then proven, not assumed: git checkout HEAD -- ABSOLUTE_PATH, git diff HEAD empty, restored blob 35a8621d equal to the HEAD blob, probe residue 0 — and tsc back to exit 0 on the restored tree.

Out of scope, deliberately

  • ⛔ The object-field per-option default (enforce, objectstack#7246 / PR #7388) and the alias table (isDefault / selected) are untouched. This PR is about where a shape is declared, never about what a surface honours.
  • ⛔ The 8 remaining copies enumerated above are listed, not fixed — for the PM to file or fold.

Generated by Claude Code

…spec
`metadata-viewer.tsx` declared a module-local `interface SelectOption` — four
of the five keys `@objectstack/spec/data`'s `SelectOption` declares, with
`visibleWhen` dropped by silence: no `Omit` naming the narrowing, no comment
saying the drop was deliberate. It is the same defect class this repo has been
closing for several cards running (one contract, several hand-written
descriptions, each only ever reconciled against itself).
Derived now, as `type StateOption = Omit<SpecSelectOption, 'visibleWhen'>` —
the `Omit`-with-named-narrowings form `metadata-admin/form-spec.ts` already
uses for `FormFieldSpec.options`. The dropped key and the retained `default`
are each written out beside their reason.
Zero behaviour change: the type is module-local, the value it annotates arrives
from `useMetadataItem` as `any`, and `StateMachineView`'s `labelOf` / `colorOf`
/ `initial` logic is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3180.2 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-BsvtHAQI.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 (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
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)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
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.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
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)512.08KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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 (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 5c09ccaAug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6887-derive-selectoption branch August 31, 2026 13:34
Sign up for freeto 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.

finding(components): metadata-viewer hand-writes a fifth copy of the select-option shape instead of deriving it

2 participants

@os-sam@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); refactor(components): derive metadata-viewer's option shape from the spec's SelectOption by claude[bot] · Pull Request #7013 · objectstack-ai/objectui · GitHub
Skip to content

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption - #7013

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption
Aug 31, 2026
Merged

refactor(components): derive metadata-viewer's option shape from the spec's SelectOption#7013
os-sam merged 1 commit into
mainfrom
claude/issue-6887-derive-selectoption

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes#6887

Declaration-only. metadata-viewer.tsx hand-wrote its own shape for a select option — four of the five keys the spec declares, with the fifth dropped by silence. It is now derived from the spec, with the drop named. Zero behaviour change: StateMachineView's labelOf / colorOf / initial logic, and which keys it reads, are untouched.


Must-answer 1 — where the authoritative declaration lives

Package@objectstack/spec, subpath export @objectstack/spec/data
ExportSelectOption (export type SelectOption = z.input.of.SelectOptionSchema, packages/spec/src/data/field.zod.ts:1905; the schema itself at :217)
Settled?Yes — settled, and this card is 照抄.

Verified on disk, not on trust — three independent checks:

  1. The installed package really exports it.node_modules/@objectstack/spec@17.2.0 re-exports SelectOption and SelectOptionParsed from dist/data/index.d.mts, and the bundled declaration carries exactly five keys:

    label: z.ZodString // required
    value: z.ZodString // required (SystemIdentifierSchema)
    color: z.ZodOptional.of.ZodString
    default: z.ZodOptional.of.ZodBoolean
    visibleWhen: z.ZodOptional.of.ExpressionInput
    
  2. This repo has already sanctioned it as the authority for THIS contract.packages/app-shell/src/views/metadata-admin/form-spec.ts:91 imports type SelectOption from @objectstack/spec/data and derives at :159 in the Omit-with-named-narrowings form. That convergence is objectui#6247 / PR Honour per-option visibleWhen in the metadata-admin renderer #6618 — so the authority for the select option was settled there, not by [app-shell] metadata-admin 的 FormFieldSpec 没有声明 dependsOn,但两个 widget 都读它 —— 类型化的 form spec 写不出这两个 widget 的必备配置 #5040 / [finding] A THIRD inline copy of the form-field authoring contract lives in apps/console FormPage.tsx — objectui#5040 converged only the app-shell two #5542 (those converged FormFieldSpec itself; A per-option visibleWhen written in a *.form.ts is silently inert — the metadata-admin renderer never reads it #6247 converged its options element type, which was the last hand-written position in that file).

  3. A second, independent binding to the same authority already exists.packages/types/src/zod/form.zod.ts's SelectOptionSchema is a spec derivation, pinned by packages/types/src/__tests__/select-option-spec-parity.test.ts — which asserts the local schema carries every spec key and extends it by exactly two documented UI-only keys.

Two consumers already deriving from the same export, one of them test-pinned, is what makes this settled rather than a choice I made. ⛔ Nothing here derives the fifth copy from the fourth.

What changed

packages/components/src/renderers/basic/metadata-viewer.tsx — the module-local interface SelectOption is replaced by a derivation, and the local binding is renamed so a narrowed shape no longer sits under the spec's own export name (the planted-premise hazard check-spec-symbol-derivation.mjs exists to stop):

importtype{SelectOptionasSpecSelectOption}from'@objectstack/spec/data';typeStateOption=Omit<SpecSelectOption,'visibleWhen'>;

Each narrowing is now named, with its reason next to it:

  • visibleWhen is DROPPED, and now says so. This view draws the declared state set — every option the field declares becomes a chip, and the graph comes from the state_machine rule. Per-option visibleWhen withdraws an option from a picker for one record and one current_user (ADR-0068); a diagram of the machine has no such subject, so there is nothing here to evaluate it against.
  • default is KEPT, and is read at initial. That is the whole reason this is a spec derivation and not a narrower local shape: default on the object-field face is ruled enforce (objectstack#7246, implemented by PR #7388), so the state this view calls "initial" is the one the platform actually writes.
  • SelectOption (z.input) rather than SelectOptionParsed, even though this is a read model: on the four retained keys the two are the same type — none of label / value / color / default carries a .default() or a transform — so the entire input/output difference lives on the omitted key.

Structural equivalence — one difference, stated precisely

The derived type is not structurally identical to the hand-written one. It admits one shape less:

keyhand-writtenderivedeffect
labellabel?: stringlabel: string (required)derived rejects{ value } with no label
valuevalue: stringvalue: stringidentical
colorcolor?: stringcolor?: stringidentical
defaultdefault?: booleandefault?: booleanidentical

The hand copy had relaxed the spec's requiredlabel to optional — by silence, the same way it dropped visibleWhen. It is the same divergence objectui#5899's census recorded for AppLike / ObjectLike / RemoteTable / FlowRuntimeState (each relaxing a spec-required key) and for FlowNode (which also makes label optional where the spec requires it).

Nothing turned on the relaxation, and no behaviour follows from removing it:

  • The value reaching this annotation comes from useMetadataItem as any (getField returns any), so nothing is type-checked against this declaration in either direction; tsc is green.
  • labelOf falls back to the raw value for any state the option list does not describe at all — transition sources and targets are not required to be declared options — so the fallback is load-bearing for a case that has nothing to do with a missing label, and is unchanged.
  • The type is module-local, so no released surface moves.

Must-answer 2 — the enumeration: a sixth copy exists, and a seventh

"The fifth" was the card's self-report. Measured now: 8 further hand-written descriptions of this contract remain, in three tiers. ⛔ None is touched by this PR.

Tier 1 — named types describing the same contract (3)

#pathlineshapedrift measurable today
1packages/types/src/field-types.ts290SelectOptionMetadatalabel, value, color?, icon?, disabled?, visibleWhen?Yes — default is absent. This is the object-definition field face, the very face metadata-viewer reads, and it cannot describe the ruled-enforce key. Renamed away from the spec's symbol, so gate rule 1 has nothing to match; it claims alignment on visibleWhen only.
2packages/types/src/form.ts266SelectOption — adds disabled / icon, widens valueNo. Declared under the spec's own export name, but it is the SDUI component dialect, and its Zod twin is a spec derivation pinned by select-option-spec-parity.test.ts + zod-mirror-parity.test.ts. Hand-written TS, transitively backed.
3packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx68interface Option { value: string; label?: string; color?: string }Same relaxed-label divergence this PR just removed, plus default and visibleWhen both absent. No claim comment, no pin — and this one is the object-field options editor, i.e. an authoring surface, unlike the read model here. The closest sibling to this card; the strongest candidate to fold next.

Tier 2 — anonymous inline restatements of the same triple (5)

#pathlineshape
4packages/app-shell/src/services/MetadataService.ts112options?: Array.of.{ label: string; value: string; color?: string } on FieldMetadataPayload
5packages/types/src/data-display.ts415options?: Array.of.{ value: any; label: string; color?: string }
6packages/types/src/reports.ts124options?: Array.of.{ value: string | number; label: string; color?: string }
7packages/plugin-dashboard/src/recordFields.tsx216options?: Array.of.{ value: any; label: string; color?: string } on FieldMeta
8packages/plugin-list/src/UserFilters.tsx31ResolvedOption { label; value; color?; count? } (adds a resolved-only count)

Tier 3 — declared-deliberate, already derived downstream (1, listed for completeness)

packages/core/src/evaluator/optionRules.ts:40OptionLike { label; value; visibleWhen? }, documented in place as a deliberately minimal reader shape kept structurally assignable-from the richer option types. packages/fields/src/widgets/MultiSelectField.tsx:10, RadioField.tsx:10 and CheckboxesField.tsx:10 derive from it (extends / alias) rather than restating it — the behaviour this class wants.

Two conduits deliberately excluded (both already reasoned in place, both carrying a [key: string]: unknown catch-all so they do not restate the vocabulary): ActionParamOption (packages/core/src/actions/ActionRunner.ts:582) and RawActionParamOption (packages/app-shell/src/utils/resolveActionParams.ts:69). Likewise excluded: RadioOption / ComboboxOption (distinct component vocabularies with their own spec schemas) and the various FieldOption shapes in plugin-tree / plugin-view (field pickers — value is a field name, a different concept).

One measured negative worth recording: after this PR, packages/types/src/form.ts:299 is the only declaration left in the tree that carries a select option's default key. The object-definition face (SelectOptionMetadata, #1 above) does not, and neither does the authoring editor (#3).

Gate readings — before and after, unchanged, and why

node scripts/check-spec-symbol-derivation.mjs, both runs exit 0:

before: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages
after: 16 declared dialects, 0 untriaged collisions in 0 packages
2 declared deliberate copies, 18 unbacked claims in 5 packages

Identical, as expected — this fix is invisible to the gate, and the card's account of why is half the story. The card noted the declaration carries no alignment claim, so rule 2 has nothing to match on. The other half is in the script's own header: both rules skip any declaration without an export modifier (hasExportModifier, once per scanner). This declaration is module-local, so rule 1 could not see it either — even though it was named after a spec export and had drifted, which is exactly rule 1's trigger. objectui#5899 measured that hole: forcing the filter true takes rule 1 from 18 findings to 47, of which 22 of 30 additional sites are real mirrors and 12 carry a divergence measurable today. This declaration was one of that population; it no longer is.

⇒ The gate numbers not moving is the correct outcome, not a missing step. Closing the class the gate cannot see is the card's third candidate direction and is left to the PM.

Verification

All at 2843b62e7, the final commit.

checkcommandresult
dependency closure buildpnpm --filter '@object-ui/components^...' buildexit 0
type-check (both legs)pnpm --filter @object-ui/components type-check (= tsc --noEmit && tsc -p tsconfig.test.json)exit 0
tests, unchangedpnpm vitest run packages/components/src/__tests__/metadata-viewer.test.tsx (root-relative)7 passed / 7, exit 0
eslint (plain form)npx eslint packages/components/src/renderers/basic/metadata-viewer.tsx --format json0 errors; 7 pre-existing no-explicit-any warnings, none on a line this diff adds (the diff adds zero any)
spec symbol derivationpnpm check:spec-symbolsexit 0 (see above)
control bytespnpm check:control-bytesexit 0, 5819 files
phantom depspnpm check:phantom-depsexit 0 — @objectstack/spec is already a declared dependency of @object-ui/components
self-import / esm specifierspnpm check:self-import, pnpm check:esm-specifiersexit 0
changeset presencenode scripts/check-changeset-presence.mjsexit 0 — 1 changeset, empty frontmatter (the explicit "releases nothing" declaration)
changeset no-majornode scripts/check-changeset-no-major.mjsexit 0

Coverage is measured, not assumed.packages/components/tsconfig.jsonexcludessrc/__tests__ and **/*.test.tsx, so the first tsc leg says nothing about test files; tsc --listFiles confirms the edited file is in the main project (1 hit) and the test file is in tsconfig.test.json (1 hit). The eslint run is a declared narrowing to the one changed file: the flat config uses tseslint.configs.recommended with no parserOptions.project and no projectService, so type-aware linting is off and this diff cannot move the verdict on any file it does not touch; the repo-wide pass belongs to CI.

Reverse verification — the derivation is really bound to the spec

Run from the committed state, so the restore leg has a real reference point. Two probes injected at the type alias; predicted direction red, observed red:

mutated blob 3cddbe88 (HEAD blob 35a8621d) — probe greps 1/1, so the mutation reached disk
metadata-viewer.tsx(156,54): error TS2353: Object literal may only specify known
properties, and 'visibleWhen' does not exist in type 'StateOption'.
metadata-viewer.tsx(158,7): error TS2741: Property 'label' is missing in type
'{ value: string; }' but required in type 'StateOption'.
total diagnostics: 2 (tsc exit 2)

TS2353 proves the Omit really removed the key and that the alias resolves to a real object type out of the spec's .d.mts rather than collapsing to any. TS2741 independently confirms the label divergence documented above. Restore was then proven, not assumed: git checkout HEAD -- ABSOLUTE_PATH, git diff HEAD empty, restored blob 35a8621d equal to the HEAD blob, probe residue 0 — and tsc back to exit 0 on the restored tree.

Out of scope, deliberately

  • ⛔ The object-field per-option default (enforce, objectstack#7246 / PR #7388) and the alias table (isDefault / selected) are untouched. This PR is about where a shape is declared, never about what a surface honours.
  • ⛔ The 8 remaining copies enumerated above are listed, not fixed — for the PM to file or fold.

Generated by Claude Code

…spec
`metadata-viewer.tsx` declared a module-local `interface SelectOption` — four
of the five keys `@objectstack/spec/data`'s `SelectOption` declares, with
`visibleWhen` dropped by silence: no `Omit` naming the narrowing, no comment
saying the drop was deliberate. It is the same defect class this repo has been
closing for several cards running (one contract, several hand-written
descriptions, each only ever reconciled against itself).
Derived now, as `type StateOption = Omit<SpecSelectOption, 'visibleWhen'>` —
the `Omit`-with-named-narrowings form `metadata-admin/form-spec.ts` already
uses for `FormFieldSpec.options`. The dropped key and the retained `default`
are each written out beside their reason.
Zero behaviour change: the type is module-local, the value it annotates arrives
from `useMetadataItem` as `any`, and `StateMachineView`'s `labelOf` / `colorOf`
/ `initial` logic is untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hfmP9hoMd3dJwTh85J4yB
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 45 chunks)3180.2 KB3222.7 KB
Main entry chunk (gzip)143.6 KB350 KB
Entry fileindex-BsvtHAQI.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 (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)12.46KB4.71KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
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)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
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.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
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)512.08KB116.42KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
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)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
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)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)247.26KB63.17KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.08KB54.61KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.15KB27.59KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.05KB8.37KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.79KB21.10KB
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)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
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)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.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 (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
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 (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
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-sam
os-sam added this pull request to the merge queueAug 31, 2026
Merged via the queue into main with commit 5c09ccaAug 31, 2026
32 checks passed
@os-sam
os-sam deleted the claude/issue-6887-derive-selectoption branch August 31, 2026 13:34
Sign up for freeto 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.

finding(components): metadata-viewer hand-writes a fifth copy of the select-option shape instead of deriving it

2 participants

@os-sam@claude