From a8446a2ae917d8a105049797009c7220bcf77234 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:38:30 +0000 Subject: [PATCH 1/4] feat(types)!: point DetailViewFieldSchema.options at the spec's authoring SelectOptionSchema Ruling F1 on objectui#7759: a structural mirror points at the spec's authoring schema, never at the declaration's runtime read model (SelectOptionMetadata). The inline option shape that admitted a number/boolean value is gone; the remaining visibleWhen envelope difference is recorded in the parity ledgers as an expected divergence, with its one-level-up propagation to DetailViewSchema fields/sections and DetailViewSectionSchema fields. --- ...296-detail-view-field-options-authoring.md | 27 +++++++ .../detail-view-field-options-10296.test.ts | 76 +++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 48 +++++++++++- packages/types/src/zod/views.zod.ts | 23 ++++-- 4 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 .changeset/10296-detail-view-field-options-authoring.md create mode 100644 packages/types/src/__tests__/detail-view-field-options-10296.test.ts diff --git a/.changeset/10296-detail-view-field-options-authoring.md b/.changeset/10296-detail-view-field-options-authoring.md new file mode 100644 index 0000000000..ca03e791db --- /dev/null +++ b/.changeset/10296-detail-view-field-options-authoring.md @@ -0,0 +1,27 @@ +--- +'@object-ui/types': minor +--- + +feat(types)!: `DetailViewFieldSchema.options` is the spec's authoring `SelectOptionSchema` (objectui#10296) + +The zod mirror of a detail-view field's `options` carried an inline option shape +that admitted a number or boolean `value` — which the TypeScript declaration +(`SelectOptionMetadata[]`) refuses — and silently stripped every other key. It +now reads `SelectOptionSchema` from `@objectstack/spec/data` by reference, per +ruling F1 on objectui#7759: a structural mirror points at the spec's AUTHORING +schema, never at the declaration's runtime read model. + +BREAKING for authored detail-view metadata that the old shape let through: + +- `value` must be the spec's machine identifier: a lowercase string of at least + two characters (for example `in_progress`). A number or boolean `value` is + refused with `invalid_type`; write it as an identifier string. +- `icon` and `disabled` on an option are refused by name (`unrecognized_keys`), + as the spec refuses them on object metadata. They were stripped silently + before, so no renderer ever received them from this surface. +- `description`, `default` and `visibleWhen` are now accepted as the spec + declares them, where before they were stripped. + +The TypeScript declaration is unchanged. The one remaining difference between the +two faces, the `visibleWhen` envelope, is recorded in the parity ledger as an +expected divergence against the read model. diff --git a/packages/types/src/__tests__/detail-view-field-options-10296.test.ts b/packages/types/src/__tests__/detail-view-field-options-10296.test.ts new file mode 100644 index 0000000000..ddecca1f67 --- /dev/null +++ b/packages/types/src/__tests__/detail-view-field-options-10296.test.ts @@ -0,0 +1,76 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `DetailViewFieldSchema.options` is the spec's AUTHORING option schema, by + * reference (objectui#10296, ruling F1 on objectui#7759). + * + * Before this card the mirror carried an inline option shape that admitted a + * number or boolean `value` the declaration (`SelectOptionMetadata[]`) refuses, + * and silently STRIPPED every other key. F1 rules that a structural mirror points + * at the spec's authoring schema, ⛔ never at the declaration: the declaration here + * is the runtime READ model, which admits `disabled` and `icon` — two keys the + * spec refuses by name. The remaining difference between the two faces + * (`visibleWhen`'s envelope) is recorded as an EXPECTED DIVERGENCE in the parity + * ledgers, not repaired. + * + * Every refusal below is asserted on the issue `code` and the `path` it lands on, + * each beside an accepting control, so a schema that refuses everything cannot + * pass. On a revert to the inline shape the numeric/boolean `value` and the + * by-name refusals turn green-to-red. + */ + +import { describe, it, expect } from 'vitest'; +import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; +import { DetailViewFieldSchema } from '../zod/views.zod.js'; + +const field = (option: Record) => + DetailViewFieldSchema.safeParse({ name: 'status', type: 'select', options: [option] }); + +const issuesOf = (r: ReturnType) => + r.success ? [] : r.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); + +describe('DetailViewFieldSchema.options is the spec authoring option schema (objectui#10296)', () => { + it('accepts an authored option carrying every spec key (accepting control)', () => { + const r = field({ + label: 'Open', + value: 'open', + description: 'Not started yet', + color: '#22c55e', + default: true, + visibleWhen: "record.kind == 'task'", + }); + expect(issuesOf(r)).toEqual([]); + }); + + it.each([ + ['a number', 1], + ['a boolean', true], + ])('refuses %s as `value` — the spec value is a machine identifier string', (_label, value) => { + const r = field({ label: 'One', value }); + expect(r.success).toBe(false); + expect(issuesOf(r)).toContainEqual({ code: 'invalid_type', path: 'options.0.value' }); + }); + + it.each(['icon', 'disabled'])('refuses the read-model-only key `%s` BY NAME', (key) => { + const r = field({ label: 'Open', value: 'open', [key]: key === 'icon' ? 'circle' : true }); + expect(r.success).toBe(false); + const issue = r.success ? undefined : r.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue?.path.join('.')).toBe('options.0'); + expect((issue as { keys?: string[] } | undefined)?.keys).toEqual([key]); + }); + + it('gives the same verdict as the spec schema itself, over a spread of options', () => { + const samples: Record[] = [ + { label: 'Open', value: 'open' }, + { label: 'Open', value: 'Open' }, + { label: 'Open', value: 'o' }, + { label: 'Open', value: 'in_progress', color: 'blue' }, + { value: 'open' }, + { label: 'Open', value: 'open', stray: 1 }, + ]; + for (const option of samples) { + expect(field(option).success, JSON.stringify(option)) + .toBe(SpecSelectOptionSchema.safeParse(option).success); + } + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 11e4c55a5b..31e1e7cc3a 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -154,7 +154,15 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **46 entries** in `KnownDrift`, **85 keys** across them — 46 / 83 until + * - **48 entries** in `KnownDrift`, **89 keys** across them — 46 / 85 until + * objectui#10296 pointed `views.zod.ts#DetailViewFieldSchema`'s `options` at the + * spec's AUTHORING `SelectOptionSchema` (ruling F1 on objectui#7759): that pair + * and `views.zod.ts#DetailViewSectionSchema` are NEW entries with one key each, + * plus `fields` and `sections` on the EXISTING `views.zod.ts#DetailViewSchema` + * entry — the two containers carry those field elements. One + * EXPECTED DIVERGENCE against the runtime read model the declaration names, not + * a defect — the two entries' docblocks carry the reading. + * It was 46 / 83 until * objectui#9447 DECLARED `onNavigate` and `onAddComment` on * `views.zod.ts#DetailViewSchema`, an EXISTING entry (it already held `onBack`), * so the key total moved by two and the entry count did not. ⭐ A pure TRANSFER, @@ -568,7 +576,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 46 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 48 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is * pinned to its EXACT drifted key set, so the entry fails when new drift appears on * that mirror AND when the recorded drift is fixed — a stale entry cannot rot * quietly. Correcting them is not one change: the pairs below split into DISJOINT @@ -2206,8 +2214,31 @@ interface KnownDrift { * `DetailView`'s `schema.onNavigate` / `schema.onAddComment` by identity — * exactly as it does under `'detail'`, whose twin arm refused both by name * with objectui#7804. The TS face keeps the callable on all three. + * + * `fields` and `sections` JOINED with objectui#10296: they carry + * `DetailViewFieldSchema` elements, so that pair's EXPECTED DIVERGENCE on + * `options` (the entry below) is measured here one level up. Same cause, + * not a separate finding. + */ + 'views.zod.ts#DetailViewSchema': 'onBack' | 'onNavigate' | 'onAddComment' | 'fields' | 'sections'; + /** + * EXPECTED DIVERGENCE, read model (objectui#10296, ruling F1 on objectui#7759). + * The mirror is the spec's AUTHORING `SelectOptionSchema` by reference; the + * declaration is `SelectOptionMetadata[]`, the runtime READ model. The one member + * the two faces spell differently is `visibleWhen`: the spec's input envelope + * requires `dialect`, the read model carries objectui's wire (objectui#2212), + * where `dialect` is optional and `source` required. ⛔ Not repaired by pointing + * the mirror at the declaration (it would admit `disabled` / `icon`, which the + * spec refuses by name). Also in `WiderThanDeclared`, for the other half. */ - 'views.zod.ts#DetailViewSchema': 'onBack' | 'onNavigate' | 'onAddComment'; + 'views.zod.ts#DetailViewFieldSchema': 'options'; + /** + * objectui#10296: `fields` carries `DetailViewFieldSchema` elements, so the + * EXPECTED DIVERGENCE on that pair's `options` (the entry above) is measured + * here one level up — the same reading as `fields` / `sections` on + * `views.zod.ts#DetailViewSchema`. Same cause, not a separate finding. + */ + 'views.zod.ts#DetailViewSectionSchema': 'fields'; } /* ── The measured unmirrored-declared ledger (objectui#6058) ────────────────── */ @@ -3132,7 +3163,13 @@ interface WiderThanDeclared { * sat inside the region that card could not look at. */ 'overlay.zod.ts#TooltipSchema': 'content'; - /** CONCRETE: an inline option shape against the named `SelectOptionMetadata`. */ + /** + * EXPECTED DIVERGENCE, read model (objectui#10296, ruling F1): the mirror is the + * spec's authoring `SelectOptionSchema`, whose `visibleWhen` envelope admits a + * `dialect`-only object the read model's wire does not. See the `KnownDrift` + * entry for this pair. (The inline option shape that stood here, admitting a + * number or boolean `value`, is gone.) + */ 'views.zod.ts#DetailViewFieldSchema': 'options'; /** SCHEMA-NODE. (`tabs` left under objectui#7760; `fields` and `sections` did not.) */ 'views.zod.ts#DetailViewSchema': 'fields' | 'sections'; @@ -4074,6 +4111,9 @@ const SPEC_DERIVED_PAIRS: readonly string[] = [ // Membership here is what re-derives the pair's one remaining // `UnmirroredDeclared` key (`listViews`) into the split's SPEC-DERIVED half. 'objectql.zod.ts#ObjectViewSchema', + // objectui#10296: `options` is the spec's authoring `SelectOptionSchema` by + // reference (ruling F1 on objectui#7759). + 'views.zod.ts#DetailViewFieldSchema', ]; /* ── Runtime: the population is closed ──────────────────────────────────────── */ diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index 635b449fa9..5caaadb66a 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -20,13 +20,15 @@ import { z } from 'zod'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; +import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; +import { stripImportedDefaults } from './imported-defaults.js'; /** * The spec's own list-view type vocabulary, unwrapped from its `.default('grid')`. * - * ⭐ NOT a crossing of the objectui#8317 import boundary, and this file has no - * other read of `@objectstack/spec`, which is why it imports no - * `stripImportedDefaults`. A vocabulary is a set of VALUES, not a subschema: + * ⭐ NOT a crossing of the objectui#8317 import boundary. (This file's one + * crossing is `DetailViewFieldSchema.options` below, wrapped in + * `stripImportedDefaults` at the site.) A vocabulary is a set of VALUES, not a subschema: * `.removeDefault()` here reaches the spec's enum and nothing that could write a * key into a parsed document ever flows from it. The declared-exception list in * `../__tests__/imported-defaults-8317.test.ts` names this site and its twin. @@ -75,11 +77,16 @@ export const DetailViewFieldSchema = z.object({ readonly: z.boolean().optional().describe('Whether field is read-only'), visible: z.union([z.boolean(), z.string()]).optional().describe('Field visibility condition'), span: z.number().optional().describe('Span across columns (for grid layout)'), - options: z.array(z.object({ - label: z.string(), - value: z.union([z.string(), z.number(), z.boolean()]), - color: z.string().optional(), - })).optional().describe('Options for select/lookup fields'), + /** + * The spec's AUTHORING option schema, by reference (objectui#10296, ruling F1 + * on objectui#7759) — ⛔ never the declaration's element type + * `SelectOptionMetadata`, which is the runtime READ model and admits keys the + * spec refuses by name (`disabled`, `icon`). The two faces are therefore + * EXPECTED to differ on this key: the ledger records that divergence rather + * than repairing it. Re-derived by `__tests__/detail-view-field-options-10296.test.ts`. + */ + options: z.array(stripImportedDefaults(SpecSelectOptionSchema)).optional() + .describe('Options for select/lookup fields'), reference_to: z.string().optional().describe('Referenced object name for lookup/master_detail fields'), reference_field: z.string().optional().describe('Display field on the referenced object'), currency: z.string().optional().describe('Currency code for currency fields (e.g. USD, EUR)'), From 3925bddacd806aa10cc16b58df47d721c76795ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:03:19 +0000 Subject: [PATCH 2/4] test(types): pin DetailViewFieldSchema.options visibleWhen output shape and value rule Contract-review follow-up on PR #10307: the spec's expression-input pipe survives stripImportedDefaults, so a bare-string visibleWhen parses to a { dialect: 'cel', source } envelope. Pin that output shape, the refusal of a dialect-less envelope and of an empty string, and the whitespace-only acceptance on spec 17.4.0 that the next spec bump tightens. The changeset now states the SystemIdentifierSchema value rule exactly. --- ...296-detail-view-field-options-authoring.md | 16 ++++- .../detail-view-field-options-10296.test.ts | 72 +++++++++++++++++++ 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/.changeset/10296-detail-view-field-options-authoring.md b/.changeset/10296-detail-view-field-options-authoring.md index ca03e791db..f069dea3e6 100644 --- a/.changeset/10296-detail-view-field-options-authoring.md +++ b/.changeset/10296-detail-view-field-options-authoring.md @@ -13,14 +13,24 @@ schema, never at the declaration's runtime read model. BREAKING for authored detail-view metadata that the old shape let through: -- `value` must be the spec's machine identifier: a lowercase string of at least - two characters (for example `in_progress`). A number or boolean `value` is - refused with `invalid_type`; write it as an identifier string. +- `value` must be the spec's `SystemIdentifierSchema`: a string matching + `^[a-z][a-z0-9_.]*$`, at least two characters long. So `open`, `in_progress` + and `order.created` are accepted, while `Open`, `1st`, `a-b` and `o` are + refused. A number or boolean `value` is refused with `invalid_type`; write it + as an identifier string. - `icon` and `disabled` on an option are refused by name (`unrecognized_keys`), as the spec refuses them on object metadata. They were stripped silently before, so no renderer ever received them from this surface. - `description`, `default` and `visibleWhen` are now accepted as the spec declares them, where before they were stripped. +- A bare-string `visibleWhen` is canonicalized in the PARSE OUTPUT to the + spec's envelope `{ dialect: 'cel', source }`. An authored envelope passes + through unchanged. (objectui's own form select option keeps the bare string; + this surface follows the spec.) +- A `visibleWhen` envelope without `dialect` (objectui's wire spelling + `{ source }`) is refused, as is an empty string. On the installed spec + 17.4.0 a whitespace-only string is still accepted; the next spec release + refuses it. The TypeScript declaration is unchanged. The one remaining difference between the two faces, the `visibleWhen` envelope, is recorded in the parity ledger as an diff --git a/packages/types/src/__tests__/detail-view-field-options-10296.test.ts b/packages/types/src/__tests__/detail-view-field-options-10296.test.ts index ddecca1f67..25085eba6d 100644 --- a/packages/types/src/__tests__/detail-view-field-options-10296.test.ts +++ b/packages/types/src/__tests__/detail-view-field-options-10296.test.ts @@ -59,6 +59,78 @@ describe('DetailViewFieldSchema.options is the spec authoring option schema (obj expect((issue as { keys?: string[] } | undefined)?.keys).toEqual([key]); }); + it.each(['Open', '1st', 'a-b', 'o'])( + 'refuses `value: %s` — the spec value is `^[a-z][a-z0-9_.]*$`, at least 2 characters', + (value) => { + const r = field({ label: 'Open', value }); + expect(r.success).toBe(false); + expect(issuesOf(r).map((i) => i.path)).toContain('options.0.value'); + }, + ); + + it.each(['ab', 'a.b', 'in_progress'])('accepts `value: %s` (accepting control for the rule above)', (value) => { + expect(issuesOf(field({ label: 'Open', value }))).toEqual([]); + }); + + /** + * OUTPUT SHAPE, declared rather than left implicit. `stripImportedDefaults` + * removes defaults only, so the spec's expression-input PIPE survives on + * `options[].visibleWhen`: the parsed output is the canonical envelope + * whichever spelling was authored. One authored document therefore has two + * input spellings and ONE output shape here. That differs from objectui's + * own form `SelectOptionSchema`, which keeps objectui's wire and passes a + * bare string through unchanged (objectui#7735 / #8317). The contract review + * on objectui#10296 kept the spec's pipe (option A); these pins keep it + * visible. + */ + const optionOut = (visibleWhen: unknown) => { + const r = field({ label: 'Open', value: 'open', visibleWhen }); + return r.success ? r.data.options?.[0] : r; + }; + + it('canonicalizes a bare-string `visibleWhen` to a `{ dialect: "cel", source }` envelope in OUTPUT', () => { + expect(optionOut("record.kind == 'task'")).toEqual({ + label: 'Open', + value: 'open', + visibleWhen: { dialect: 'cel', source: "record.kind == 'task'" }, + }); + }); + + it('passes an authored `{ dialect, source }` envelope through as the same envelope', () => { + expect(optionOut({ dialect: 'cel', source: 'x == 1' })).toEqual({ + label: 'Open', + value: 'open', + visibleWhen: { dialect: 'cel', source: 'x == 1' }, + }); + }); + + it('refuses an envelope without `dialect` — objectui wire spelling, not the spec envelope', () => { + const r = field({ label: 'Open', value: 'open', visibleWhen: { source: 'x == 1' } }); + expect(r.success).toBe(false); + expect(issuesOf(r)).toContainEqual({ code: 'invalid_union', path: 'options.0.visibleWhen' }); + }); + + it('refuses an empty-string `visibleWhen`', () => { + const r = field({ label: 'Open', value: 'open', visibleWhen: '' }); + expect(r.success).toBe(false); + expect(issuesOf(r).map((i) => i.path)).toContain('options.0.visibleWhen'); + }); + + /** + * ⚠️ Pinned as it stands on the INSTALLED `@objectstack/spec` 17.4.0: a + * whitespace-only string is ACCEPTED and canonicalized. objectstack `main` + * already moved `visibleWhen` to `EvaluatedExpressionInputSchema`, whose + * string arm refuses blank strings, so the next spec bump TIGHTENS this and + * turns this test red on purpose. When it does, flip it to a refusal. + */ + it('accepts a whitespace-only `visibleWhen` on spec 17.4.0 (the next spec bump refuses it)', () => { + expect(optionOut(' ')).toEqual({ + label: 'Open', + value: 'open', + visibleWhen: { dialect: 'cel', source: ' ' }, + }); + }); + it('gives the same verdict as the spec schema itself, over a spread of options', () => { const samples: Record[] = [ { label: 'Open', value: 'open' }, From da3afd5325c8fd0f0e66b4d5e9c0e6c66b9d65b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:29:45 +0000 Subject: [PATCH 3/4] test(types): gate the DetailViewField options WIDER rows on the installed spec's envelope The Spec Main Shape Gate compiles the parity ledger against objectstack main, where SelectOptionSchema.visibleWhen requires a non-blank source. There the options row and its two container rows (DetailViewSchema fields/sections, DetailViewSectionSchema fields) no longer measure WIDER, while on the pinned 17.4.0 they do. The ledger entries stay literal; the reconciliation withholds exactly those rows when the mirror's own input face no longer admits a source-less envelope. A runtime tripwire in the pin file goes red at the pin bump to force their removal. --- .../detail-view-field-options-10296.test.ts | 18 ++++++ .../src/__tests__/zod-mirror-parity.test.ts | 63 ++++++++++++++++--- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/packages/types/src/__tests__/detail-view-field-options-10296.test.ts b/packages/types/src/__tests__/detail-view-field-options-10296.test.ts index 25085eba6d..43060e3945 100644 --- a/packages/types/src/__tests__/detail-view-field-options-10296.test.ts +++ b/packages/types/src/__tests__/detail-view-field-options-10296.test.ts @@ -131,6 +131,24 @@ describe('DetailViewFieldSchema.options is the spec authoring option schema (obj }); }); + /** + * TRIPWIRE for the spec-version gate in `zod-mirror-parity.test.ts` + * (`SpecEnvelopeAdmitsSourceless`). On the installed spec 17.4.0 the envelope's + * `source` is OPTIONAL in the static input type, and at runtime `ast` alone + * satisfies its refine (`{ dialect }` with neither is refused). So an envelope + * with no `source` parses, which the declaration's wire refuses. That is why + * `options` and its two containers are ledgered WIDER. objectstack `main` requires + * a non-blank `source`. When the pin bump turns this red, delete the gated rows, + * their `WIDER_ARMS` entries and the gate, then flip this to a refusal. + */ + it('accepts a source-less `{ dialect, ast }` envelope on spec 17.4.0 (the gated WIDER rows rest on this)', () => { + expect(optionOut({ dialect: 'cel', ast: { kind: 'ident' } })).toEqual({ + label: 'Open', + value: 'open', + visibleWhen: { dialect: 'cel', ast: { kind: 'ident' } }, + }); + }); + it('gives the same verdict as the spec schema itself, over a spread of options', () => { const samples: Record[] = [ { label: 'Open', value: 'open' }, diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 31e1e7cc3a..4968f48ff2 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -3451,6 +3451,59 @@ export const assertionUnmirroredMatchesLedger: never = 0 as unknown as { [K in MirrorKey]: ReconcileAgainstLedger< K, UnmirroredOf< K >, RecordedUnmirrored< K > >; }[MirrorKey]; +/** + * SPEC-VERSION GATE for the `options` row and its two containers (objectui#10296): does the installed spec's + * `visibleWhen` input envelope admit an object with NO `source`? + * + * `views.zod.ts#DetailViewFieldSchema::options` reads the spec's `SelectOptionSchema` + * by reference, so its WIDER verdict follows the spec that is installed. On the + * pinned `@objectstack/spec` 17.4.0 the envelope's `source` is optional in the static + * input type (at runtime `ast` alone satisfies its refine), so an envelope with no + * `source` is admitted and the declaration (whose wire requires `source`) refuses it: + * WIDER, recorded. On objectstack `main` the key is + * `EvaluatedExpressionInputSchema`, whose envelope REQUIRES `source`, so the mirror is + * no longer wider there (the `KnownDrift` half, `dialect` required, holds on both). + * The Spec Main Shape Gate compiles this file against `main`, so the ledger must + * hold on both. + * + * Read off the MIRROR's own input face, not an import of the spec, so it measures + * exactly the schema the row is about. The ledger entry stays a literal (the census + * parses it). Only the reconciliation consults this, and only for the rows in + * `SpecEnvelopeGatedWider` below: on 17.4.0 they must be measured WIDER exactly as + * before, and on `main` they must measure absent, so neither side is waived. ⛔ At + * the pin bump this reads `false` for good. Then delete those rows, their + * `WIDER_ARMS` entries and this gate together. + * The runtime tripwire in `detail-view-field-options-10296.test.ts` goes red at + * that bump to say so. + */ +type DetailViewOptionVisibleWhenInput = NonNullable< + NonNullable< MirrorInputOf< 'views.zod.ts#DetailViewFieldSchema', 'options' > >[number]['visibleWhen'] +>; +export type SpecEnvelopeAdmitsSourceless = + { dialect: 'cel' } extends DetailViewOptionVisibleWhenInput ? true : false; + +/** + * The rows that gate withholds on a spec whose envelope requires `source`. Measured + * against objectstack `main` `e8f163fc3a62`, not assumed: the two container pairs + * carry `DetailViewFieldSchema` elements, and there their WIDER reading is gone too. + * Their only wider member was `options` (on the base commit, the inline shape's + * number/boolean `value`; on 17.4.0, the source-less envelope). They are ledgered + * SCHEMA-NODE, but that class names the arm, not the cause, so they leave with it. + */ +type SpecEnvelopeGatedWider = { + 'views.zod.ts#DetailViewFieldSchema': 'options'; + 'views.zod.ts#DetailViewSchema': 'fields' | 'sections'; + 'views.zod.ts#DetailViewSectionSchema': 'fields'; +}; + +/** What `WiderThanDeclared` records for a pair, with the spec-version gate above applied. */ +export type WiderRecorded< K extends MirrorKey > = + K extends keyof WiderThanDeclared + ? SpecEnvelopeAdmitsSourceless extends true + ? WiderThanDeclared[K] + : Exclude< WiderThanDeclared[K], K extends keyof SpecEnvelopeGatedWider ? SpecEnvelopeGatedWider[K] : never > + : never; + /** * The THIRD direction: every pair's WIDER key set equals what `WiderThanDeclared` * records for it — `never` for the pairs with no entry. @@ -3467,11 +3520,7 @@ export const assertionUnmirroredMatchesLedger: never = 0 as unknown as { * @object-ui/types type-check` is the gate that reads it. */ export type WiderLedgerMismatch = { - [K in MirrorKey]: ReconcileAgainstLedger< - K, - WiderOf< K >, - K extends keyof WiderThanDeclared ? WiderThanDeclared[K] : never - >; + [K in MirrorKey]: ReconcileAgainstLedger< K, WiderOf< K >, WiderRecorded< K > >; }[MirrorKey]; export const assertionWiderMatchesLedger: never = 0 as unknown as WiderLedgerMismatch; @@ -3489,8 +3538,8 @@ export const assertionWiderMatchesLedger: never = 0 as unknown as WiderLedgerMis */ export type WiderLedgerKeyDrift = { [K in MirrorKey]: - | Exclude< WiderOf< K >, K extends keyof WiderThanDeclared ? WiderThanDeclared[K] : never > - | Exclude< K extends keyof WiderThanDeclared ? WiderThanDeclared[K] : never, WiderOf< K > >; + | Exclude< WiderOf< K >, WiderRecorded< K > > + | Exclude< WiderRecorded< K >, WiderOf< K > >; }[MirrorKey]; export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderLedgerKeyDrift; From 1b256ac2d530a05b3a4555b4b3a07fc1b6705551 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:00:34 +0000 Subject: [PATCH 4/4] fix(plugin-designer): NAV_TYPE_META has an entry for the spec's `doc` navigation item type objectstack#19789 widened the spec's navigation item union with `doc`, and `NAV_TYPE_META` is a Record keyed by that spec-derived union, so objectui stopped compiling against @objectstack/spec built from objectstack main (the Spec Main Shape Gate, TS2741 on NavigationDesigner). - `doc` entry: `appDesigner.navTypeDoc`, `bg-blue-100 text-blue-700`, `BookOpen`. - The map is typed `Record`: the pinned spec 17.4.0 has no `doc`, so a plain `doc:` key is an excess property there. The `| 'doc'` goes at the pin bump that ships `doc`. - `appDesigner.navTypeDoc` fallback in useDesignerTranslation and all ten locale packs. - Guard test: every discriminant of the installed spec's NavigationItemSchema renders a row with a resolved type badge (spec subset of map, not equality). - `doc` is deliberately NOT in QUICK_ADD_TYPES: an empty doc item fails the spec's book-or-doc requirement; authoring it is objectui#10188. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC (cherry picked from commit 6f970da0588a6671432a9cdee71ac759aa67be21) --- .../10287-navigation-designer-doc-type.md | 21 +++++ packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + .../src/NavigationDesigner.tsx | 13 ++- .../NavigationDesigner.specNavTypes.test.tsx | 93 +++++++++++++++++++ .../src/hooks/useDesignerTranslation.ts | 1 + 14 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 .changeset/10287-navigation-designer-doc-type.md create mode 100644 packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx diff --git a/.changeset/10287-navigation-designer-doc-type.md b/.changeset/10287-navigation-designer-doc-type.md new file mode 100644 index 0000000000..457e6b739a --- /dev/null +++ b/.changeset/10287-navigation-designer-doc-type.md @@ -0,0 +1,21 @@ +--- +'@object-ui/plugin-designer': patch +'@object-ui/i18n': patch +--- + +fix(plugin-designer): the Navigation Designer has an entry for the spec's `doc` navigation item type + +objectstack#19789 added a `doc` member to the spec's navigation item union (an +item that targets a book and/or a doc). `NAV_TYPE_META` in `NavigationDesigner` +is keyed by that spec-derived union, so objectui stopped compiling against +`@objectstack/spec` built from objectstack `main`, and every row reads its badge, +colour and icon from that map. The map now has a `doc` entry (a `BookOpen` icon, +its own colour and the `appDesigner.navTypeDoc` label key). The key has an +English fallback in the designer's defaults and a translation in all ten locale +packs. + +The map is typed `Record` so it compiles both +against the pinned `@objectstack/spec`, which predates `doc`, and against +objectstack `main`. The `| 'doc'` goes away at the pin bump that ships `doc`. +`doc` is not added to the quick-add buttons: an empty `doc` item fails the spec's +book-or-doc requirement, and authoring one is objectui#10188. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index c419304a7e..62350bdddf 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1405,6 +1405,7 @@ const ar = { navTypeSeparator: "فاصل", navTypeAction: "إجراء", navTypeComponent: "مكوّن", + navTypeDoc: "مستند", navEditIcon: "تعديل الأيقونة", navToggleVisible: "تبديل الرؤية", navHidden: "مخفي", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index e5baca0494..f2ae6e0d7f 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1398,6 +1398,7 @@ const de = { navTypeSeparator: "Trenner", navTypeAction: "Aktion", navTypeComponent: "Komponente", + navTypeDoc: "Dokument", navEditIcon: "Symbol bearbeiten", navToggleVisible: "Sichtbarkeit umschalten", navHidden: "Ausgeblendet", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index b0772d1c52..df400a3bad 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1647,6 +1647,7 @@ const en = { navTypeSeparator: 'Separator', navTypeAction: 'Action', navTypeComponent: 'Component', + navTypeDoc: 'Doc', navEditIcon: 'Edit icon', navToggleVisible: 'Toggle visibility', navHidden: 'Hidden', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index abe8f946b7..408c88ecb6 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1402,6 +1402,7 @@ const es = { navTypeSeparator: "Separador", navTypeAction: "Acción", navTypeComponent: "Componente", + navTypeDoc: "Documento", navEditIcon: "Editar icono", navToggleVisible: "Alternar visibilidad", navHidden: "Oculto", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 21949ab87c..e71ca35695 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1400,6 +1400,7 @@ const fr = { navTypeSeparator: "Séparateur", navTypeAction: "Action", navTypeComponent: "Composant", + navTypeDoc: "Document", navEditIcon: "Modifier l'icône", navToggleVisible: "Basculer la visibilité", navHidden: "Masqué", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 6706238a5b..3dcbb9d28c 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1398,6 +1398,7 @@ const ja = { navTypeSeparator: "区切り", navTypeAction: "アクション", navTypeComponent: "コンポーネント", + navTypeDoc: "ドキュメント", navEditIcon: "アイコンを編集", navToggleVisible: "表示を切り替え", navHidden: "非表示", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 645e690ca5..019f9f10ea 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1398,6 +1398,7 @@ const ko = { navTypeSeparator: "구분선", navTypeAction: "작업", navTypeComponent: "컴포넌트", + navTypeDoc: "문서", navEditIcon: "아이콘 편집", navToggleVisible: "가시성 토글", navHidden: "숨김", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 92d0bee665..b6b50b8b5f 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1397,6 +1397,7 @@ const pt = { navTypeSeparator: "Separador", navTypeAction: "Ação", navTypeComponent: "Componente", + navTypeDoc: "Documento", navEditIcon: "Editar ícone", navToggleVisible: "Alternar visibilidade", navHidden: "Oculto", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 682ec698dd..f30ac8280b 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1408,6 +1408,7 @@ const ru = { navTypeSeparator: "Разделитель", navTypeAction: "Действие", navTypeComponent: "Компонент", + navTypeDoc: "Документ", navEditIcon: "Редактировать значок", navToggleVisible: "Переключить видимость", navHidden: "Скрыто", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 6285b0ea38..703ed32126 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1463,6 +1463,7 @@ const zh = { navTypeSeparator: '分隔线', navTypeAction: '操作', navTypeComponent: '组件', + navTypeDoc: '文档', navEditIcon: '编辑图标', navToggleVisible: '切换可见性', navHidden: '已隐藏', diff --git a/packages/plugin-designer/src/NavigationDesigner.tsx b/packages/plugin-designer/src/NavigationDesigner.tsx index 677ae3f20f..dc6cc9b5f0 100644 --- a/packages/plugin-designer/src/NavigationDesigner.tsx +++ b/packages/plugin-designer/src/NavigationDesigner.tsx @@ -17,6 +17,7 @@ import React, { useState, useCallback, useRef } from 'react'; import type { NavigationItem, NavigationItemType } from '@object-ui/types'; import { + BookOpen, ChevronDown, ChevronRight, ChevronUp, @@ -79,7 +80,16 @@ function createId(prefix: string): string { return `${prefix}_${Date.now()}_${ndCounter}`; } -const NAV_TYPE_META: Record }> = { +// Keyed by the spec-derived union, so a nav type the spec adds stops this file +// compiling until it has an entry -- keep it a `Record`, never `Partial` or +// `Record`. +// +// `| 'doc'` is the published pin lagging the spec: objectstack#19789 added the +// `doc` nav item, and the pinned `@objectstack/spec` predates it, so without +// the extra key a `doc:` entry is an excess property against the pin while its +// absence fails the compile against objectstack `main` (Spec Main Shape Gate). +// Drop `| 'doc'` at the pin bump that ships `doc`; the entry itself stays. +const NAV_TYPE_META: Record }> = { object: { labelKey: 'appDesigner.navTypeObject', color: 'bg-green-100 text-green-700', Icon: Database }, dashboard: { labelKey: 'appDesigner.navTypeDashboard', color: 'bg-amber-100 text-amber-700', Icon: LayoutDashboard }, page: { labelKey: 'appDesigner.navTypePage', color: 'bg-teal-100 text-teal-700', Icon: FileText }, @@ -89,6 +99,7 @@ const NAV_TYPE_META: Record = [ diff --git a/packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx b/packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx new file mode 100644 index 0000000000..6b747efac7 --- /dev/null +++ b/packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx @@ -0,0 +1,93 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#10287 — every navigation item type the spec declares gets a row + * the Navigation Designer can draw. + * + * `NAV_TYPE_META` is a `Record` keyed by the spec-derived `NavigationItemType`, + * so the compiler already refuses a missing entry. This pin is the runtime + * half, and it reads the vocabulary from the INSTALLED `@objectstack/spec`'s + * `NavigationItemSchema` discriminants rather than from a list written here, + * so it follows whatever spec it is run against: + * + * - against the pinned release it covers that release's members; + * - against a spec built from objectstack `main` (the Spec Main Shape Gate's + * injected install) it also covers members the pin does not have yet — + * objectstack#19789's `doc` was the one that went unhandled. + * + * The map may carry MORE keys than the spec it is run against (`doc` ahead of + * the pin bump), so this asserts spec ⊆ map, never equality. + * + * A missing entry fails as a render error: every row reads `meta.Icon` from + * the map. An entry whose label key has no English fallback fails the + * raw-key assertion. + */ + +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { NavigationItemSchema } from '@objectstack/spec/ui'; +import type { NavigationItem } from '@object-ui/types'; +import { NavigationDesigner } from '../NavigationDesigner'; + +/** + * Walk `.unwrap()` (the spec wraps its schemas lazily) until the node carries + * `key`. Bounded, and answers `undefined` rather than looping on a shape it + * does not recognise. + */ +function unwrapUntil(node: unknown, key: string): Record | undefined { + let current = node as Record | undefined; + for (let depth = 0; depth < 8 && current; depth += 1) { + if (key in current) return current; + const unwrap = current.unwrap; + if (typeof unwrap !== 'function') return undefined; + current = unwrap.call(current) as Record | undefined; + } + return current && key in current ? current : undefined; +} + +/** The discriminant values of the spec's nav-item union. Throws when unreadable. */ +function specNavItemTypes(): string[] { + const union = unwrapUntil(NavigationItemSchema, 'options'); + const options = union?.options; + if (!Array.isArray(options) || options.length === 0) { + throw new Error('could not read NavigationItemSchema options from @objectstack/spec'); + } + return options.flatMap((option, index) => { + const shape = unwrapUntil(option, 'shape')?.shape as Record | undefined; + const literal = shape?.type as { values?: unknown } | undefined; + if (!(literal?.values instanceof Set) || literal.values.size === 0) { + throw new Error(`could not read the \`type\` literal of NavigationItemSchema option ${index}`); + } + return [...literal.values].map(String); + }); +} + +const SPEC_NAV_ITEM_TYPES = specNavItemTypes(); + +describe('NavigationDesigner — a row for every spec navigation item type (#10287)', () => { + it('reads a non-empty vocabulary from the installed spec', () => { + // Non-vacuity: an empty or mis-read list would make every case below pass. + expect(SPEC_NAV_ITEM_TYPES).toContain('object'); + expect(SPEC_NAV_ITEM_TYPES).toContain('group'); + expect(new Set(SPEC_NAV_ITEM_TYPES).size).toBe(SPEC_NAV_ITEM_TYPES.length); + }); + + it.each(SPEC_NAV_ITEM_TYPES)('draws the `%s` type in the tree and the live preview', (type) => { + // Cast at the fixture boundary only: the type comes from the installed + // spec at runtime, which can name a member the pinned types do not have. + const item = { id: `probe_${type}`, type, label: 'Probe item' } as unknown as NavigationItem; + + render( {}} showPreview />); + + const row = screen.getByTestId(`nav-designer-item-probe_${type}`); + // The type badge resolves to a label, not to its raw translation key. + expect(row.textContent ?? '').not.toMatch(/appDesigner\./); + }); +}); diff --git a/packages/plugin-designer/src/hooks/useDesignerTranslation.ts b/packages/plugin-designer/src/hooks/useDesignerTranslation.ts index f540bc504e..3cecda4251 100644 --- a/packages/plugin-designer/src/hooks/useDesignerTranslation.ts +++ b/packages/plugin-designer/src/hooks/useDesignerTranslation.ts @@ -104,6 +104,7 @@ export const DESIGNER_DEFAULT_TRANSLATIONS: Record = { 'appDesigner.navTypeSeparator': 'Separator', 'appDesigner.navTypeAction': 'Action', 'appDesigner.navTypeComponent': 'Component', + 'appDesigner.navTypeDoc': 'Doc', 'appDesigner.navEditIcon': 'Edit icon', 'appDesigner.navToggleVisible': 'Toggle visibility', 'appDesigner.navHidden': 'Hidden',