Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/7014-q1-tier1-select-option-convergence.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
33 changes: 16 additions & 17 deletions packages/types/src/field-types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 };
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n 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;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/7014-q1-tier1-select-option-convergence.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
33 changes: 16 additions & 17 deletions packages/types/src/field-types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 };
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/7014-q1-tier1-select-option-convergence.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
33 changes: 16 additions & 17 deletions packages/types/src/field-types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 };
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/7014-q1-tier1-select-option-convergence.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
33 changes: 16 additions & 17 deletions packages/types/src/field-types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 };
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/7014-q1-tier1-select-option-convergence.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
33 changes: 16 additions & 17 deletions packages/types/src/field-types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 };
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/7014-q1-tier1-select-option-convergence.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
33 changes: 16 additions & 17 deletions packages/types/src/field-types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 };
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/7014-q1-tier1-select-option-convergence.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
33 changes: 16 additions & 17 deletions packages/types/src/field-types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 };
}

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .changeset/7014-q1-tier1-select-option-convergence.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
@@ -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);
});
});
33 changes: 16 additions & 17 deletions packages/types/src/field-types.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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 };
}

/**
Expand Down
Loading
Loading