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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .changeset/section-field-group-reference.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

feat(spec,lint): a layout section can reference a declared field group instead of copying its members (#13855)

Additive accept widening. Maintainer ruling 2026-08-31 (option B on #13855):
「直接处理b」.

ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled in
one place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape hatches
were whole-takeover shapes with no way back to it: a custom record page's
`record:details` `properties.sections` and a view-level `form.sections` each
enumerated their members by hand, so an author who reached for either had to
hand-copy the same membership fact a second and third time. Nothing linked the
copies to the declaration, so every field added to the object afterwards made
them quietly staler — measured on a real app as three disagreeing groupings of
one object, with the detail page missing two fields the form showed.

**A section may now name the group instead.** On both surfaces:

```ts
sections: [
{ group: 'contact_info' }, // members + presentation derived
{ label: 'Notes', fields: ['note'] }, // enumerated, unchanged
]
```

Members (every visible field whose `Field.group` points at the key, in
field-declaration order) and the group's own presentation (label, icon,
description, `collapse`, `visibleWhen`, and the drop when a group has no visible
members) all come from `deriveFieldGroupLayout`. Nothing is re-implemented in
section land.

**The mixing rule**, declared once for both surfaces and pinned:

- `group` and `fields` are mutually exclusive; a section declaring neither is
refused (before this change it was unrepresentable, because `fields` was
required).
- A group-referencing section carries no key the group already declares —
`name`, `label`, `icon`/`description`, the collapse pair, `visibleWhen` (and
its deprecated `visibleOn` spelling) are refused beside `group`, each with the
pointer to the `fieldGroups` entry that owns it. Not a precedence rule: the
absence of one. The surface keys the group says nothing about — `columns`,
`pane`, `hideEmpty`, `showBorder`, `headerColor` — ride alongside as usual.
- Across sections, both kinds coexist in declared array order; a
group-referencing section occupies one slot and expands in place.
- ⛔ Not on a wizard step: a group carries `visibleWhen` and `collapse`, and a
wizard step has no slot for either (the #13704 refusals, reached through the
object's declaration instead of the step's own keys).

**Existence is checked by reference diagnostics, not at parse.** The key names
something on a different schema, so the spec door takes any well-formed
snake_case key — the `UserFilterFieldSchema.field` precedent. `@objectstack/lint`
reports a dangling one as `page-section-group-unknown` (`record:details`) or
`form-section-group-unknown` (form views, both the canonical `sections` and the
legacy `groups` bucket), advisory like every other dangling-reference finding in
that family, with the object's declared groups listed in the hint.

The key grammar is now single-sourced as `FIELD_GROUP_KEY_PATTERN` beside the
derivation, so the declaring surface (`ObjectFieldGroupSchema.key`) and the two
referencing surfaces cannot drift into accepting different keys.

**Type-surface note for consumers.** `fields` becomes optional on both section
shapes (that is what makes `group` the other way to declare the same fact), so
`z.infer` now types it `… | undefined`. A consumer that reads `section.fields`
unconditionally must handle the reference form; every in-repo reader already
guards it. No authored metadata changes shape, and nothing that parsed before
stops parsing — `fields: []` included.

The renderer half (objectui) is tracked separately; until it lands, a
group-referencing section is declared and diagnosed but not yet rendered.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ nothing to do with elevation.
| Declaration | What it is | This page? |
|:---|:---|:---:|
| `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1588` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1595` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ |
| `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ |

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,7 +302,7 @@ const result = ApiMethod.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand DownExpand Up@@ -654,7 +654,7 @@ External datasource binding (ADR-0015)

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -753,7 +753,7 @@ Sort field and direction pair
| :--- | :--- | :--- | :--- |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) |
| **layout** | `never` | optional | [REMOVED] `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. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). |
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
| **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). |
Expand All@@ -767,7 +767,8 @@ Sort field and direction pair
| **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) — resolves `objects.<object>._sections.<name>.label`; a nameless section renders its authored label in every locale |
| **label** | `string \| Record<string, string>` | optional | Section heading (omit for an untitled, borderless section) |
| **columns** | `integer` | optional | Field-grid columns for this section (1-4). Omitted → the renderer derives the width. |
| **fields** | `string[]` | ✅ | Field names rendered in this section, in order |
| **group** | `string` | optional | 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. |
| **fields** | `string[]` | optional | Field names rendered in this section, in order. Omit only when `group` supplies the members instead. |
| **hideEmpty** | `boolean` | optional | 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** | `boolean` | optional | Render this section as a collapsible card — the heading becomes a chevron toggle, initially expanded (renderer default: off). |
| **showBorder** | `boolean` | optional | 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. |
Expand Down
9 changes: 6 additions & 3 deletions content/docs/references/ui/view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -336,7 +336,8 @@ View filter rule
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormSection.fields[number]`

Expand DownExpand Up@@ -467,7 +468,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.groups[number]`

Expand All@@ -482,7 +484,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.subforms[number]`

Expand Down
13 changes: 13 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -206,6 +206,7 @@ export {
validateFormLayout,
FORM_FIELD_UNKNOWN,
FORM_COLSPAN_ABSOLUTE,
FORM_SECTION_GROUP_UNKNOWN,
} from './validate-form-layout.js';
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';

Expand DownExpand Up@@ -429,9 +430,21 @@ export {
validatePageFieldBindings,
PAGE_FIELD_UNKNOWN,
PAGE_FIELD_UNPROVISIONED,
PAGE_SECTION_GROUP_UNKNOWN,
} from './validate-page-field-bindings.js';
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';

// [#13855] The shared field-group reference half both layout surfaces resolve
// against — exported so an out-of-repo consumer (cloud graph-lint, the AI
// authoring path) can ask the same question of the same index rather than
// rebuilding it from `objects[].fieldGroups` by hand.
export {
indexObjectFieldGroups,
sectionGroupRefs,
checkSectionGroupRefs,
} from './object-field-groups.js';
export type { SectionGroupRef, SectionGroupFinding } from './object-field-groups.js';

export {
validateComponentProps,
COMPONENT_PROPS_UNKNOWN_KEY,
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .changeset/section-field-group-reference.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

feat(spec,lint): a layout section can reference a declared field group instead of copying its members (#13855)

Additive accept widening. Maintainer ruling 2026-08-31 (option B on #13855):
「直接处理b」.

ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled in
one place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape hatches
were whole-takeover shapes with no way back to it: a custom record page's
`record:details` `properties.sections` and a view-level `form.sections` each
enumerated their members by hand, so an author who reached for either had to
hand-copy the same membership fact a second and third time. Nothing linked the
copies to the declaration, so every field added to the object afterwards made
them quietly staler — measured on a real app as three disagreeing groupings of
one object, with the detail page missing two fields the form showed.

**A section may now name the group instead.** On both surfaces:

```ts
sections: [
{ group: 'contact_info' }, // members + presentation derived
{ label: 'Notes', fields: ['note'] }, // enumerated, unchanged
]
```

Members (every visible field whose `Field.group` points at the key, in
field-declaration order) and the group's own presentation (label, icon,
description, `collapse`, `visibleWhen`, and the drop when a group has no visible
members) all come from `deriveFieldGroupLayout`. Nothing is re-implemented in
section land.

**The mixing rule**, declared once for both surfaces and pinned:

- `group` and `fields` are mutually exclusive; a section declaring neither is
refused (before this change it was unrepresentable, because `fields` was
required).
- A group-referencing section carries no key the group already declares —
`name`, `label`, `icon`/`description`, the collapse pair, `visibleWhen` (and
its deprecated `visibleOn` spelling) are refused beside `group`, each with the
pointer to the `fieldGroups` entry that owns it. Not a precedence rule: the
absence of one. The surface keys the group says nothing about — `columns`,
`pane`, `hideEmpty`, `showBorder`, `headerColor` — ride alongside as usual.
- Across sections, both kinds coexist in declared array order; a
group-referencing section occupies one slot and expands in place.
- ⛔ Not on a wizard step: a group carries `visibleWhen` and `collapse`, and a
wizard step has no slot for either (the #13704 refusals, reached through the
object's declaration instead of the step's own keys).

**Existence is checked by reference diagnostics, not at parse.** The key names
something on a different schema, so the spec door takes any well-formed
snake_case key — the `UserFilterFieldSchema.field` precedent. `@objectstack/lint`
reports a dangling one as `page-section-group-unknown` (`record:details`) or
`form-section-group-unknown` (form views, both the canonical `sections` and the
legacy `groups` bucket), advisory like every other dangling-reference finding in
that family, with the object's declared groups listed in the hint.

The key grammar is now single-sourced as `FIELD_GROUP_KEY_PATTERN` beside the
derivation, so the declaring surface (`ObjectFieldGroupSchema.key`) and the two
referencing surfaces cannot drift into accepting different keys.

**Type-surface note for consumers.** `fields` becomes optional on both section
shapes (that is what makes `group` the other way to declare the same fact), so
`z.infer` now types it `… | undefined`. A consumer that reads `section.fields`
unconditionally must handle the reference form; every in-repo reader already
guards it. No authored metadata changes shape, and nothing that parsed before
stops parsing — `fields: []` included.

The renderer half (objectui) is tracked separately; until it lands, a
group-referencing section is declared and diagnosed but not yet rendered.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ nothing to do with elevation.
| Declaration | What it is | This page? |
|:---|:---|:---:|
| `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1588` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1595` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ |
| `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ |

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,7 +302,7 @@ const result = ApiMethod.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand DownExpand Up@@ -654,7 +654,7 @@ External datasource binding (ADR-0015)

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -753,7 +753,7 @@ Sort field and direction pair
| :--- | :--- | :--- | :--- |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) |
| **layout** | `never` | optional | [REMOVED] `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. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). |
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
| **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). |
Expand All@@ -767,7 +767,8 @@ Sort field and direction pair
| **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) — resolves `objects.<object>._sections.<name>.label`; a nameless section renders its authored label in every locale |
| **label** | `string \| Record<string, string>` | optional | Section heading (omit for an untitled, borderless section) |
| **columns** | `integer` | optional | Field-grid columns for this section (1-4). Omitted → the renderer derives the width. |
| **fields** | `string[]` | ✅ | Field names rendered in this section, in order |
| **group** | `string` | optional | 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. |
| **fields** | `string[]` | optional | Field names rendered in this section, in order. Omit only when `group` supplies the members instead. |
| **hideEmpty** | `boolean` | optional | 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** | `boolean` | optional | Render this section as a collapsible card — the heading becomes a chevron toggle, initially expanded (renderer default: off). |
| **showBorder** | `boolean` | optional | 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. |
Expand Down
9 changes: 6 additions & 3 deletions content/docs/references/ui/view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -336,7 +336,8 @@ View filter rule
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormSection.fields[number]`

Expand DownExpand Up@@ -467,7 +468,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.groups[number]`

Expand All@@ -482,7 +484,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.subforms[number]`

Expand Down
13 changes: 13 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -206,6 +206,7 @@ export {
validateFormLayout,
FORM_FIELD_UNKNOWN,
FORM_COLSPAN_ABSOLUTE,
FORM_SECTION_GROUP_UNKNOWN,
} from './validate-form-layout.js';
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';

Expand DownExpand Up@@ -429,9 +430,21 @@ export {
validatePageFieldBindings,
PAGE_FIELD_UNKNOWN,
PAGE_FIELD_UNPROVISIONED,
PAGE_SECTION_GROUP_UNKNOWN,
} from './validate-page-field-bindings.js';
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';

// [#13855] The shared field-group reference half both layout surfaces resolve
// against — exported so an out-of-repo consumer (cloud graph-lint, the AI
// authoring path) can ask the same question of the same index rather than
// rebuilding it from `objects[].fieldGroups` by hand.
export {
indexObjectFieldGroups,
sectionGroupRefs,
checkSectionGroupRefs,
} from './object-field-groups.js';
export type { SectionGroupRef, SectionGroupFinding } from './object-field-groups.js';

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .changeset/section-field-group-reference.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

feat(spec,lint): a layout section can reference a declared field group instead of copying its members (#13855)

Additive accept widening. Maintainer ruling 2026-08-31 (option B on #13855):
「直接处理b」.

ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled in
one place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape hatches
were whole-takeover shapes with no way back to it: a custom record page's
`record:details` `properties.sections` and a view-level `form.sections` each
enumerated their members by hand, so an author who reached for either had to
hand-copy the same membership fact a second and third time. Nothing linked the
copies to the declaration, so every field added to the object afterwards made
them quietly staler — measured on a real app as three disagreeing groupings of
one object, with the detail page missing two fields the form showed.

**A section may now name the group instead.** On both surfaces:

```ts
sections: [
{ group: 'contact_info' }, // members + presentation derived
{ label: 'Notes', fields: ['note'] }, // enumerated, unchanged
]
```

Members (every visible field whose `Field.group` points at the key, in
field-declaration order) and the group's own presentation (label, icon,
description, `collapse`, `visibleWhen`, and the drop when a group has no visible
members) all come from `deriveFieldGroupLayout`. Nothing is re-implemented in
section land.

**The mixing rule**, declared once for both surfaces and pinned:

- `group` and `fields` are mutually exclusive; a section declaring neither is
refused (before this change it was unrepresentable, because `fields` was
required).
- A group-referencing section carries no key the group already declares —
`name`, `label`, `icon`/`description`, the collapse pair, `visibleWhen` (and
its deprecated `visibleOn` spelling) are refused beside `group`, each with the
pointer to the `fieldGroups` entry that owns it. Not a precedence rule: the
absence of one. The surface keys the group says nothing about — `columns`,
`pane`, `hideEmpty`, `showBorder`, `headerColor` — ride alongside as usual.
- Across sections, both kinds coexist in declared array order; a
group-referencing section occupies one slot and expands in place.
- ⛔ Not on a wizard step: a group carries `visibleWhen` and `collapse`, and a
wizard step has no slot for either (the #13704 refusals, reached through the
object's declaration instead of the step's own keys).

**Existence is checked by reference diagnostics, not at parse.** The key names
something on a different schema, so the spec door takes any well-formed
snake_case key — the `UserFilterFieldSchema.field` precedent. `@objectstack/lint`
reports a dangling one as `page-section-group-unknown` (`record:details`) or
`form-section-group-unknown` (form views, both the canonical `sections` and the
legacy `groups` bucket), advisory like every other dangling-reference finding in
that family, with the object's declared groups listed in the hint.

The key grammar is now single-sourced as `FIELD_GROUP_KEY_PATTERN` beside the
derivation, so the declaring surface (`ObjectFieldGroupSchema.key`) and the two
referencing surfaces cannot drift into accepting different keys.

**Type-surface note for consumers.** `fields` becomes optional on both section
shapes (that is what makes `group` the other way to declare the same fact), so
`z.infer` now types it `… | undefined`. A consumer that reads `section.fields`
unconditionally must handle the reference form; every in-repo reader already
guards it. No authored metadata changes shape, and nothing that parsed before
stops parsing — `fields: []` included.

The renderer half (objectui) is tracked separately; until it lands, a
group-referencing section is declared and diagnosed but not yet rendered.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ nothing to do with elevation.
| Declaration | What it is | This page? |
|:---|:---|:---:|
| `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1588` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1595` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ |
| `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ |

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,7 +302,7 @@ const result = ApiMethod.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand DownExpand Up@@ -654,7 +654,7 @@ External datasource binding (ADR-0015)

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -753,7 +753,7 @@ Sort field and direction pair
| :--- | :--- | :--- | :--- |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) |
| **layout** | `never` | optional | [REMOVED] `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. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). |
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
| **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). |
Expand All@@ -767,7 +767,8 @@ Sort field and direction pair
| **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) — resolves `objects.<object>._sections.<name>.label`; a nameless section renders its authored label in every locale |
| **label** | `string \| Record<string, string>` | optional | Section heading (omit for an untitled, borderless section) |
| **columns** | `integer` | optional | Field-grid columns for this section (1-4). Omitted → the renderer derives the width. |
| **fields** | `string[]` | ✅ | Field names rendered in this section, in order |
| **group** | `string` | optional | 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. |
| **fields** | `string[]` | optional | Field names rendered in this section, in order. Omit only when `group` supplies the members instead. |
| **hideEmpty** | `boolean` | optional | 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** | `boolean` | optional | Render this section as a collapsible card — the heading becomes a chevron toggle, initially expanded (renderer default: off). |
| **showBorder** | `boolean` | optional | 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. |
Expand Down
9 changes: 6 additions & 3 deletions content/docs/references/ui/view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -336,7 +336,8 @@ View filter rule
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormSection.fields[number]`

Expand DownExpand Up@@ -467,7 +468,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.groups[number]`

Expand All@@ -482,7 +484,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.subforms[number]`

Expand Down
13 changes: 13 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -206,6 +206,7 @@ export {
validateFormLayout,
FORM_FIELD_UNKNOWN,
FORM_COLSPAN_ABSOLUTE,
FORM_SECTION_GROUP_UNKNOWN,
} from './validate-form-layout.js';
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';

Expand DownExpand Up@@ -429,9 +430,21 @@ export {
validatePageFieldBindings,
PAGE_FIELD_UNKNOWN,
PAGE_FIELD_UNPROVISIONED,
PAGE_SECTION_GROUP_UNKNOWN,
} from './validate-page-field-bindings.js';
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';

// [#13855] The shared field-group reference half both layout surfaces resolve
// against — exported so an out-of-repo consumer (cloud graph-lint, the AI
// authoring path) can ask the same question of the same index rather than
// rebuilding it from `objects[].fieldGroups` by hand.
export {
indexObjectFieldGroups,
sectionGroupRefs,
checkSectionGroupRefs,
} from './object-field-groups.js';
export type { SectionGroupRef, SectionGroupFinding } from './object-field-groups.js';

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .changeset/section-field-group-reference.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

feat(spec,lint): a layout section can reference a declared field group instead of copying its members (#13855)

Additive accept widening. Maintainer ruling 2026-08-31 (option B on #13855):
「直接处理b」.

ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled in
one place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape hatches
were whole-takeover shapes with no way back to it: a custom record page's
`record:details` `properties.sections` and a view-level `form.sections` each
enumerated their members by hand, so an author who reached for either had to
hand-copy the same membership fact a second and third time. Nothing linked the
copies to the declaration, so every field added to the object afterwards made
them quietly staler — measured on a real app as three disagreeing groupings of
one object, with the detail page missing two fields the form showed.

**A section may now name the group instead.** On both surfaces:

```ts
sections: [
{ group: 'contact_info' }, // members + presentation derived
{ label: 'Notes', fields: ['note'] }, // enumerated, unchanged
]
```

Members (every visible field whose `Field.group` points at the key, in
field-declaration order) and the group's own presentation (label, icon,
description, `collapse`, `visibleWhen`, and the drop when a group has no visible
members) all come from `deriveFieldGroupLayout`. Nothing is re-implemented in
section land.

**The mixing rule**, declared once for both surfaces and pinned:

- `group` and `fields` are mutually exclusive; a section declaring neither is
refused (before this change it was unrepresentable, because `fields` was
required).
- A group-referencing section carries no key the group already declares —
`name`, `label`, `icon`/`description`, the collapse pair, `visibleWhen` (and
its deprecated `visibleOn` spelling) are refused beside `group`, each with the
pointer to the `fieldGroups` entry that owns it. Not a precedence rule: the
absence of one. The surface keys the group says nothing about — `columns`,
`pane`, `hideEmpty`, `showBorder`, `headerColor` — ride alongside as usual.
- Across sections, both kinds coexist in declared array order; a
group-referencing section occupies one slot and expands in place.
- ⛔ Not on a wizard step: a group carries `visibleWhen` and `collapse`, and a
wizard step has no slot for either (the #13704 refusals, reached through the
object's declaration instead of the step's own keys).

**Existence is checked by reference diagnostics, not at parse.** The key names
something on a different schema, so the spec door takes any well-formed
snake_case key — the `UserFilterFieldSchema.field` precedent. `@objectstack/lint`
reports a dangling one as `page-section-group-unknown` (`record:details`) or
`form-section-group-unknown` (form views, both the canonical `sections` and the
legacy `groups` bucket), advisory like every other dangling-reference finding in
that family, with the object's declared groups listed in the hint.

The key grammar is now single-sourced as `FIELD_GROUP_KEY_PATTERN` beside the
derivation, so the declaring surface (`ObjectFieldGroupSchema.key`) and the two
referencing surfaces cannot drift into accepting different keys.

**Type-surface note for consumers.** `fields` becomes optional on both section
shapes (that is what makes `group` the other way to declare the same fact), so
`z.infer` now types it `… | undefined`. A consumer that reads `section.fields`
unconditionally must handle the reference form; every in-repo reader already
guards it. No authored metadata changes shape, and nothing that parsed before
stops parsing — `fields: []` included.

The renderer half (objectui) is tracked separately; until it lands, a
group-referencing section is declared and diagnosed but not yet rendered.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ nothing to do with elevation.
| Declaration | What it is | This page? |
|:---|:---|:---:|
| `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1588` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1595` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ |
| `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ |

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,7 +302,7 @@ const result = ApiMethod.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand DownExpand Up@@ -654,7 +654,7 @@ External datasource binding (ADR-0015)

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -753,7 +753,7 @@ Sort field and direction pair
| :--- | :--- | :--- | :--- |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) |
| **layout** | `never` | optional | [REMOVED] `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. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). |
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
| **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). |
Expand All@@ -767,7 +767,8 @@ Sort field and direction pair
| **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) — resolves `objects.<object>._sections.<name>.label`; a nameless section renders its authored label in every locale |
| **label** | `string \| Record<string, string>` | optional | Section heading (omit for an untitled, borderless section) |
| **columns** | `integer` | optional | Field-grid columns for this section (1-4). Omitted → the renderer derives the width. |
| **fields** | `string[]` | ✅ | Field names rendered in this section, in order |
| **group** | `string` | optional | 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. |
| **fields** | `string[]` | optional | Field names rendered in this section, in order. Omit only when `group` supplies the members instead. |
| **hideEmpty** | `boolean` | optional | 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** | `boolean` | optional | Render this section as a collapsible card — the heading becomes a chevron toggle, initially expanded (renderer default: off). |
| **showBorder** | `boolean` | optional | 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. |
Expand Down
9 changes: 6 additions & 3 deletions content/docs/references/ui/view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -336,7 +336,8 @@ View filter rule
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormSection.fields[number]`

Expand DownExpand Up@@ -467,7 +468,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.groups[number]`

Expand All@@ -482,7 +484,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.subforms[number]`

Expand Down
13 changes: 13 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -206,6 +206,7 @@ export {
validateFormLayout,
FORM_FIELD_UNKNOWN,
FORM_COLSPAN_ABSOLUTE,
FORM_SECTION_GROUP_UNKNOWN,
} from './validate-form-layout.js';
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';

Expand DownExpand Up@@ -429,9 +430,21 @@ export {
validatePageFieldBindings,
PAGE_FIELD_UNKNOWN,
PAGE_FIELD_UNPROVISIONED,
PAGE_SECTION_GROUP_UNKNOWN,
} from './validate-page-field-bindings.js';
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';

// [#13855] The shared field-group reference half both layout surfaces resolve
// against — exported so an out-of-repo consumer (cloud graph-lint, the AI
// authoring path) can ask the same question of the same index rather than
// rebuilding it from `objects[].fieldGroups` by hand.
export {
indexObjectFieldGroups,
sectionGroupRefs,
checkSectionGroupRefs,
} from './object-field-groups.js';
export type { SectionGroupRef, SectionGroupFinding } from './object-field-groups.js';

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .changeset/section-field-group-reference.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

feat(spec,lint): a layout section can reference a declared field group instead of copying its members (#13855)

Additive accept widening. Maintainer ruling 2026-08-31 (option B on #13855):
「直接处理b」.

ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled in
one place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape hatches
were whole-takeover shapes with no way back to it: a custom record page's
`record:details` `properties.sections` and a view-level `form.sections` each
enumerated their members by hand, so an author who reached for either had to
hand-copy the same membership fact a second and third time. Nothing linked the
copies to the declaration, so every field added to the object afterwards made
them quietly staler — measured on a real app as three disagreeing groupings of
one object, with the detail page missing two fields the form showed.

**A section may now name the group instead.** On both surfaces:

```ts
sections: [
{ group: 'contact_info' }, // members + presentation derived
{ label: 'Notes', fields: ['note'] }, // enumerated, unchanged
]
```

Members (every visible field whose `Field.group` points at the key, in
field-declaration order) and the group's own presentation (label, icon,
description, `collapse`, `visibleWhen`, and the drop when a group has no visible
members) all come from `deriveFieldGroupLayout`. Nothing is re-implemented in
section land.

**The mixing rule**, declared once for both surfaces and pinned:

- `group` and `fields` are mutually exclusive; a section declaring neither is
refused (before this change it was unrepresentable, because `fields` was
required).
- A group-referencing section carries no key the group already declares —
`name`, `label`, `icon`/`description`, the collapse pair, `visibleWhen` (and
its deprecated `visibleOn` spelling) are refused beside `group`, each with the
pointer to the `fieldGroups` entry that owns it. Not a precedence rule: the
absence of one. The surface keys the group says nothing about — `columns`,
`pane`, `hideEmpty`, `showBorder`, `headerColor` — ride alongside as usual.
- Across sections, both kinds coexist in declared array order; a
group-referencing section occupies one slot and expands in place.
- ⛔ Not on a wizard step: a group carries `visibleWhen` and `collapse`, and a
wizard step has no slot for either (the #13704 refusals, reached through the
object's declaration instead of the step's own keys).

**Existence is checked by reference diagnostics, not at parse.** The key names
something on a different schema, so the spec door takes any well-formed
snake_case key — the `UserFilterFieldSchema.field` precedent. `@objectstack/lint`
reports a dangling one as `page-section-group-unknown` (`record:details`) or
`form-section-group-unknown` (form views, both the canonical `sections` and the
legacy `groups` bucket), advisory like every other dangling-reference finding in
that family, with the object's declared groups listed in the hint.

The key grammar is now single-sourced as `FIELD_GROUP_KEY_PATTERN` beside the
derivation, so the declaring surface (`ObjectFieldGroupSchema.key`) and the two
referencing surfaces cannot drift into accepting different keys.

**Type-surface note for consumers.** `fields` becomes optional on both section
shapes (that is what makes `group` the other way to declare the same fact), so
`z.infer` now types it `… | undefined`. A consumer that reads `section.fields`
unconditionally must handle the reference form; every in-repo reader already
guards it. No authored metadata changes shape, and nothing that parsed before
stops parsing — `fields: []` included.

The renderer half (objectui) is tracked separately; until it lands, a
group-referencing section is declared and diagnosed but not yet rendered.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ nothing to do with elevation.
| Declaration | What it is | This page? |
|:---|:---|:---:|
| `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1588` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1595` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ |
| `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ |

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,7 +302,7 @@ const result = ApiMethod.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand DownExpand Up@@ -654,7 +654,7 @@ External datasource binding (ADR-0015)

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -753,7 +753,7 @@ Sort field and direction pair
| :--- | :--- | :--- | :--- |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) |
| **layout** | `never` | optional | [REMOVED] `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. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). |
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
| **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). |
Expand All@@ -767,7 +767,8 @@ Sort field and direction pair
| **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) — resolves `objects.<object>._sections.<name>.label`; a nameless section renders its authored label in every locale |
| **label** | `string \| Record<string, string>` | optional | Section heading (omit for an untitled, borderless section) |
| **columns** | `integer` | optional | Field-grid columns for this section (1-4). Omitted → the renderer derives the width. |
| **fields** | `string[]` | ✅ | Field names rendered in this section, in order |
| **group** | `string` | optional | 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. |
| **fields** | `string[]` | optional | Field names rendered in this section, in order. Omit only when `group` supplies the members instead. |
| **hideEmpty** | `boolean` | optional | 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** | `boolean` | optional | Render this section as a collapsible card — the heading becomes a chevron toggle, initially expanded (renderer default: off). |
| **showBorder** | `boolean` | optional | 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. |
Expand Down
9 changes: 6 additions & 3 deletions content/docs/references/ui/view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -336,7 +336,8 @@ View filter rule
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormSection.fields[number]`

Expand DownExpand Up@@ -467,7 +468,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.groups[number]`

Expand All@@ -482,7 +484,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.subforms[number]`

Expand Down
13 changes: 13 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -206,6 +206,7 @@ export {
validateFormLayout,
FORM_FIELD_UNKNOWN,
FORM_COLSPAN_ABSOLUTE,
FORM_SECTION_GROUP_UNKNOWN,
} from './validate-form-layout.js';
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';

Expand DownExpand Up@@ -429,9 +430,21 @@ export {
validatePageFieldBindings,
PAGE_FIELD_UNKNOWN,
PAGE_FIELD_UNPROVISIONED,
PAGE_SECTION_GROUP_UNKNOWN,
} from './validate-page-field-bindings.js';
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';

// [#13855] The shared field-group reference half both layout surfaces resolve
// against — exported so an out-of-repo consumer (cloud graph-lint, the AI
// authoring path) can ask the same question of the same index rather than
// rebuilding it from `objects[].fieldGroups` by hand.
export {
indexObjectFieldGroups,
sectionGroupRefs,
checkSectionGroupRefs,
} from './object-field-groups.js';
export type { SectionGroupRef, SectionGroupFinding } from './object-field-groups.js';

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .changeset/section-field-group-reference.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

feat(spec,lint): a layout section can reference a declared field group instead of copying its members (#13855)

Additive accept widening. Maintainer ruling 2026-08-31 (option B on #13855):
「直接处理b」.

ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled in
one place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape hatches
were whole-takeover shapes with no way back to it: a custom record page's
`record:details` `properties.sections` and a view-level `form.sections` each
enumerated their members by hand, so an author who reached for either had to
hand-copy the same membership fact a second and third time. Nothing linked the
copies to the declaration, so every field added to the object afterwards made
them quietly staler — measured on a real app as three disagreeing groupings of
one object, with the detail page missing two fields the form showed.

**A section may now name the group instead.** On both surfaces:

```ts
sections: [
{ group: 'contact_info' }, // members + presentation derived
{ label: 'Notes', fields: ['note'] }, // enumerated, unchanged
]
```

Members (every visible field whose `Field.group` points at the key, in
field-declaration order) and the group's own presentation (label, icon,
description, `collapse`, `visibleWhen`, and the drop when a group has no visible
members) all come from `deriveFieldGroupLayout`. Nothing is re-implemented in
section land.

**The mixing rule**, declared once for both surfaces and pinned:

- `group` and `fields` are mutually exclusive; a section declaring neither is
refused (before this change it was unrepresentable, because `fields` was
required).
- A group-referencing section carries no key the group already declares —
`name`, `label`, `icon`/`description`, the collapse pair, `visibleWhen` (and
its deprecated `visibleOn` spelling) are refused beside `group`, each with the
pointer to the `fieldGroups` entry that owns it. Not a precedence rule: the
absence of one. The surface keys the group says nothing about — `columns`,
`pane`, `hideEmpty`, `showBorder`, `headerColor` — ride alongside as usual.
- Across sections, both kinds coexist in declared array order; a
group-referencing section occupies one slot and expands in place.
- ⛔ Not on a wizard step: a group carries `visibleWhen` and `collapse`, and a
wizard step has no slot for either (the #13704 refusals, reached through the
object's declaration instead of the step's own keys).

**Existence is checked by reference diagnostics, not at parse.** The key names
something on a different schema, so the spec door takes any well-formed
snake_case key — the `UserFilterFieldSchema.field` precedent. `@objectstack/lint`
reports a dangling one as `page-section-group-unknown` (`record:details`) or
`form-section-group-unknown` (form views, both the canonical `sections` and the
legacy `groups` bucket), advisory like every other dangling-reference finding in
that family, with the object's declared groups listed in the hint.

The key grammar is now single-sourced as `FIELD_GROUP_KEY_PATTERN` beside the
derivation, so the declaring surface (`ObjectFieldGroupSchema.key`) and the two
referencing surfaces cannot drift into accepting different keys.

**Type-surface note for consumers.** `fields` becomes optional on both section
shapes (that is what makes `group` the other way to declare the same fact), so
`z.infer` now types it `… | undefined`. A consumer that reads `section.fields`
unconditionally must handle the reference form; every in-repo reader already
guards it. No authored metadata changes shape, and nothing that parsed before
stops parsing — `fields: []` included.

The renderer half (objectui) is tracked separately; until it lands, a
group-referencing section is declared and diagnosed but not yet rendered.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ nothing to do with elevation.
| Declaration | What it is | This page? |
|:---|:---|:---:|
| `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1588` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1595` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ |
| `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ |

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,7 +302,7 @@ const result = ApiMethod.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand DownExpand Up@@ -654,7 +654,7 @@ External datasource binding (ADR-0015)

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -753,7 +753,7 @@ Sort field and direction pair
| :--- | :--- | :--- | :--- |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) |
| **layout** | `never` | optional | [REMOVED] `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. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). |
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
| **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). |
Expand All@@ -767,7 +767,8 @@ Sort field and direction pair
| **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) — resolves `objects.<object>._sections.<name>.label`; a nameless section renders its authored label in every locale |
| **label** | `string \| Record<string, string>` | optional | Section heading (omit for an untitled, borderless section) |
| **columns** | `integer` | optional | Field-grid columns for this section (1-4). Omitted → the renderer derives the width. |
| **fields** | `string[]` | ✅ | Field names rendered in this section, in order |
| **group** | `string` | optional | 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. |
| **fields** | `string[]` | optional | Field names rendered in this section, in order. Omit only when `group` supplies the members instead. |
| **hideEmpty** | `boolean` | optional | 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** | `boolean` | optional | Render this section as a collapsible card — the heading becomes a chevron toggle, initially expanded (renderer default: off). |
| **showBorder** | `boolean` | optional | 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. |
Expand Down
9 changes: 6 additions & 3 deletions content/docs/references/ui/view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -336,7 +336,8 @@ View filter rule
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormSection.fields[number]`

Expand DownExpand Up@@ -467,7 +468,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.groups[number]`

Expand All@@ -482,7 +484,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.subforms[number]`

Expand Down
13 changes: 13 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -206,6 +206,7 @@ export {
validateFormLayout,
FORM_FIELD_UNKNOWN,
FORM_COLSPAN_ABSOLUTE,
FORM_SECTION_GROUP_UNKNOWN,
} from './validate-form-layout.js';
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';

Expand DownExpand Up@@ -429,9 +430,21 @@ export {
validatePageFieldBindings,
PAGE_FIELD_UNKNOWN,
PAGE_FIELD_UNPROVISIONED,
PAGE_SECTION_GROUP_UNKNOWN,
} from './validate-page-field-bindings.js';
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';

// [#13855] The shared field-group reference half both layout surfaces resolve
// against — exported so an out-of-repo consumer (cloud graph-lint, the AI
// authoring path) can ask the same question of the same index rather than
// rebuilding it from `objects[].fieldGroups` by hand.
export {
indexObjectFieldGroups,
sectionGroupRefs,
checkSectionGroupRefs,
} from './object-field-groups.js';
export type { SectionGroupRef, SectionGroupFinding } from './object-field-groups.js';

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .changeset/section-field-group-reference.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

feat(spec,lint): a layout section can reference a declared field group instead of copying its members (#13855)

Additive accept widening. Maintainer ruling 2026-08-31 (option B on #13855):
「直接处理b」.

ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled in
one place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape hatches
were whole-takeover shapes with no way back to it: a custom record page's
`record:details` `properties.sections` and a view-level `form.sections` each
enumerated their members by hand, so an author who reached for either had to
hand-copy the same membership fact a second and third time. Nothing linked the
copies to the declaration, so every field added to the object afterwards made
them quietly staler — measured on a real app as three disagreeing groupings of
one object, with the detail page missing two fields the form showed.

**A section may now name the group instead.** On both surfaces:

```ts
sections: [
{ group: 'contact_info' }, // members + presentation derived
{ label: 'Notes', fields: ['note'] }, // enumerated, unchanged
]
```

Members (every visible field whose `Field.group` points at the key, in
field-declaration order) and the group's own presentation (label, icon,
description, `collapse`, `visibleWhen`, and the drop when a group has no visible
members) all come from `deriveFieldGroupLayout`. Nothing is re-implemented in
section land.

**The mixing rule**, declared once for both surfaces and pinned:

- `group` and `fields` are mutually exclusive; a section declaring neither is
refused (before this change it was unrepresentable, because `fields` was
required).
- A group-referencing section carries no key the group already declares —
`name`, `label`, `icon`/`description`, the collapse pair, `visibleWhen` (and
its deprecated `visibleOn` spelling) are refused beside `group`, each with the
pointer to the `fieldGroups` entry that owns it. Not a precedence rule: the
absence of one. The surface keys the group says nothing about — `columns`,
`pane`, `hideEmpty`, `showBorder`, `headerColor` — ride alongside as usual.
- Across sections, both kinds coexist in declared array order; a
group-referencing section occupies one slot and expands in place.
- ⛔ Not on a wizard step: a group carries `visibleWhen` and `collapse`, and a
wizard step has no slot for either (the #13704 refusals, reached through the
object's declaration instead of the step's own keys).

**Existence is checked by reference diagnostics, not at parse.** The key names
something on a different schema, so the spec door takes any well-formed
snake_case key — the `UserFilterFieldSchema.field` precedent. `@objectstack/lint`
reports a dangling one as `page-section-group-unknown` (`record:details`) or
`form-section-group-unknown` (form views, both the canonical `sections` and the
legacy `groups` bucket), advisory like every other dangling-reference finding in
that family, with the object's declared groups listed in the hint.

The key grammar is now single-sourced as `FIELD_GROUP_KEY_PATTERN` beside the
derivation, so the declaring surface (`ObjectFieldGroupSchema.key`) and the two
referencing surfaces cannot drift into accepting different keys.

**Type-surface note for consumers.** `fields` becomes optional on both section
shapes (that is what makes `group` the other way to declare the same fact), so
`z.infer` now types it `… | undefined`. A consumer that reads `section.fields`
unconditionally must handle the reference form; every in-repo reader already
guards it. No authored metadata changes shape, and nothing that parsed before
stops parsing — `fields: []` included.

The renderer half (objectui) is tracked separately; until it lands, a
group-referencing section is declared and diagnosed but not yet rendered.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ nothing to do with elevation.
| Declaration | What it is | This page? |
|:---|:---|:---:|
| `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1588` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1595` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ |
| `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ |

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,7 +302,7 @@ const result = ApiMethod.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand DownExpand Up@@ -654,7 +654,7 @@ External datasource binding (ADR-0015)

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -753,7 +753,7 @@ Sort field and direction pair
| :--- | :--- | :--- | :--- |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) |
| **layout** | `never` | optional | [REMOVED] `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. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). |
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
| **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). |
Expand All@@ -767,7 +767,8 @@ Sort field and direction pair
| **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) — resolves `objects.<object>._sections.<name>.label`; a nameless section renders its authored label in every locale |
| **label** | `string \| Record<string, string>` | optional | Section heading (omit for an untitled, borderless section) |
| **columns** | `integer` | optional | Field-grid columns for this section (1-4). Omitted → the renderer derives the width. |
| **fields** | `string[]` | ✅ | Field names rendered in this section, in order |
| **group** | `string` | optional | 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. |
| **fields** | `string[]` | optional | Field names rendered in this section, in order. Omit only when `group` supplies the members instead. |
| **hideEmpty** | `boolean` | optional | 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** | `boolean` | optional | Render this section as a collapsible card — the heading becomes a chevron toggle, initially expanded (renderer default: off). |
| **showBorder** | `boolean` | optional | 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. |
Expand Down
9 changes: 6 additions & 3 deletions content/docs/references/ui/view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -336,7 +336,8 @@ View filter rule
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormSection.fields[number]`

Expand DownExpand Up@@ -467,7 +468,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.groups[number]`

Expand All@@ -482,7 +484,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.subforms[number]`

Expand Down
13 changes: 13 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -206,6 +206,7 @@ export {
validateFormLayout,
FORM_FIELD_UNKNOWN,
FORM_COLSPAN_ABSOLUTE,
FORM_SECTION_GROUP_UNKNOWN,
} from './validate-form-layout.js';
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';

Expand DownExpand Up@@ -429,9 +430,21 @@ export {
validatePageFieldBindings,
PAGE_FIELD_UNKNOWN,
PAGE_FIELD_UNPROVISIONED,
PAGE_SECTION_GROUP_UNKNOWN,
} from './validate-page-field-bindings.js';
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';

// [#13855] The shared field-group reference half both layout surfaces resolve
// against — exported so an out-of-repo consumer (cloud graph-lint, the AI
// authoring path) can ask the same question of the same index rather than
// rebuilding it from `objects[].fieldGroups` by hand.
export {
indexObjectFieldGroups,
sectionGroupRefs,
checkSectionGroupRefs,
} from './object-field-groups.js';
export type { SectionGroupRef, SectionGroupFinding } from './object-field-groups.js';

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .changeset/section-field-group-reference.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
---
"@objectstack/spec": minor
"@objectstack/lint": minor
---

feat(spec,lint): a layout section can reference a declared field group instead of copying its members (#13855)

Additive accept widening. Maintainer ruling 2026-08-31 (option B on #13855):
「直接处理b」.

ADR-0085 makes `fieldGroups` + `Field.group` the canonical grouping, assembled in
one place — `deriveFieldGroupLayout` (ADR-0085 §5). The two layout escape hatches
were whole-takeover shapes with no way back to it: a custom record page's
`record:details` `properties.sections` and a view-level `form.sections` each
enumerated their members by hand, so an author who reached for either had to
hand-copy the same membership fact a second and third time. Nothing linked the
copies to the declaration, so every field added to the object afterwards made
them quietly staler — measured on a real app as three disagreeing groupings of
one object, with the detail page missing two fields the form showed.

**A section may now name the group instead.** On both surfaces:

```ts
sections: [
{ group: 'contact_info' }, // members + presentation derived
{ label: 'Notes', fields: ['note'] }, // enumerated, unchanged
]
```

Members (every visible field whose `Field.group` points at the key, in
field-declaration order) and the group's own presentation (label, icon,
description, `collapse`, `visibleWhen`, and the drop when a group has no visible
members) all come from `deriveFieldGroupLayout`. Nothing is re-implemented in
section land.

**The mixing rule**, declared once for both surfaces and pinned:

- `group` and `fields` are mutually exclusive; a section declaring neither is
refused (before this change it was unrepresentable, because `fields` was
required).
- A group-referencing section carries no key the group already declares —
`name`, `label`, `icon`/`description`, the collapse pair, `visibleWhen` (and
its deprecated `visibleOn` spelling) are refused beside `group`, each with the
pointer to the `fieldGroups` entry that owns it. Not a precedence rule: the
absence of one. The surface keys the group says nothing about — `columns`,
`pane`, `hideEmpty`, `showBorder`, `headerColor` — ride alongside as usual.
- Across sections, both kinds coexist in declared array order; a
group-referencing section occupies one slot and expands in place.
- ⛔ Not on a wizard step: a group carries `visibleWhen` and `collapse`, and a
wizard step has no slot for either (the #13704 refusals, reached through the
object's declaration instead of the step's own keys).

**Existence is checked by reference diagnostics, not at parse.** The key names
something on a different schema, so the spec door takes any well-formed
snake_case key — the `UserFilterFieldSchema.field` precedent. `@objectstack/lint`
reports a dangling one as `page-section-group-unknown` (`record:details`) or
`form-section-group-unknown` (form views, both the canonical `sections` and the
legacy `groups` bucket), advisory like every other dangling-reference finding in
that family, with the object's declared groups listed in the hint.

The key grammar is now single-sourced as `FIELD_GROUP_KEY_PATTERN` beside the
derivation, so the declaring surface (`ObjectFieldGroupSchema.key`) and the two
referencing surfaces cannot drift into accepting different keys.

**Type-surface note for consumers.** `fields` becomes optional on both section
shapes (that is what makes `group` the other way to declare the same fact), so
`z.infer` now types it `… | undefined`. A consumer that reads `section.fields`
unconditionally must handle the reference form; every in-repo reader already
guards it. No authored metadata changes shape, and nothing that parsed before
stops parsing — `fields: []` included.

The renderer half (objectui) is tracked separately; until it lands, a
group-referencing section is declared and diagnosed but not yet rendered.
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,7 +47,7 @@ nothing to do with elevation.
| Declaration | What it is | This page? |
|:---|:---|:---:|
| `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts:269` | The elevation flag on an operation's context | ✅ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1588` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `Object.isSystem` — `packages/spec/src/data/object.zod.ts:1595` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ |
| `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts:125` | Built-in template; tenants may override but should not delete | ❌ |
| `Environment.isSystem` — `packages/spec/src/cloud/environment.zod.ts:137` | Platform-infrastructure environment, not user data | ❌ |

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/data/object.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -302,7 +302,7 @@ const result = ApiMethod.parse(data);

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand DownExpand Up@@ -654,7 +654,7 @@ External datasource binding (ADR-0015)

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group. |
| **key** | `string` | ✅ | Group machine key (snake_case). Referenced by Field.group, and by a layout section's `group`. |
| **label** | `string` | ✅ | Group display label |
| **icon** | `string` | optional | Icon name (Lucide/Material) for the group header |
| **description** | `string` | optional | Optional description shown under the group header |
Expand Down
5 changes: 3 additions & 2 deletions content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -753,7 +753,7 @@ Sort field and direction pair
| :--- | :--- | :--- | :--- |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'>` | optional (default: `"2"`) | Number of columns for field layout (1-4) |
| **layout** | `never` | optional | [REMOVED] `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. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; fields: string[]; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }`. |
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; group?: string; … }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields, hideEmpty?, collapsible?, showBorder?, defaultCollapsed?, icon?, description?, headerColor? }` — or the group-reference form `{ group, columns?, hideEmpty?, showBorder?, headerColor? }`, which inherits members and presentation from the object's `fieldGroups` entry (ADR-0085 §5). |
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
| **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). |
Expand All@@ -767,7 +767,8 @@ Sort field and direction pair
| **name** | `string` | optional | Stable section identifier for i18n lookup (snake_case) — resolves `objects.<object>._sections.<name>.label`; a nameless section renders its authored label in every locale |
| **label** | `string \| Record<string, string>` | optional | Section heading (omit for an untitled, borderless section) |
| **columns** | `integer` | optional | Field-grid columns for this section (1-4). Omitted → the renderer derives the width. |
| **fields** | `string[]` | ✅ | Field names rendered in this section, in order |
| **group** | `string` | optional | 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. |
| **fields** | `string[]` | optional | Field names rendered in this section, in order. Omit only when `group` supplies the members instead. |
| **hideEmpty** | `boolean` | optional | 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** | `boolean` | optional | Render this section as a collapsible card — the heading becomes a chevron toggle, initially expanded (renderer default: off). |
| **showBorder** | `boolean` | optional | 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. |
Expand Down
9 changes: 6 additions & 3 deletions content/docs/references/ui/view.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -336,7 +336,8 @@ View filter rule
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormSection.fields[number]`

Expand DownExpand Up@@ -467,7 +468,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.groups[number]`

Expand All@@ -482,7 +484,8 @@ Form-view select option — the object-field option shape minus the per-option `
| **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. |
| **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| 1 \| 2 \| 3 \| 4` | optional (default: `1`) | |
| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | ✅ | |
| **group** | `string` | optional | Field group key (snake_case) whose members and presentation this section inherits, from the bound object's `fieldGroups` (ADR-0085 §5 `deriveFieldGroupLayout`). Mutually exclusive with `fields`, and with every key the group itself declares (`name`, `label`, `description`, `collapsible`, `collapsed`, `visibleWhen`/`visibleOn`). Not valid on a wizard step. Must name a declared group — checked by reference diagnostics. |
| **fields** | `(string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| …>; options?: object[]; reference?: string; … })[]` | optional | |

### Nested Shape: `FormView.subforms[number]`

Expand Down
13 changes: 13 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -206,6 +206,7 @@ export {
validateFormLayout,
FORM_FIELD_UNKNOWN,
FORM_COLSPAN_ABSOLUTE,
FORM_SECTION_GROUP_UNKNOWN,
} from './validate-form-layout.js';
export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js';

Expand DownExpand Up@@ -429,9 +430,21 @@ export {
validatePageFieldBindings,
PAGE_FIELD_UNKNOWN,
PAGE_FIELD_UNPROVISIONED,
PAGE_SECTION_GROUP_UNKNOWN,
} from './validate-page-field-bindings.js';
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';

// [#13855] The shared field-group reference half both layout surfaces resolve
// against — exported so an out-of-repo consumer (cloud graph-lint, the AI
// authoring path) can ask the same question of the same index rather than
// rebuilding it from `objects[].fieldGroups` by hand.
export {
indexObjectFieldGroups,
sectionGroupRefs,
checkSectionGroupRefs,
} from './object-field-groups.js';
export type { SectionGroupRef, SectionGroupFinding } from './object-field-groups.js';

export {
validateComponentProps,
COMPONENT_PROPS_UNKNOWN_KEY,
Expand Down
Loading
Loading