From 3c7aa670a5c8b1f181bc6e4631d86cb79fdee0a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 17:58:15 +0000 Subject: [PATCH] refactor(types): converge the two named select-option types on one spec-derived base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SelectOptionMetadata` (the object-metadata read model) and `SelectOption` (the SDUI form vocabulary) each restated the select-option vocabulary by hand. Both now extend the new `SelectOptionBase`, which derives the spec's keys from `@objectstack/spec/data` by reference and writes out only the divergences. The convergence is an EXTENSION, not a replacement: objectui legitimately carries `description` (LookupField searches it), `disabled` and `icon`, none of which the spec declares — its `SelectOptionSchema` is strict over exactly `{label, value, color, default, visibleWhen}` and refuses all three by name. Each is declared as objectui dialect with that refusal written into the published JSDoc rather than described as spec-aligned. Nothing narrowed. `SelectOption` resolves member-for-member to what it resolved to before; `SelectOptionMetadata` gains exactly one optional spec key, `default`, which that face could not describe before. Both are pinned invariantly against the pre-convergence member lists. Ref objectui#7014 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EMrWaQw3XS5DxTHxp4yRyC --- ...7014-q1-tier1-select-option-convergence.md | 39 +++ ...lect-option-tier1-convergence-7014.test.ts | 274 ++++++++++++++++++ packages/types/src/field-types.ts | 33 +-- packages/types/src/form.ts | 66 ++--- packages/types/src/index.ts | 6 + packages/types/src/select-option.ts | 104 +++++++ 6 files changed, 462 insertions(+), 60 deletions(-) create mode 100644 .changeset/7014-q1-tier1-select-option-convergence.md create mode 100644 packages/types/src/__tests__/select-option-tier1-convergence-7014.test.ts create mode 100644 packages/types/src/select-option.ts diff --git a/.changeset/7014-q1-tier1-select-option-convergence.md b/.changeset/7014-q1-tier1-select-option-convergence.md new file mode 100644 index 000000000..27c27512e --- /dev/null +++ b/.changeset/7014-q1-tier1-select-option-convergence.md @@ -0,0 +1,39 @@ +--- +'@object-ui/types': minor +--- + +Converge the two named select-option types onto one spec-derived base (objectui#7014, Q1). + +`SelectOptionMetadata` (`field-types`, the object-metadata read model) and `SelectOption` +(`form`, the SDUI form vocabulary) each restated the select-option vocabulary by hand. +Both now extend the new `SelectOptionBase`, which derives the spec's keys from +`@objectstack/spec/data` **by reference** and writes out only the divergences. A key the +spec adds now reaches both faces with no edit here; a key it removes becomes a compile +error at the sites that read it, instead of a hand copy that goes on compiling while the +contract moves underneath it. + +**What widened.** Exactly one key, on one face: `SelectOptionMetadata` gains +`default?: boolean`. It is a spec key that face could not describe before — ruled +`enforce` on the object-field face (objectstack#7246), where the engine seeds a new +record from the option marked `default: true` — and it arrives OPTIONAL, so every +document that face accepted before is still accepted. + +**What narrowed.** Nothing. Both faces resolve to member-for-member what they resolved to +before (`SelectOption` identically; `SelectOptionMetadata` identically plus `default`), +pinned invariantly against the pre-convergence member lists in +`select-option-tier1-convergence-7014.test.ts` so a future "unification" cannot quietly +drop a key. `SelectOption.value` keeps its deliberate widening past the spec's machine +identifier (numeric/boolean values for standalone forms, objectui#3090), now named in an +`Omit` instead of restated. + +**The convergence is an EXTENSION, not a replacement.** objectui legitimately carries +keys the spec does not: `description` (`LookupField` searches it, objectui#6153) on the +metadata face, and `disabled` / `icon` on both. The spec's `SelectOptionSchema` is strict +over exactly `{label, value, color, default, visibleWhen}` and refuses each of those +three **by name**, so they are declared as objectui dialect with that refusal written +into the published JSDoc rather than described as spec-aligned. These are read-model keys +and must never reach an authored object document — a field's `options` are routed through +the strict schema, so one of them fails the whole field. + +`SelectOptionBase` is exported from `@object-ui/types` because it appears in the +`extends` clause of both published interfaces. diff --git a/packages/types/src/__tests__/select-option-tier1-convergence-7014.test.ts b/packages/types/src/__tests__/select-option-tier1-convergence-7014.test.ts new file mode 100644 index 000000000..844e88387 --- /dev/null +++ b/packages/types/src/__tests__/select-option-tier1-convergence-7014.test.ts @@ -0,0 +1,274 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two named select-option faces are ONE declaration now, and converging + * them narrowed nothing (objectui#7014, Q1). + * + * ## What changed + * + * `SelectOptionMetadata` (`../field-types`, the object-metadata read model) and + * `SelectOption` (`../form`, the SDUI form vocabulary) each restated the + * select-option vocabulary by hand. Both now extend `SelectOptionBase` + * (`../select-option`), which derives the spec's keys from + * `@objectstack/spec/data` BY REFERENCE and writes out only the divergences. + * + * ## What this file pins, and why each half is needed + * + * A convergence has exactly two ways to go wrong, and they fail in opposite + * directions, so neither half can stand alone: + * + * 1. **It narrows something.** A "unification" quietly drops a key one face + * had, or tightens a member's type, and every literal that used the + * dropped key becomes an excess-property error somewhere else. The + * `PRE_CONVERGENCE_*` types below are the two faces' member lists AS THEY + * STOOD BEFORE, written out by hand from the declarations, and each is + * asserted INVARIANTLY equal to what the converged face resolves to today. + * `Equal`, not `extends`: a one-way check passes on a narrowing. + * + * 2. **It widens past the authoring contract without saying so.** The + * objectui faces are deliberately WIDER than the spec — that is the point + * of the dialect keys — but only the runtime READ model may be wider. + * The runtime block asserts the spec still refuses every dialect key BY + * NAME, each refusal behind a control that accepts the same document minus + * the key, so a red here reads "the key's status changed" and never "the + * fixture drifted". + * + * The one deliberate widening this convergence DOES make is `default` on the + * object-metadata face: it is a spec key that face could not describe before, + * ruled `enforce` on the object-field face (objectstack#7246), and it arrives + * as an OPTIONAL key, so every document that face accepted before it is still + * accepted. It is asserted below rather than left implicit. + * + * ## The control the spec fixtures need + * + * A select option's `value` is a lowercase machine identifier with a minimum + * length, so a one-character value fails `too_small` and NOT for the reason a + * key-boundary fixture is asking about. Every fixture here uses a value long + * enough that the only thing under test is the key set, and the accepting + * control proves it. + */ + +import { describe, it, expect } from 'vitest'; +import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; + +import type { SelectOptionBase } from '../select-option'; +import type { SelectOptionMetadata } from '../field-types'; +import type { SelectOption } from '../form'; + +/* ── Type-level helpers ──────────────────────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal< A, B > = + (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false; +type Expect< T extends true > = T; + +/** objectui's own expression wire shape (objectui#2212), as both faces declared it. */ +type ExpressionWire = string | { dialect?: string; source: string }; + +/* ── 1. No narrowing: the pre-convergence member lists, verbatim ─────────── */ + +/** + * `SelectOption` as `../form` declared it before the convergence — seven + * members, `value` widened for standalone forms. + */ +interface PRE_CONVERGENCE_SelectOption { + label: string; + value: string | number | boolean; + disabled?: boolean; + icon?: string; + color?: string; + default?: boolean; + visibleWhen?: ExpressionWire; +} + +/** + * `SelectOptionMetadata` as `../field-types` declared it before the + * convergence — seven members, `value` the spec's identifier, `description` + * the read-model extension, and NO `default` (the gap this convergence closes). + */ +interface PRE_CONVERGENCE_SelectOptionMetadata { + label: string; + value: string; + description?: string; + color?: string; + icon?: string; + disabled?: boolean; + visibleWhen?: ExpressionWire; +} + +/** The SDUI form face is member-for-member what it was. */ +export type assertionFormFaceUnchanged = + Expect< Equal< SelectOption, PRE_CONVERGENCE_SelectOption > >; + +/** + * The object-metadata face kept every member it had, at the same type — the + * no-narrowing half, stated by removing the one key that was added and + * comparing what is left. + */ +export type assertionMetadataFaceKeptEveryMember = + Expect< Equal< Omit< SelectOptionMetadata, 'default' >, PRE_CONVERGENCE_SelectOptionMetadata > >; + +/** + * …and `default` is the ONLY key it gained. Written as a set difference so a + * second key cannot ride along silently: add one and this stops being `'default'`. + */ +export type assertionMetadataFaceGainedOnlyDefault = + Expect< Equal< Exclude< keyof SelectOptionMetadata, keyof PRE_CONVERGENCE_SelectOptionMetadata >, 'default' > >; + +/** …and `default` really is optional, so no document that parsed before now fails. */ +export type assertionDefaultIsOptional = + Expect< Equal< SelectOptionMetadata['default'], boolean | undefined > >; + +/** The two faces differ on exactly one inherited member, and it is `value`. */ +export type assertionOnlyValueDiffers = + Expect< Equal< Omit< SelectOption, 'value' >, Omit< SelectOptionMetadata, 'value' | 'description' > > >; +export type assertionFormValueIsWide = + Expect< Equal< SelectOption['value'], string | number | boolean > >; +export type assertionMetadataValueIsSpecIdentifier = + Expect< Equal< SelectOptionMetadata['value'], string > >; + +/* ── 2. The base carries every spec key, checked on BOTH sides ───────────── */ + +/** + * The spec's key names, each annotated as a member of the derived base. The + * ANNOTATION is the type-side half — drop a key from the base and this stops + * compiling — and the runtime assertion below is the other half: add a key to + * the spec and the lists stop agreeing. Neither half alone is a pin. + */ +const SPEC_KEYS_ON_BASE: readonly (keyof SelectOptionBase)[] = [ + 'color', + 'default', + 'label', + 'value', + 'visibleWhen', +]; + +/** The objectui-only keys, annotated the same way. */ +const DIALECT_KEYS_ON_BASE: readonly (keyof SelectOptionBase)[] = ['disabled', 'icon']; + +/* ── 3. Neither face admits a key nobody declares ────────────────────────── */ + +export const rejectsUndeclaredKeyOnFormFace = (): SelectOption => ({ + label: 'High priority', + value: 'high_priority', + // @ts-expect-error `weight` is declared by neither the spec nor this repo. + weight: 3, +}); + +export const rejectsUndeclaredKeyOnMetadataFace = (): SelectOptionMetadata => ({ + label: 'High priority', + value: 'high_priority', + // @ts-expect-error `weight` is declared by neither the spec nor this repo. + weight: 3, +}); + +/** + * The control for the two refusals above: the SAME literal carrying every key + * the converged faces DO declare compiles with no error. Without it, a face + * that had gone `never` would pass both `@ts-expect-error` cases vacuously. + */ +export const acceptsEveryDeclaredKey = (): SelectOptionMetadata => ({ + label: 'High priority', + value: 'high_priority', + color: '#ef4444', + default: true, + visibleWhen: { dialect: 'cel', source: "record.stage == 'open'" }, + description: 'Blocks the release', + disabled: false, + icon: 'flame', +}); + +export const acceptsEveryDeclaredKeyOnFormFace = (): SelectOption => ({ + label: 'Ten', + value: 10, + color: '#ef4444', + default: false, + visibleWhen: "'admin' in current_user.positions", + disabled: true, + icon: 'hash', +}); + +/* ── Runtime ─────────────────────────────────────────────────────────────── */ + +/** A valid spec option: `value` is a machine identifier well over the minimum. */ +const CONTROL = { label: 'High priority', value: 'high_priority' } as const; + +/** The `unrecognized_keys` issue naming `key`, or undefined. */ +const refusedByName = ( + result: { success: boolean; error?: { issues: readonly { code: string; keys?: readonly string[] }[] } }, + key: string, +) => + result.success + ? undefined + : result.error!.issues.find((i) => i.code === 'unrecognized_keys' && (i.keys ?? []).includes(key)); + +describe('the derived base carries the spec vocabulary', () => { + it('lists exactly the keys the spec declares', () => { + expect([...SPEC_KEYS_ON_BASE].sort()).toEqual(Object.keys(SpecSelectOptionSchema.shape).sort()); + }); + + it('CONTROL: a clean option is accepted, and for the stated reason', () => { + const res = SpecSelectOptionSchema.safeParse(CONTROL); + expect(res.success).toBe(true); + // …and the reason really is "no extra key, valid value": a one-character + // value fails for a DIFFERENT reason, which is what makes the ACCEPT above + // a reading rather than luck. + const short = SpecSelectOptionSchema.safeParse({ label: 'H', value: 'h' }); + expect(short.success).toBe(false); + expect(short.success ? [] : short.error.issues.map((i) => i.code)).toContain('too_small'); + }); + + it('accepts an option carrying every spec key at once', () => { + const res = SpecSelectOptionSchema.safeParse({ + ...CONTROL, + color: '#ef4444', + default: true, + visibleWhen: "record.stage == 'open'", + }); + expect(res.success).toBe(true); + }); +}); + +describe('the objectui dialect keys sit OUTSIDE that vocabulary', () => { + it('names the dialect keys the base adds', () => { + expect([...DIALECT_KEYS_ON_BASE].sort()).toEqual(['disabled', 'icon']); + // Neither is a spec key — the two lists are disjoint. + const specKeys = Object.keys(SpecSelectOptionSchema.shape); + expect(DIALECT_KEYS_ON_BASE.filter((k) => specKeys.includes(k as string))).toEqual([]); + }); + + for (const [key, value] of [ + ['disabled', true], + ['icon', 'flame'], + ['description', 'Blocks the release'], + ] as const) { + it(`the spec refuses \`${key}\` BY NAME, with the same option minus the key accepted`, () => { + const res = SpecSelectOptionSchema.safeParse({ ...CONTROL, [key]: value }); + expect(res.success).toBe(false); + expect(refusedByName(res, key), `expected unrecognized_keys naming '${key}'`).toBeDefined(); + // Control, per fixture: the key is the only difference. + expect(SpecSelectOptionSchema.safeParse(CONTROL).success).toBe(true); + }); + } + + it('a fully-populated read-model option is refused as a whole, naming all three', () => { + const readModel: SelectOptionMetadata = { + label: 'High priority', + value: 'high_priority', + color: '#ef4444', + default: true, + description: 'Blocks the release', + disabled: false, + icon: 'flame', + }; + const res = SpecSelectOptionSchema.safeParse(readModel); + expect(res.success).toBe(false); + for (const key of ['description', 'disabled', 'icon']) { + expect(refusedByName(res, key), `expected unrecognized_keys naming '${key}'`).toBeDefined(); + } + + // Control: the same document with only the spec keys parses. + const { description: _d, disabled: _di, icon: _i, ...specOnly } = readModel; + expect(SpecSelectOptionSchema.safeParse(specOnly).success).toBe(true); + }); +}); diff --git a/packages/types/src/field-types.ts b/packages/types/src/field-types.ts index a7f243621..564bc1d6b 100644 --- a/packages/types/src/field-types.ts +++ b/packages/types/src/field-types.ts @@ -311,12 +311,24 @@ export interface TimeFieldMetadata extends BaseFieldMetadata { format?: string; } +import type { SelectOptionBase } from './select-option.js'; + /** - * Select option + * Select option — the OBJECT-METADATA face of the one select-option contract + * (objectui#7014). It extends {@link SelectOptionBase}, which derives the spec + * keys (`label`, `value`, `color`, `default`) from `@objectstack/spec/data` by + * reference and carries objectui's `visibleWhen` wire shape plus the two + * objectui-only keys `disabled` and `icon`. This face restates none of them; it + * adds exactly the one key below and keeps the spec's `value` — a lowercase + * machine identifier — as declared. + * + * This is the declared element type of a select field's and a lookup field's + * `options`, so it is the runtime READ model the renderers consume. It is WIDER + * than what may be authored: `description`, `disabled` and `icon` are each + * refused BY NAME by the spec's strict `SelectOptionSchema`, which a field's + * `options` are routed through. */ -export interface SelectOptionMetadata { - label: string; - value: string; +export interface SelectOptionMetadata extends SelectOptionBase { /** * Optional secondary/help text for the option (objectui#6153, inheriting the * objectui#6140 ruling frame — a key that is genuinely consumed gets @@ -337,19 +349,6 @@ export interface SelectOptionMetadata { * `__tests__/select-option-spec-extension-7014.test.ts` (objectui#7014). */ description?: string; - color?: string; - icon?: string; - disabled?: boolean; - /** - * Per-option visibility predicate (CEL). The option is offered only when TRUE, - * evaluated against the live record + `current_user` (same env as field-level - * `visibleWhen`). Omit = always available. Drives cascading/dependent options - * (`record.country == 'cn'`) and role/context gating - * (`'admin' in current_user.positions`). Aligns with @objectstack/spec - * SelectOption.visibleWhen. Client-side hiding is UX only — access-control - * gating must also be enforced server-side. - */ - visibleWhen?: string | { dialect?: string; source: string }; } /** diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 746ae4d66..1dc1ec4d1 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -16,6 +16,7 @@ */ import type { BaseSchema, SchemaNode } from './base.js'; +import type { SelectOptionBase } from './select-option.js'; /** * Button component @@ -269,55 +270,34 @@ export interface SelectSchema extends BaseSchema { } /** - * Select option + * Select option — the SDUI FORM face of the one select-option contract + * (objectui#7014). It extends {@link SelectOptionBase}, which derives the spec + * keys (`label`, `color`, `default`) from `@objectstack/spec/data` by reference + * and carries objectui's `visibleWhen` wire shape plus the two objectui-only + * keys `disabled` and `icon`. This face restates none of them; the one key it + * changes is named in the `Omit` below, which is where a divergence has to be + * written for it to stay visible. + * + * Its zod twin, `SelectOptionSchema` in `./zod/form.zod`, is the same + * derivation on the runtime side and diverges on the same key. */ -export interface SelectOption { - /** - * Option label (displayed to user) - */ - label: string; +export interface SelectOption extends Omit { /** * Option value (submitted in form). * - * Widened beyond `string` (#3090) to match what `SelectOptionSchema` has - * always accepted: standalone UI forms legitimately bind numeric/boolean - * values. The renderers stringify for the string-speaking controls and map - * the selection back to the authored value (`matchOptionValue`), so the - * authored type survives the round trip instead of morphing to `"2"`. - */ - value: string | number | boolean; - /** - * Whether option is disabled - */ - disabled?: boolean; - /** - * Option icon - */ - icon?: string; - /** - * Color code for badges/charts. Aligns with @objectstack/spec - * SelectOption.color — `@object-ui/fields` already resolves it for the - * select badge/dot rendering; only this contract lagged (objectstack#4115). - */ - color?: string; - /** - * Whether this is the default option. Aligns with @objectstack/spec - * SelectOption.default. - */ - default?: boolean; - /** - * Per-option visibility predicate (CEL). The option is offered only when this - * evaluates TRUE against the live record + `current_user` (same engine/env as - * field-level {@link FormFieldConfig.visibleWhen}). Omit = always available. - * Expresses both cascading options (`record.country == 'cn'`) and role/context - * gating (`'admin' in current_user.positions`). Aligns with @objectstack/spec - * SelectOption.visibleWhen. + * Widened beyond the spec's lowercase machine identifier (#3090): standalone + * UI forms legitimately bind numeric/boolean values, and this package's zod + * twin has always accepted them. The renderers stringify for the + * string-speaking controls and map the selection back to the authored value + * (`matchOptionValue`), so the authored type survives the round trip instead + * of morphing to `"2"`. * - * Client-side hiding is UX, not authorization — access-control gating must also - * be enforced server-side (the rule-validator rejects writes of a value whose - * predicate is false). + * This is the ONE key on which the SDUI form face is wider than the + * object-metadata face (`SelectOptionMetadata` in `./field-types`), which + * keeps the spec's `string`. Authored OBJECT metadata is validated against + * the spec, so a numeric option value belongs to standalone forms only. */ - visibleWhen?: string | { dialect?: string; source: string }; + value: string | number | boolean; } /** diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d089ea523..5e40e3975 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -178,6 +178,12 @@ export type { FormComponentSchema, } from './form.js'; +// The one select-option contract both option faces extend — `SelectOption` +// above (the SDUI form face) and `SelectOptionMetadata` below (the +// object-metadata face). Exported because it appears in the `extends` clause of +// both, so a consumer that wants to name it can (objectui#7014). +export type { SelectOptionBase } from './select-option.js'; + // ============================================================================ // Data Display Components - Information Presentation // ============================================================================ diff --git a/packages/types/src/select-option.ts b/packages/types/src/select-option.ts new file mode 100644 index 000000000..b6f7d14f3 --- /dev/null +++ b/packages/types/src/select-option.ts @@ -0,0 +1,104 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * @object-ui/types - The select-option contract, declared once + * + * A select option is ONE concept that this package used to describe twice by + * hand: once as the object-metadata read model (`SelectOptionMetadata` in + * `./field-types`) and once as the SDUI form vocabulary (`SelectOption` in + * `./form`). Both now extend the derivation below, so the vocabulary is stated + * in a single place and each face writes down only what it changes. + * + * @module select-option + * @packageDocumentation + */ + +import type { SelectOption as SpecSelectOption } from '@objectstack/spec/data'; + +/** + * The objectui select-option contract, DERIVED from the spec's own + * `SelectOption` (`@objectstack/spec/data`), with every divergence written out + * (objectui#7014). + * + * The spec's keys arrive BY REFERENCE through the `Omit`, so a key the spec + * adds appears on both objectui faces with no edit here, and a key it removes + * becomes a compile error at the sites that read it. That is the whole point of + * deriving rather than restating: a hand copy goes on compiling while the + * contract moves underneath it, and answers an older question in silence. + * + * WHAT COMES FROM THE SPEC, unchanged: + * + * - `label` — the human-readable display label. REQUIRED, because the spec + * makes it required. An empty string is a valid label; an ABSENT one is + * not. + * - `value` — the stored value, a lowercase machine identifier. The + * object-metadata face keeps this as declared; the SDUI form face widens it + * for standalone forms and names that widening in its own `Omit`. + * - `color` — colour code for badges/charts. + * - `default` — marks the option the platform seeds a new record with. + * + * WHAT DIVERGES, each named at its own member below: `visibleWhen` (dropped + * from the spec type in the `Omit` and re-declared on objectui's wire shape), + * plus the two objectui-only keys `disabled` and `icon`. + * + * WARNING - this type is WIDER than the authoring contract, and it is not a + * description of what an authored object document may carry. It is the runtime + * READ MODEL the renderers consume. The spec's `SelectOptionSchema` is a strict + * object over exactly `label`, `value`, `color`, `default` and `visibleWhen`, + * and it refuses `disabled` and `icon` BY NAME (`unrecognized_keys`); a field's + * `options` are routed through that schema, so either key written into authored + * object metadata fails the WHOLE field at publish time. Pinned, each refusal + * behind an accepting control, by + * `__tests__/select-option-spec-extension-7014.test.ts` and + * `__tests__/select-option-tier1-convergence-7014.test.ts`. + */ +export interface SelectOptionBase extends Omit { + /** + * Per-option visibility predicate (CEL). The option is offered only when this + * evaluates TRUE against the live record plus `current_user`; omit it and the + * option is always available. Expresses cascading/dependent options + * (`record.country == 'cn'`) and role/context gating + * (`'admin' in current_user.positions`). + * + * This is the spec's key carried on objectui's own WIRE shape (objectui#2212) + * — a bare string, or the object form `{ dialect?, source }` with `source` + * required. The spec's declared input is an expression pipe that + * canonicalizes a bare string into an envelope at parse time and makes + * `dialect` the required member; adopting it here would change what this + * package's zod twin emits, so the key is dropped from the derivation above + * and re-declared with this shape instead. The two are therefore NOT + * interchangeable in either direction, which is why the divergence is written + * down rather than inherited. + * + * Client-side hiding is UX, not authorization — an option gated for + * access-control reasons must also be rejected server-side. + */ + visibleWhen?: string | { dialect?: string; source: string }; + /** + * Whether the option is rendered but not selectable. + * + * WARNING - objectui-only extension, NOT a spec key. The spec's + * `SelectOptionSchema` refuses `disabled` BY NAME (`unrecognized_keys`) and + * deliberately has no per-option enabled/disabled flag: on the OBJECT-field + * face an option is offered or withheld (`visibleWhen`), never shown-but- + * unselectable, and the whole picker is frozen with `readonly` on the field. + * This key belongs to objectui's own component vocabulary and must never + * reach an authored object document. + */ + disabled?: boolean; + /** + * Icon name rendered beside the option label. + * + * WARNING - objectui-only extension, NOT a spec key. The spec's + * `SelectOptionSchema` refuses `icon` BY NAME (`unrecognized_keys`). It lives + * on the runtime read model the renderers consume and must never reach an + * authored object document. + */ + icon?: string; +}