-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathcomponent.zod.ts
More file actions
3035 lines (2968 loc) · 186 KB
/
Copy pathcomponent.zod.ts
File metadata and controls
3035 lines (2968 loc) · 186 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { z } from 'zod';
import { ViewFilterRuleSchema, ViewDataSchema } from './view.zod';
import { InlineActionSchema, ActionLocationSchema } from './action.zod';
import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod';
import { FeedItemType, FeedFilterMode } from '../data/feed.zod';
import { lazySchema } from '../shared/lazy-schema';
import { ExpressionInputSchema } from '../shared/expression.zod';
import { retiredKey } from '../shared/retired-key';
// `user:profile`'s retirement prescription — one string, three doors (#14159):
// the enum's error map and the `PageComponentSchema.type` check in page.zod.ts,
// and the kept `ComponentPropsMap` row below (`retiredComponentProps`).
import { RETIRED_PAGE_COMPONENT_TYPES } from './page.zod';
// `element:record_picker`'s flat `sort` shorthand is the SAME contract as
// `ElementDataSourceSchema.sort` (page.zod.ts) — one shape, imported from the
// shared source rather than re-spelled here (#6276).
import { SortItemSchema } from '../shared/enums.zod';
import { strictObject } from '../shared/strict-object';
import type { KeySetGuidance } from '../shared/suggestions.zod';
// [#13855] The section → field-group reference form, shared with
// `FormSectionSchema` (view.zod.ts) so one mixing rule serves both escape hatches.
import { SectionGroupKeySchema, sectionGroupReferenceRefinement } from '../shared/section-group-reference';
// ---------------------------------------------------------------------------
// CLOSED AGAINST UNKNOWN KEYS as of #4001 batch A -- all 31 object sites.
// (#7751 then GREW the map by the `object-*` block family -- six entries,
// strict from birth, key sets derived from objectui's renderer read points;
// see the "Object-bound SDUI blocks" section below. The "31"s in this header
// are batch A's own count, kept as the historical measurement they were.)
//
// SDUI component prop schemas: the declarative shape of every `page:*`,
// `record:*`, `element:*`, `nav:*` and `ai:*` node a page can carry.
//
// ⚠️ READ THE SCOPE BEFORE ACTING ON THIS. Closing these shapes moved the
// rejection into ONE door -- the #5068 authoring gate's `safeParse` half. It
// did NOT close the carrier and it did NOT close storage:
//
// - `PageComponentSchema.properties` is still `z.record(z.string(),
// z.unknown())`. Direction B (a discriminated `properties`) stays DECLINED
// by the maintainer's 2026-08-05 ruling, because `type` is an open union and
// a discriminated carrier would reject the unregistered types real pages
// author. An unknown key inside `properties` therefore still survives
// `PageSchema.parse()` -- pinned, deliberately, in `component.test.ts`.
// - A `saveMetaItem` / REST `/meta` write still stores an unvalidated props
// bag (#4463's fourth wall). Recorded, not fixed.
// - The gate is still WARNING level. Batch A did not upgrade it; what stands
// between it and `error` is the page rewrites named at the end of this
// header, not a declaration in this file.
//
// So the honest one-line summary is: an undeclared prop is now rejected BY THE
// PARSE at authoring time, with the surface named and the rename offered,
// instead of being reconstructed by a walker reading a strip-mode object. Same
// rule id, same tier, one fewer moving part -- and a shape that can now carry
// its own `aliases` / `guidance`, which a walker's reconstruction could not.
//
// The 批 17 measurement that produced the earlier `no gate` verdict is kept
// verbatim below. It is why this file took three batches, and every sentence of
// it was true when written; the two flips since (#5068 wired the parse, batch A
// closed the shapes) are recorded at the points where they land.
//
// It was scheduled as the #4001 campaign's largest remaining `ui/` block and
// the measurement came back NEGATIVE: nothing parses these schemas, so
// `.strict()` here would enforce exactly nothing while spending a v17 breaking
// change to produce what #4583 calls "a precisely validated dead slot -- the
// more convincing lie".
//
// `.strict()` is a property of a PARSE. Three independent measurements, each
// with its controls green in the same run (2026-08-04):
//
// 1. THE CARRIER IS AN OPEN BAG. `PageComponentSchema.properties` is
// `z.record(z.string(), z.unknown())` (`page.zod.ts`). `PageComponentSchema`
// itself has been `.strict()` since ADR-0089 D3a — but strictness does NOT
// recurse, so it closes the component node's own keys and leaves everything
// under `properties` unchecked. Nothing dispatches `ComponentPropsMap` by
// `type`.
// 2. BFS-UNREACHABLE. From all 24 metadata-type roots plus `defineStack`'s
// `ObjectStackSchema`, over a 6899-node closure built with `build-schemas.ts`'s
// own `zodChildSchemas` / `zodShapeOf` (the #4650 walk), all 52 targets here
// (21 exported schemas + every one of `ComponentPropsMap`'s 31 entries) come
// back UNREACHABLE — while `PageSchema`, `PageComponentSchema`,
// `PageRegionSchema`, `ThemeSchema`, `ChartConfigSchema` and
// `ResponsiveConfigSchema` all resolve `root-graph` in that same run, and 批 13's
// measured no-door shapes stay unreachable. The walk stops at `properties`.
// 3. NO PRODUCTION PARSE. Across `objectstack`, `objectui` and `cloud`, every
// `.parse()` / `.safeParse()` on anything in this file is inside this file's
// own unit tests. `objectui` mirrors the props as hand-written React
// interfaces and imports only the inferred TYPES; `cloud` references none.
// `react-blocks.ts` uses `Object.keys(ComponentPropsMap)` for type names only —
// its `REACT_BLOCKS[].schema` entries all point at view/chart schemas.
//
// The #5056 bridge defect does NOT touch this result. That defect makes the
// derived-clone bridge report dead shapes as REACHABLE (shared `.describe()`
// clones under common leaves like `SnakeCaseIdentifier` / `I18nLabel`), so its
// error direction is the opposite of this verdict -- it could only have hidden a
// no-gate finding, never manufactured one. And nothing here rests on that bridge
// anyway: all six positive controls resolve `root-graph` (their own instances are
// in the closure), and all 52 targets miss BOTH `root-graph` and `derived-clone`.
// The two non-BFS measurements below stand on their own regardless.
//
// Empirically, through the live door (`definePage()` IS `PageSchema.parse()`): on
// the example corpus an undeclared key written inside `components[].properties`
// parses clean and is RETAINED on 10/10 pages, while the same key one level out
// — a sibling of `properties` — is rejected on 10/10. The negative control is
// what makes the first number mean something.
//
// WHY `no gate` AND NOT `no door` (批 13 vs 批 15)
//
// The vocabulary here is ALIVE — this is not dead surface to retire under
// ADR-0049. Authors write these keys on real pages, and objectui's
// `SchemaRenderer` hoists `properties` onto the node and spreads every key that
// is not on its fixed metadata deny-list straight into the React component. So
// a misspelled key is neither rejected nor dropped: it reaches the renderer and
// is ignored there. That is the ADR-0078 failure mode, one layer below where
// this campaign can reach.
//
// The contract-first fix is therefore to WIRE THE PARSE at the carrier's own
// gate, not to close schemas nobody calls — filed as #5068, which also
// records the two constraints that stop it being a drive-by: `type` is an open
// union (unregistered types like `record:line_items` are authored in the wild),
// and real pages already author shapes these schemas do not declare (the record
// picker's `labelField` — see `packages/lint/src/validate-page-field-bindings.ts`,
// which has documented the untyped bag all along). `record:details`
// `sections[]` / `hideFields[]` WAS the largest such divergence and is now
// closed: #5611 re-declared `sections` in the object form every page actually
// authors and declared `hideFields`, so wiring the gate no longer turns three
// showcase pages and the `sys_user` platform page into hard parse errors.
//
// #5775 closed the rest of that inventory in both directions, on the same #5611
// rule (the delivered, authorized shape is the contract): nine keys the
// renderers honour were DECLARED (`element:record_picker` `labelField` /
// `valueField` / `label` / `emptyText`, `record:path` `stages[].terminal`,
// `page:tabs` `items[].value` / `items[].count`, `page:card` `children`, and
// `children` on the three thin containers that were declared `EmptyProps`),
// and four that nothing read were RETIRED with tombstones + ADR-0087 D2
// conversions (`displayField` → `labelField`, `page:card.body` → `children`,
// `searchFields`, `multiple`). What is deliberately NOT closed here is
// `page:card.visible`: a component-level visibility predicate written into
// `properties` and hoisted by `SchemaRenderer`. The canonical spelling is the
// component-level `visibleWhen` (ADR-0089) — that one is a page to rewrite, not
// a key to declare.
//
// "The rest of that inventory" was one pair short, and how the shortfall
// happened is the reusable part: #5775's ruling named its keys individually, so
// the two `element:record_picker` shorthands the renderer reads through the
// SAME `ds.x ?? props.x` line as the keys that were named — `sort` and `limit`
// — fell outside it and stayed undeclared. #6276 declared them on the same
// #5611 rule (maintainer ruling 2026-08-08, direction A). The lesson for the
// next divergence sweep: enumerate by the RENDERER'S read pattern, not by the
// key list a previous ruling happened to quote. Retiring the flat family
// wholesale in favour of `dataSource` is the standing alternative, deferred to
// v18 as #11509 — not rejected.
//
// ── #5068: THE GATE IS WIRED — read the flip precisely ─────────────────────
//
// `packages/lint/src/validate-component-props.ts` dispatches on the component's
// `type` and judges `properties` against the entry below it: undeclared keys
// through the same walker every metadata collection uses
// (`lintUnknownKeysAgainstSchema`), values through `safeParse`. It runs on
// `os validate` / `os build` / `os lint` from the shared authoring registry.
// So these schemas ARE parsed now, and this file is `authorable`.
//
// Three things that flip did NOT do, each of which someone will otherwise
// assume:
//
// 1. **The carrier is unchanged, on purpose.** `PageComponentSchema.properties`
// is still `z.record(z.string(), z.unknown())`. The maintainer's 2026-08-05
// ruling took direction A (gate at the authoring door) and DECLINED
// direction B (a discriminated `properties`) as breaking against an open
// `type` union. So the three standing assertions in `component.test.ts`
// stay GREEN — measured, not assumed — and their prose was updated to say
// which dispatch actually landed.
// 2. **Nothing here became strict** — at #5068. All 31 entries still STRIPPED,
// and the gate reported an undeclared key because the walker read a
// strip-mode object. ✅ **#4001 batch A did the conversion this sentence
// predicted**: every site is a `strictObject` now, so the same report
// arrives through the gate's `safeParse` half (`unrecognized_keys`, routed
// to the same rule id). Two things came with it that the walker could not
// produce, and they are the reason the conversion was not cosmetic:
// hand-written `aliases`/`guidance` per surface (`key` → `value` on a tab
// item, `description` → `subtitle` on a header, the wrong-layer
// component-node family), and a rejection that holds on ANY caller of these
// schemas rather than only inside the gate that walks them.
//
// Union arms needed one piece of wiring on the lint side to arrive at all:
// zod 4 collapses arm failures into a single `invalid_union`, so
// `validate-component-props.ts` unpacks a lone arm's `unrecognized_keys`
// back onto the unknown-key rule id (`unrecognizedKeysFromUnionArm`), and
// deliberately declines to do so when two arms could both have been meant.
// 3. **The storage path is still open.** The gate is an AUTHORING door. A
// `saveMetaItem` / REST `/meta` write still stores an unvalidated props bag
// (#4463's fourth wall). That is recorded, not fixed, by #5068.
//
// The gate is WARNING-level in this first step. The live corpus violated these
// declarations in places that were open contract questions rather than
// authoring mistakes, and the inventory is the acceptance baseline for the
// error upgrade. Two of the three entries are now cleared:
//
// - #5775 declared the keys objectui's renderers honour and tombstoned the
// four nothing read.
// - #5728 settled the inline `{ en, 'zh-CN' }` label maps the three published
// platform pages author: the maintainer ruled (2026-08-06) that the map is a
// delivered capability, so `I18nLabelSchema` is a union of the plain string
// and an inline locale map, and `element:text.content` — declared a bare
// `z.string()` and therefore out of that union's reach — was named in the
// same ruling and moved onto it. That retired all 42 `component-props-invalid`
// findings this gate reported on the platform pages (34 label + 8 content).
//
// What remains before the upgrade to error is the page rewrites
// (`page:card.visible` → the component-level `visibleWhen`, #5776's tab `key`
// → `value`), not a declaration in this file.
//
// ⚠️ One inventory item batch A ADDED rather than closed, because measuring the
// renderers turned it up: objectui's Studio block designer publishes inputs that
// no renderer reads — `page:accordion` `title` and its items' `value` (the
// renderer overwrites `value` with `panel-<index>`), and `page:header.icon`,
// which #6946 retired here. Those are producer-side defects in the sibling repo
// (filed as #7973), not keys to declare; the accordion item's
// `value` carries a `guidance` entry so an author who copies the designer's
// output is told what happened rather than merely refused.
//
// The verdict is pinned in `component.test.ts` and in the `ui/` tables of
// `docs/audits/2026-07-unknown-key-strictness-ledger.md` — change all three
// together or none.
// ---------------------------------------------------------------------------
/**
* What silently happened to an undeclared prop before these shapes were closed
* — the one sentence every rejection on this file carries.
*
* Two layers of silence, not one, which is why the sentence names both: the
* schema STRIPPED the key (nothing in `ComponentPropsMap` was strict), and the
* carrier never parsed it anyway (`PageComponent.properties` is
* `z.record(z.string(), z.unknown())`). #5068 wired the parse; this closes the
* shapes behind it, so the rejection is now the parse's own rather than a
* walker's reconstruction of it.
*/
const PROPS_HISTORY =
'Until this shape was closed, an undeclared prop was dropped in silence: the props schema stripped it '
+ 'and `PageComponent.properties` is an open bag, so the key reached objectui\'s renderer, was '
+ 'not read there, and the author got a success receipt for configuration that did nothing.';
/**
* The keys that belong on the component NODE, written one level down inside
* `properties` — the wrong-layer trap this carrier creates by construction.
*
* objectui's `SchemaRenderer` HOISTS `properties` onto the node before
* rendering, which is what makes the confusion durable: for a renderer read
* the two spellings are interchangeable, so an author who writes
* `properties.visibleWhen` sees the key "work" in some places. It does not
* work where it matters — `visibleWhen` is evaluated by the page runtime off
* the NODE, and `SchemaRenderer` deliberately skips `type` and `id` when
* hoisting (hoisting `type` would shadow which renderer to dispatch to). So
* the inner spelling is honoured by nothing that decides anything.
*
* A pattern rather than a list for the visibility family, on the #6619
* precedent: the point is to catch the spellings nobody enumerated
* (`visibleIf`, `hiddenWhen`, `visibility`), and ADR-0089 made `visibleWhen`
* canonical on the node, so an author borrowing it here is not making a typo.
* `page:card.visible` is the live specimen the file header has carried since
* #5775 — deliberately never declared, because it is a page to rewrite rather
* than a key to add.
*/
/**
* The two sets are NAMED individually (#8744) because one row cannot carry the
* visibility set: `record:alert` DECLARES `visible` — the one record component
* whose renderer evaluates a props-level predicate — and the #6619 audit
* rightly refuses a pattern set whose example is a declared key. Every other
* row keeps taking the pair via `COMPONENT_LEVEL_GUIDANCE` below, unchanged.
*/
const COMPONENT_NODE_VISIBILITY_GUIDANCE: KeySetGuidance =
{
name: 'COMPONENT_NODE_VISIBILITY_KEYS',
keys: /^(visible|visibility|visibleOn|visibleIf|visibleWhen|hidden|hiddenWhen|conceal|showWhen)$/,
examples: ['visible', 'visibleWhen', 'visibleIf', 'hiddenWhen', 'visibility'],
prescription:
'Visibility is a COMPONENT-level predicate, not a prop: move it up one level to the '
+ 'component node\'s own `visibleWhen` (ADR-0089 canonical spelling), beside `type` and '
+ '`id` — one canonical spelling per layer, not because the props-level form is inert. '
+ 'Since the console release of 2026-08-21 (`c86185eb5`) the hoisted form IS evaluated by '
+ 'the node-level gate: the two gates evaluate the same value and compose as an '
+ 'idempotent AND, so leaving it in `properties` duplicates the canonical key rather '
+ 'than silently failing to gate.',
};
const COMPONENT_NODE_KEYS_GUIDANCE: KeySetGuidance =
{
name: 'COMPONENT_NODE_KEYS',
/**
* Read off `PageComponentSchema`'s own shape (`page.zod.ts`) and then
* NARROWED, twice, because a set member the shape declares is a dead entry
* the `alias-integrity` audit rejects — and it caught both of these:
*
* - `type` is out. It really is a prop on `element:metadata_viewer` (the
* metadata view kind — `state_machine` | `flow` | `permission`) and a
* tombstone on `page:tabs` (#6776), so a blanket "this belongs on the
* node" would be a WRONG answer on the two surfaces most likely to see it.
* - `label`, `aria` and `properties` are out for the same reason: `label`
* and `aria` are declared props almost everywhere in this file.
*
* What is left is node-only in both directions: nothing in this file
* declares any of them, and the page runtime reads each off the node.
*/
keys: ['id', 'events', 'style', 'className', 'responsiveStyles', 'dataSource', 'responsive'],
prescription:
'This key belongs on the component NODE, not inside `properties` — write it as a sibling '
+ 'of `type`. `SchemaRenderer` skips `id` when it hoists `properties`, and `dataSource` / '
+ '`responsive` / `events` / `style` / `className` / `responsiveStyles` are read off the '
+ 'node by the page runtime, so the inner spelling is parsed by nothing.',
};
const COMPONENT_LEVEL_GUIDANCE: readonly KeySetGuidance[] = [
COMPONENT_NODE_VISIBILITY_GUIDANCE,
COMPONENT_NODE_KEYS_GUIDANCE,
];
/**
* Empty Properties Schema
*/
/**
* A component that declares no props at all — `app:launcher`, `nav:menu`,
* `nav:breadcrumb`, `global:search`, `global:notifications`,
* `element:divider`, and the three plugin console widgets
* `cloud-connection:panel` and `marketplace:installed-list` (#11575) and
* `mcp:connect-agent` (#12344). `user:profile` left this list at #14159 — it
* is not author-placeable at all, so its row refuses the whole bag
* ({@link retiredComponentProps}).
*
* A factory rather than one shared `EmptyProps` const, because the surface name
* is the whole value of the rejection here: an empty shape has no candidate
* keys, so the edit-distance fallback can say nothing, and "unrecognized key on
* this component" would leave the author guessing which of the nine it meant.
* One `strictObject(` call site either way — the ledger counts sites from the
* AST, and this is one.
*
* Closing them is not vacuous even with nothing to declare: `element:divider`
* carries an authored `{}` on 9 nodes of the example corpus, and the whole
* point of the class is that these components take no configuration. Before
* this, `<Divider color="red">` parsed clean and drew a divider with no colour.
*/
const emptyProps = (type: string) =>
strictObject(
{
surface: `this \`${type}\` component`,
history: `\`${type}\` declares no props at all. ${PROPS_HISTORY}`,
guidanceSets: COMPONENT_LEVEL_GUIDANCE,
},
{},
);
/**
* A component RETIRED at element grain whose props bag is refused WHOLE —
* `user:profile` (#14159). The `retiredKey` channel one grain wider: where a
* tombstoned KEY accepts absence and refuses any value, a retired ELEMENT has
* nothing an author may write at all, so the row is `z.never` — `{}` is refused
* exactly like a populated bag, `expected: 'never'` / `code: 'invalid_type'` is
* the same issue shape a key tombstone raises, and the message is the element's
* retirement prescription from `RETIRED_PAGE_COMPONENT_TYPES` (page.zod.ts), so
* the row and the node-level refusal on `PageComponentSchema.type` cannot drift
* apart. A type that map does not name has no business here — the throw makes
* a row without its prescription a module-load error, not a silent `undefined`
* message.
*
* Why not delete the row: `component-type-vocabulary.ts` derives the KNOWN set
* from the row keys, the #5068 props gate skips a type with no row as an
* unregistered custom string, and `check-yaml-examples` judges only rowed types
* — deleting the row would demote a loud retirement to a silent skip on every
* reader that dispatches on it (the `element:filter` argument, #9220).
*/
const retiredComponentProps = (type: string) => {
const guidance = RETIRED_PAGE_COMPONENT_TYPES.get(type);
if (!guidance) {
throw new Error(`retiredComponentProps: \`${type}\` has no RETIRED_PAGE_COMPONENT_TYPES entry (page.zod.ts)`);
}
return z.never({ error: () => guidance }).describe(`[REMOVED] ${guidance}`);
};
/**
* The composition slot every thin container renders: `page:section`,
* `page:footer`, `page:sidebar`.
*
* All three were declared `EmptyProps` — "this component takes zero props" —
* while their renderers have always rendered a child list
* (`renderChildren(schema.children || schema.body)` in objectui's
* `containers.tsx`, one per registered renderer). Declaring zero props for a
* container that renders children is the ADR-0078 shape from the schema side:
* the #5068 gate reports every authored `children` as an unknown key, and a
* `.strict()` batch would reject the only thing these components are for.
*
* `children` is the canonical spelling — it is what `grid`, `flex`,
* `page:accordion` items and `page:tabs` items already use, and what the
* renderers read FIRST. `body` is deliberately NOT declared here (#5775): one
* composition key, not two (Prime Directive #12). The renderers keep reading
* `body` as a back-compat fallback for stored documents; that fallback is
* objectui's to retire on its own schedule, and it is not a second authorable
* spelling.
*
* Shared by all three entries rather than copied: they are the same contract,
* and three identical defs would be three places for it to drift.
*/
export const PageContainerProps = strictObject(
{
surface: 'this container component (`page:section` / `page:footer` / `page:sidebar`)',
history: PROPS_HISTORY,
guidanceSets: COMPONENT_LEVEL_GUIDANCE,
guidance: {
// Not a typo the suggester can reach (`body` → `children` is five edits),
// and not a second spelling either: the renderers read `body` as a
// back-compat fallback for STORED documents (`renderChildren(schema.children
// || schema.body)`), which #5775 settled is objectui's to retire on its own
// schedule rather than an authorable key. Closing the shape is what makes
// that distinction reach the author.
body: '`body` is not an authorable spelling of the composition slot — write `children`. '
+ 'The renderers still read `body` as a back-compat fallback for documents stored under '
+ 'the older spelling, but one composition key is the contract (Prime Directive #12).',
},
},
{
children: z.array(z.unknown()).optional().describe('Child components rendered inside this container, in order'),
},
);
export type PageContainerProps = z.input<typeof PageContainerProps>;
/**
* ----------------------------------------------------------------------
* 1. Structure Components
* ----------------------------------------------------------------------
*/
export const PageHeaderProps = strictObject({
surface: 'this `page:header`',
history: PROPS_HISTORY,
guidanceSets: COMPONENT_LEVEL_GUIDANCE,
aliases: {
/**
* The ADR-0087 D2 conversion `page-header-subtitle-alias` (#4827,
* objectui#3226) renames this on load and on stored-row rehydration, so the
* canonical paths never reach here. What DOES reach here is the source an
* author is typing right now — and until this shape closed, that was the
* one path with no diagnostic at all: `conversions/walk.ts` records the
* hole in as many words, that `description` "is tombstoned nowhere
* (`description` is a live declared prop on other components), got no
* diagnostic at a nested site from any layer".
*
* An alias rather than a `retiredKey` tombstone precisely because of that
* parenthesis: `description` is a live prop elsewhere in this file
* (`element:text_input`), so the answer is a rename on THIS surface, not a
* removal notice. Grounded in the conversion registry rather than guessed.
*/
description: 'subtitle',
},
}, {
/**
* Page title (#7702, maintainer ruling 2026-08-11 「接受你的建议,开始加速处理」
* on the lane's A/B recommendation). OPTIONAL, not required: the platform's
* own synthesizer (objectui `buildDefaultHeader`) emits every seeded
* `page:header` with no `title` at all — `PageHeaderRenderer`
* (`containers.tsx:1013`) reads `schema?.title ?? schema?.properties?.title`
* and, finding neither, falls through to the record chip's own
* record-derived heading. A required `title` would reject the platform's
* own canonical output. Sanctioned spelling: title omitted ⇒ the renderer
* derives the heading from the record. Authors still set it explicitly for
* non-record pages (dashboards, landing pages) where there is no record to
* derive from.
*/
title: I18nLabelSchema.optional().describe(
'Page title. Omit to let the renderer derive the heading from the record (the default for record pages) — set explicitly on non-record pages (dashboard, landing) with no record to derive from.',
),
subtitle: I18nLabelSchema.optional().describe('Page subtitle'),
/**
* REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3829,
* route (c) — retire upstream).
*
* A header icon nothing has ever drawn. `PageHeaderRenderer`
* (`containers.tsx`) resolves `icon` only per header ACTION (`action.icon`,
* inside the action pipeline) and never off the header's own props bag;
* `@object-ui/layout`'s `<PageHeader>` accepts an `icon` REACT prop from a
* host but — unlike `actions`, whose `schema?.actions ??
* schema?.properties?.actions` fallback sits four lines away in the same
* function — gives it no schema fallback, so an authored node cannot reach
* it. objectui's registration publishes no `icon` input either, which is
* what put this key in that repo's `UNPUBLISHED_EXEMPTIONS` map as a B-class
* "spec declares it, NO renderer read point" entry.
*
* The live mechanism is the record chrome (`recordChrome`, on by default)
* for the header's own identity, and each action's own `icon` for the
* buttons beside it.
*/
icon: retiredKey(
'`page:header` property `icon` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — '
+ 'no renderer ever read it: objectui resolves `icon` only per header action (`action.icon`), '
+ 'never off the header\'s own props bag, and the component registry never published it as an '
+ 'input, so an authored value was accepted and dropped. Delete the key. The header\'s own '
+ 'identity is drawn by the record chrome (`recordChrome`, on by default) and each action '
+ 'carries its own `icon`. '
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
breadcrumb: z.boolean().default(true).describe('Show breadcrumb'),
actions: z.array(z.string()).optional().describe('Action IDs to show in header'),
/**
* Which of the two page-header layouts the renderer builds (#6776).
*
* ON (the default) the header carries the **record chrome**: the title
* renders as a record chip with the follow star and the copy-record-id
* button beside it. OFF it falls back to a bare heading — one title line and
* nothing record-shaped — which is what a dashboard or a landing page wants,
* since there is no record for the chip to describe.
*
* Declared here because the renderer has always read it and the schema had
* not caught up: `containers.tsx:979` resolves
* `schema?.recordChrome === false || schema?.properties?.recordChrome === false`
* and `:1453` branches the whole header on it, while objectui's own console
* preview sample authors `recordChrome: false` on a non-record page. Until
* this declaration that page was legal per objectui's published manifest and
* `warning: undeclared` per `validateComponentProps` (#5068) — two platform
* authorities disagreeing about one key (#5435).
*/
recordChrome: z.boolean().default(true).describe(
'Render the record chrome — the title as a record chip with its follow star and copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout.',
),
/**
* Follow (favourite) star beside the record title — `RecordTitleChip
* showStar` (#6776). Part of the record chrome, so it has no effect when
* `recordChrome` is false. Read at `containers.tsx:980`, consumed at `:1531`.
*/
showStar: z.boolean().default(true).describe(
'Show the follow (favourite) star beside the record title. Part of the record chrome — no effect when `recordChrome` is false.',
),
/**
* Copy-record-id button beside the record title — `RecordTitleChip
* showCopyId` (#6776). Same record-chrome scoping as `showStar`. Read at
* `containers.tsx:981`, consumed at `:1532`.
*/
showCopyId: z.boolean().default(true).describe(
'Show the copy-record-id button beside the record title. Part of the record chrome — no effect when `recordChrome` is false.',
),
/**
* How many header actions render as inline buttons before the rest fold into
* the overflow menu — desktop and mobile budgets (#4001 batch A).
*
* Declared on the #5611/#5775/#6276 rule, for the same reason and by the same
* evidence: the renderer has always read them and the schema had not caught
* up. `containers.tsx:1358` resolves
* `schema?.maxVisible ?? schema?.properties?.maxVisible` (and the `mobile*`
* twin), the `?? 3` / `?? 1` are its own fallbacks, and its comment says out
* loud that both are "overridable on the page:header". Closing this shape
* without declaring them would turn an invited affordance into a hard
* rejection — the #6276 lesson, which is to enumerate by the RENDERER'S read
* pattern rather than by the key list a previous ruling happened to quote.
*
* Optional with NO schema default, deliberately: 3 and 1 are the renderer's
* fallbacks, and declaring them here would materialize a `maxVisible` on
* every parsed header — turning an unset key into an authored one, exactly
* as the record picker's `limit` docblock records for its own 50.
*/
maxVisible: z.number().int().positive().optional().describe(
'How many header actions render as inline buttons before the rest fold into the overflow menu (renderer default 3).',
),
mobileMaxVisible: z.number().int().positive().optional().describe(
'The `maxVisible` budget on mobile viewports (renderer default 1).',
),
/** ARIA accessibility */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
});
export const PageTabsProps = strictObject({
surface: 'this `page:tabs`',
history: PROPS_HISTORY,
guidanceSets: COMPONENT_LEVEL_GUIDANCE,
}, {
/**
* Tab-strip visual style. **Renamed from `type` at protocol 17 (#6776,
* ADR-0087 D2)** — the same concept, the same three values, a spelling an
* author can actually write.
*
* A props key named `type` collides with the component node's own dispatch
* key, and the collision is structural rather than cosmetic:
*
* - objectui's `SchemaRenderer` hoists `properties` onto the node but
* deliberately skips `type` and `id`, or the inner value would shadow
* which renderer to dispatch to — its comment names this exact case
* ("tab visual style: 'line' | 'card' | 'pill'").
* - `sdui-parser`'s `BASE_PROPS` contains `'type'`, so a manifest input by
* that name is skipped as a base prop and never validated at all.
* - In the flat and JSX carriers a node reads `{ type: 'page:tabs', … }`,
* so `type` is the tag name and this prop has no spelling left.
*
* `tabStyle` is what objectui's registry publishes and what the renderer
* reads in every carrier (`containers.tsx:381`), so the contract converges on
* the spelling that works rather than the one that reads well — the #5775
* `displayField` → `labelField` shape, and one spelling rather than two
* (Prime Directive #12).
*/
tabStyle: z.enum(['line', 'card', 'pill']).default('line')
.describe("Tab-strip visual style: 'line' underlines the active tab, 'card' frames each tab, 'pill' renders rounded pills"),
/**
* REMOVED (#6776). The declared spelling of `tabStyle`, unauthorable in any
* flat or JSX carrier because a page component's own dispatch key is also
* called `type`. The live mechanism is `tabStyle`.
*/
type: retiredKey(
'`page:tabs` property `type` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — '
+ 'a props key named `type` collides with the page component\'s own dispatch key, so it is '
+ 'unauthorable in the flat and JSX carriers and was never validated in them. Rename the key '
+ 'to `tabStyle`; the value (`line` | `card` | `pill`) is unchanged. '
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
position: z.enum(['top', 'left']).default('top'),
/**
* Keep the tab strip visible when there is only one tab (#4001 batch A).
*
* The renderer hides a one-tab strip by default — "a single pill labelled
* 'Details' is visual clutter rather than an affordance" — and its own
* comment invites the override: *"Authors who want the strip even at length 1
* can pass `properties.alwaysShowStrip: true`"* (`containers.tsx:637`, read as
* `schema?.properties?.alwaysShowStrip === true`). Declared on the same
* #5611/#5775/#6276 rule as `page:header`'s action budget: the delivered,
* invited shape is the contract, and a closed schema that rejected it would
* be the declaration disagreeing with the renderer in the direction that
* costs the author.
*/
alwaysShowStrip: z.boolean().optional().describe(
'Render the tab strip even when only one tab is visible (renderer default: a one-tab strip is hidden).',
),
items: z.array(strictObject({
surface: 'this `page:tabs` item',
history: PROPS_HISTORY,
aliases: {
/**
* NOT a typo — `key` → `value` is four edits, so the distance fallback
* cannot reach it, and this is the alias category the helper's docblock
* describes: a different WORD for the same intent, correct on a
* neighbouring surface. Measured producers, both live: objectui's Studio
* block designer publishes `key` as the tab item's text input
* (`previews/block-config.ts`, `page:tabs.items.itemFields`), and #5776
* recorded the showcase authoring the same spelling. The renderer reads
* neither — `containers.tsx:566` takes `it.value` and falls back to
* `tab-${idx}` — so an authored `key` silently yields index-derived tab
* tokens that move the moment the item list changes.
*/
key: 'value',
/**
* Action-side spellings (#8382) — an author who learned `visible` /
* `showWhen` from `ui/action.zod.ts` and reaches for the same words
* here. One landing key, no boolean sibling, so per this package's
* alias/guidance rule (`visible-when-alias-guidance.test.ts` header)
* this is the simple rename case, not guidance prose.
*/
visible: 'visibleWhen',
showWhen: 'visibleWhen',
/**
* `visibility` / `visibleOn` (#8382) — the ADR-0089 spellings this
* surface deliberately does NOT fold in (see the docblock below): they
* stay rejected, but an author who used them correctly on a page
* component or view form is reaching for the identical intent here, so
* the rejection still points at the one key that lands it. A pointer is
* a message, not acceptance — nothing below changes what parses.
*/
visibility: 'visibleWhen',
visibleOn: 'visibleWhen',
},
}, {
label: I18nLabelSchema,
/**
* Tab-trigger icon, and the reason this key carries a docblock at all: it
* presents to a liveness sweep exactly as `page:accordion`'s item `icon`
* did one component over — declared bare, asserted nowhere — and that
* absence cost a full dispatch cycle re-deriving the cross-repo read point
* before the retirement candidate was closed (#9397 closed
* premise-overtaken; #9881 recorded the accordion's liveness; this is the
* same record for the tab item, so the sweep cannot re-derive the same
* false candidate a component over).
*
* The key is LIVE at the objectui pin this repo builds against
* (`.objectui-sha` = `a472b0716`; re-derived at that pin 2026-09-04 —
* `containers.tsx` is byte-identical to the one at `00d3f09c5`, the hop on
* which both anchors moved by exactly one line (the icon block from
* `729-735`, the registration input from `788`), so NO anchor moved here;
* both were re-READ at the new pin with the cited text unchanged rather
* than inferred from that identity): `containers.tsx:730-736`
* renders
* `{item.icon && <LazyIcon name={item.icon} …/>}` inside the
* `TabsTrigger`, left of the label span (`mr-1.5 h-3.5 w-3.5 shrink-0
* opacity-70`, `aria-hidden`), and the renderer's registration publishes
* the key to the Studio block designer at `:789` (the `items` input,
* documented as `[{ label, value?, icon?, count?, visibleWhen?, children
* }]`).
*
* Vocabulary is Lucide, resolved through objectui's `LazyIcon`
* (`lib/lazy-icon.tsx` — kebab-case or PascalCase, normalised to
* kebab-case, with a fallback when the name is not a real Lucide icon), the
* same slot every other authorable icon on this surface uses. Contrast the
* item `key` prescribed against above: that spelling reaches no read point
* at all, and a read point is precisely what separates the two verdicts.
*/
icon: z.string().optional().describe(
'Lucide icon name rendered in the tab trigger, left of the label. Read on this component — the renderer draws it via `LazyIcon`; contrast the item `key` beside it, which no read point takes and which the alias table answers with `value`.',
),
/**
* Conditional tab (CEL, #2606): when the predicate evaluates FALSE the
* whole tab — header *and* panel — is omitted from the strip. This is the
* item-level complement to a child component's own `visibleWhen`, which
* hides only the panel content and would leave an empty tab header behind.
*
* **Contract-bound roots**: `record`, `current_user` (ADR-0068 aliases
* `user` / `ctx.user` — one object, three spellings; see the reasoning on
* `PageComponentSchema.visibleWhen`), plus page state as `page.<var>`
* (re-evaluated live).
*
* ⚠️ **This surface is NOT the same environment as page-component
* `visibleWhen`, despite sharing the key name.** It is rendered by its own
* evaluator, and that evaluator differs on two points — both renderer
* behaviour, NOT contract-guaranteed:
*
* * **`data` is the record ROW here**, where the component-node evaluator
* binds it to the data-source ADAPTER. Same key, two meanings.
* * **The row's bare fields are spread flat**, so `status` resolves as
* well as `record.status`. The ambient scope is spread AFTER the row,
* so an ambient root (`app`, `features`, `user`, …) wins over a record
* field of the same name.
*
* Like the component-node surface it also mounts the ambient `app` /
* `features` / `os.user` roots, which no ADR rules for a UI predicate
* (ADR-0068's Non-goals: "only the user object is in scope here").
*
* Measured at the `.objectui-sha` pin `190fbd01d061`:
* `components/src/renderers/layout/containers.tsx:450-457`.
*
* Canonical `*When` name per ADR-0089 — this key is new, so the deprecated
* `visibility` / `visibleOn` aliases are NOT ACCEPTED on tab items: unlike
* the view/page surfaces that fold them into `visibleWhen` via
* `normalizeVisibleWhen`, none of `visible` / `showWhen` / `visibility` /
* `visibleOn` parses here — all four are rejected. #8382 gave the
* rejection a pointer at this key for all four spellings (message only:
* being pointed AT `visibleWhen` is not the same as being accepted).
*/
visibleWhen: ExpressionInputSchema.optional().describe(
'Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user`), `page.<var>`. ⚠️ NOT the same environment as page-component `visibleWhen`: this surface\'s own evaluator binds `data` to the record ROW (not the data-source adapter) and also spreads the row\'s bare fields — renderer behaviour, NOT contract-guaranteed. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key.',
),
/**
* Stable URL token for this tab — the value `?tab=` carries and the
* renderer restores on reload. Omitted, the renderer derives `tab-<index>`,
* which silently points at a DIFFERENT tab as soon as the item list
* changes; that is why a durable link needs a semantic value here
* (`details`, `related:task`, …). Declared for #5776: the showcase authors
* this slot as `key`, which is neither spelling the renderer reads.
*/
value: z.string().optional().describe('Stable `?tab=` URL token for this tab (default: index-derived `tab-<i>`, which is not durable across item-list changes)'),
/**
* Badge count rendered next to the label. Omitted, the renderer derives it
* by probing the `record:related_list` descendants of this tab's children,
* so an explicit value is only needed when the count is not that sum.
*/
count: z.number().int().min(0).optional().describe('Badge count shown next to the tab label (default: derived from `record:related_list` descendants)'),
children: z.array(z.unknown()).describe('Child components')
})),
/** ARIA accessibility */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
});
export const PageCardProps = strictObject({
surface: 'this `page:card`',
history: PROPS_HISTORY,
guidanceSets: COMPONENT_LEVEL_GUIDANCE,
}, {
title: I18nLabelSchema.optional(),
bordered: z.boolean().default(true),
/**
* REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3829,
* route (c) — retire upstream).
*
* A card action list nothing has ever rendered. `PageCardRenderer`
* (`containers.tsx`) reads exactly four keys — `title`, `bordered`,
* `body ?? children`, `footer` — and returns a `<Card>` built from them;
* there is no actions area in the markup and no `actions` input in the
* registration, which is what put this key in objectui's
* `UNPUBLISHED_EXEMPTIONS` map as a B-class "spec declares it, NO renderer
* read point" entry. The card's sibling `page:header` DOES read `actions`
* off its bag, so the divergence was invisible to anyone reading the two
* declarations side by side.
*
* The live mechanism is composition: author the buttons as components in
* `children` or `footer` (`element:button`, `record:quick_actions`).
*/
actions: retiredKey(
'`page:card` property `actions` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — '
+ 'no renderer ever read it: objectui\'s card renderer builds its `<Card>` from `title`, '
+ '`bordered`, `children` and `footer` only, has no actions area, and the component registry '
+ 'never published it as an input, so an authored value was accepted and dropped. Delete the '
+ 'key and author the buttons as components in the card\'s `children` or `footer` '
+ '(`element:button`, `record:quick_actions`), which is what actually renders. '
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
* Card content, in order — the canonical composition slot, matching every
* other container (`grid`, `flex`, `page:section`, `page:tabs` items).
*
* This spelling was authored by the showcase and rendered by objectui long
* before it was declared (`schema.body ?? schema.children`, with the
* renderer's own comment saying authors expect `children` to work here); the
* declaration was `body` alone. #5775 converges the two on `children` rather
* than declaring both — one composition key, not two de-facto contracts
* (Prime Directive #12). `footer` is a genuinely distinct slot and stays.
*/
children: z.array(z.unknown()).optional().describe('Card content components, in order (the card body slot)'),
/**
* REMOVED (#5775). `body` was the declared spelling of the slot every other
* container calls `children`; the two are the same slot, and the renderer
* already reads both. The live mechanism is `children`.
*/
body: retiredKey(
'`page:card` property `body` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — '
+ 'it was a second spelling of the composition slot every other container calls `children`, '
+ 'and the renderer reads both. Rename the key to `children`; the value (an array of child '
+ 'components) is unchanged. '
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/** Slot for footer content */
footer: z.array(z.unknown()).optional().describe('Card footer components (slot)'),
/** ARIA accessibility */
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
});
/**
* ----------------------------------------------------------------------
* 2. Record Context Components
* ----------------------------------------------------------------------
*/
export const RecordDetailsProps = strictObject({
surface: 'this `record:details`',
history: PROPS_HISTORY,
guidanceSets: COMPONENT_LEVEL_GUIDANCE,
}, {
columns: z.enum(['1', '2', '3', '4']).default('2').describe('Number of columns for field layout (1-4)'),
/**
* REMOVED (#6946, maintainer ruling 2026-08-09 「全部接受」 on objectui#3818 —
* the removal direction).
*
* The declared `auto` | `custom` semantics were never implemented. objectui's
* `RecordDetailsRenderer` does read `layout`, but only to test it against
* `inline` | `compact` — two values this enum never permitted — so BOTH legal
* values fell to the same `vertical` branch and the key selected nothing.
* That is why it survived `check:react-declaration-parity`: objectui's
* registry declared `layout` with the same `auto` | `custom` enum this schema
* did, and the gate compares two DECLARATIONS, never a declaration against a
* renderer (AGENTS.md). A third spelling, `stacked` | `inline` | `compact`,
* sat in `@object-ui/types`' mirror — three declarations of one key, none of
* them the branch the renderer takes.
*
* The live mechanism is what you author: `sections` renders the explicit
* groups (the old `custom`), and omitting it falls back to the object's
* `highlightFields` (the old `auto`). objectui#3818 deletes the input and the
* dead branch on the next pin bump.
*/
layout: retiredKey(
'`record:details` property `layout` was removed in @objectstack/spec 17.0.0 (ADR-0087 D2) — '
+ 'its declared `auto` | `custom` semantics were never implemented: the renderer tests `layout` '
+ 'only against `inline` | `compact`, two values the schema never permitted, so both legal '
+ 'values took the same branch and the key selected nothing. Delete the key — the body is '
+ 'already chosen by what you author: `sections` renders the explicit groups (the old '
+ '`custom`), and omitting it falls back to the object\'s `highlightFields` (the old `auto`). '
+ 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.',
),
/**
* Field groups rendered as the detail body, IN ORDER.
*
* Declared as the object form because that is the only form anything
* delivers or authors (#5611). Until 17.x this key was `z.array(z.string())`
* — "section IDs" — which no page in this repo, and no read path in
* `objectui`, has ever used: `RecordDetailsRenderer` maps every entry as an
* object (`s.name` / `s.label` / `s.fields`) with no string branch anywhere,
* `@object-ui/types`' `RecordDetailsComponentProps` mirror declares the
* object form, and the Studio block designer can only author
* `{label, columns, fields}`. The ID-list spelling was a declaration with no
* producer and no consumer, so it is gone rather than unioned in: one shape,
* not two de-facto contracts (Prime Directive #12).
*/
sections: z.array(strictObject({
surface: 'this `record:details` section',
history: PROPS_HISTORY,
// Both are the author reaching for the #13855 reference form with the word
// the neighbouring surface uses: the object declares `fieldGroups`, and the
// field points back with `group`. Neither is a typo edit distance reaches.
aliases: {
fieldGroup: 'group',
groupKey: 'group',
},
}, {
/**
* Stable section identifier, snake_case. This is the i18n anchor: the
* heading resolves through `objects.<object>._sections.<name>.label`, so a
* section WITHOUT a name renders its authored `label` in every locale.
* `packages/lint`'s `translation-section-name-missing` rule exists to tell
* authors to add it, which is why it is declared here — a key one rule
* demands must not be a key the schema rejects.
*/
name: z.string().optional().describe('Stable section identifier for i18n lookup (snake_case) — resolves `objects.<object>._sections.<name>.label`; a nameless section renders its authored label in every locale'),
/** Heading text. Omit for an untitled section, which renders borderless. */
label: I18nLabelSchema.optional().describe('Section heading (omit for an untitled, borderless section)'),
/**
* Field-grid width for THIS section; falls back to the renderer's own
* derivation when omitted.
*
* An int range rather than `z.union([z.literal(1), …])` — same accepted set
* (1-4), but the docs generator renders numeric literals as QUOTED strings
* (`'1' | '2'`, see `FormSectionSchema.columns` in `references/ui/view.mdx`),
* which would tell an author to write `columns: '2'` where this key requires
* `2`. Shipping a reference that misdocuments the key is the exact harm
* #5611 is fixing, so the shape that documents itself truthfully wins.
*/
columns: z.number().int().min(1).max(4).optional().describe('Field-grid columns for this section (1-4). Omitted → the renderer derives the width.'),
/**
* [#13855] Reference a declared field GROUP instead of enumerating members
* — the delta form ruled 2026-08-31 (maintainer: 「直接处理b」).
*
* `{ group: 'contact_info' }` inherits the object's `fieldGroups` entry with
* that key: its members (every visible field whose `Field.group` points at
* it, in field-declaration order) and its own presentation (label, icon,
* description, `collapse`, `visibleWhen`, and the drop when the group has no
* visible members) all come from `deriveFieldGroupLayout` (ADR-0085 §5).
* The section restates none of it — see
* {@link sectionGroupReferenceRefinement} for the mixing rule and why the
* keys the group owns are refused here rather than given a precedence.
*
* Existence is NOT a parse question: the key names something on a DIFFERENT
* schema, so it follows the `UserFilterFieldSchema.field` precedent — parse
* takes any well-formed key and `page-section-group-unknown` (`@objectstack/lint`)
* reports one that resolves to no declared group.
*/
group: SectionGroupKeySchema.optional().describe(
'Field group key (snake_case) whose members and presentation this section inherits, from the object\'s `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `icon`, `description`, `collapsible`, `defaultCollapsed`). Must name a declared group — checked by reference diagnostics.',
),
/**
* Field names shown in this section, in order.
*
* Optional since #13855 — and optional ONLY in the sense that `group` is the
* other way to declare the same fact. A section carrying neither is refused
* (see {@link sectionGroupReferenceRefinement}), so no section reaches a
* renderer without a member source, which is what the previously-required
* key guaranteed.
*/
fields: z.array(z.string()).optional().describe('Field names rendered in this section, in order. Omit only when `group` supplies the members instead.'),
/**
* The three presentation keys the renderer has honoured all along,
* declared at last (#11289, maintainer ruling 2026-08-23 — direction 1:
* declare, defaults matching current renderer behavior; the renderer is
* unchanged). `RecordDetailsRenderer` spreads every authored section
* through to `DetailSection`, which reads all three — while this shape
* rejected them, so `objectstack validate` warned that an authored key
* "did nothing". For `hideEmpty` that warning hid the one key that decides
* whether a section EXISTS: the renderer forces `hideEmpty ?? true`, and
* an all-empty section then returns `null` outright — no heading, no
* skeleton — with no declarable way to ask for the skeleton back.
*
* All three are optional with NO schema default, for the `maxVisible`
* reason (see `inlineEdit` below): the fallbacks are the RENDERER'S, and
* a schema default would turn "the author said nothing" into "the author
* asked for the default". Defaults in the describe() texts are MEASURED
* at the `.objectui-sha` pin (objectui `plugin-detail/src/renderers/
* record-details.tsx` + `DetailSection.tsx`), not transcribed from a TS
* interface.
*/
hideEmpty: z.boolean().optional().describe('Hide this section\'s empty fields (renderer default: on — and a section whose fields are ALL empty then renders nothing at all: no heading, no skeleton). Set `false` to render empty rows, keeping the section\'s label skeleton on an all-empty record (e.g. a brand-new one).'),
/** Collapsible card. Initial state is expanded; the toggle is the heading. */
collapsible: z.boolean().optional().describe('Render this section as a collapsible card — the heading becomes a chevron toggle, initially expanded (renderer default: off).'),
/** Card chrome; the renderer derives it from the presence of a title. */
showBorder: z.boolean().optional().describe('Draw this section\'s card chrome (renderer default: derived — on for a titled section, off for an untitled one). Set `false` for a borderless titled section, or `true` for a bordered untitled one.'),
/**
* Three more section keys the renderer has honoured all along (#11661 —
* same defect class as #11289, inheriting its 2026-08-23 ruling WITH its
* reason: declare what the renderer honours; the renderer is unchanged).
* Optional with NO schema default, for the same `maxVisible` reason as the
* #11289 trio above; the defaults in the describe() texts are MEASURED at
* the `.objectui-sha` pin (`190fbd01`, objectui `plugin-detail/src/
* renderers/record-details.tsx` + `plugin-detail/src/DetailSection.tsx`).
*
* One key the same measurement found is deliberately NOT declared here
* (#11661 holds its fork):
* - `title` — the renderer's `s.title ?? s.label` limb is a second
* spelling of the heading slot `label` already declares (identical
* localization handling, zero producers). Same shape as the `page:card`
* `body`-vs-`children` pair, which #5775 CONVERGED rather than declared
* — one heading slot, not two de-facto contracts (Prime Directive #12).
* Held for the maintainer's declare-vs-converge ruling.
*
* `headerColor` used to be withheld alongside it (the renderer's only
* read was `bg-${headerColor}`, a template-literal Tailwind class that
* generates no CSS under the v4 source scan — declaring it would have
* advertised a capability the renderer did not deliver). objectui#6294
* (merged 2026-08-25) replaced the interpolation with a lookup of
* complete class literals in `plugin-detail/src/headerColor.ts`, so the
* renderer now delivers the key because the module declares it; the
* refusal outlived its recorded reason and #12126 (maintainer ruling A,
* 2026-08-26) declares the key below as a closed enum.
*/
defaultCollapsed: z.boolean().optional().describe('Start a `collapsible: true` section collapsed (renderer default: expanded). Consulted only when `collapsible` is on — a non-collapsible section never reads its collapse state.'),
icon: z.string().optional().describe('Heading icon, as a lucide icon name (kebab-case, e.g. `building-2`). A value that is not an ASCII identifier (emoji, CJK text) renders as literal text beside the heading instead. Shown where the section heading renders: a titled section, or any collapsible section.'),