From f98572221ceb5a1e1a5a389a4308e17e17ea23b4 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sat, 4 Jul 2026 22:27:04 +0800 Subject: [PATCH 1/3] feat(spec,lint): adaptive record surface + semantic field span (#2578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 (framework) of #2578 — field-heavy objects, AI-authored: - deriveRecordSurface(def, opts): pure spec derivation of the default record surface (page vs drawer/modal) from authorable (visible, non-system) field count; mobile forces page. A DERIVATION, not an authored key — a `recordSurface` object key fails ADR-0085 §2's admission test (field count is machine-inferable, modal-vs-page is pure re-arrangement). - FormField.span: 'auto' | 'full' — relative field width decoupled from the per-surface derived column count (mobile 1 / modal 2 / page 3-4); replaces absolute colSpan as the primary primitive (colSpan retained + clamped by the renderer). `half` deferred. - validateFormLayout lint: form-field-unknown + absolute-colspan-discouraged (both warnings with fix hints, ADR-0078/0019, same bar for AI + hand authors). - ADR-0085 §2 rejected-keys clarification (recordSurface); changeset. Additive only (0 breaking). spec 6677 tests + api-surface green; lint 121 tests + tsc green. Co-Authored-By: Claude Opus 4.8 --- .../adaptive-record-surface-span-2578.md | 40 +++++ ...semantic-roles-over-surface-hint-blocks.md | 2 +- packages/lint/src/index.ts | 7 + .../lint/src/validate-form-layout.test.ts | 114 +++++++++++++ packages/lint/src/validate-form-layout.ts | 160 ++++++++++++++++++ packages/spec/api-surface.json | 6 + packages/spec/src/data/index.ts | 4 + packages/spec/src/data/record-surface.test.ts | 71 ++++++++ packages/spec/src/data/record-surface.ts | 93 ++++++++++ packages/spec/src/ui/view.zod.ts | 9 +- 10 files changed, 503 insertions(+), 3 deletions(-) create mode 100644 .changeset/adaptive-record-surface-span-2578.md create mode 100644 packages/lint/src/validate-form-layout.test.ts create mode 100644 packages/lint/src/validate-form-layout.ts create mode 100644 packages/spec/src/data/record-surface.test.ts create mode 100644 packages/spec/src/data/record-surface.ts diff --git a/.changeset/adaptive-record-surface-span-2578.md b/.changeset/adaptive-record-surface-span-2578.md new file mode 100644 index 0000000000..514e7bd841 --- /dev/null +++ b/.changeset/adaptive-record-surface-span-2578.md @@ -0,0 +1,40 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +--- + +feat(spec,lint): adaptive record surface + semantic field `span` for field-heavy objects (#2578) + +Field-heavy objects need two things the protocol did not express well: multi-column +forms, and opening create/edit/detail as a full page rather than a cramped popup — +for *some* objects, automatically. Because all metadata is AI-authored, the design +goal is to make AI unable to get it wrong, which reshaped both features away from +new authored keys. + +**`deriveRecordSurface` (new spec derivation, ADR-0085 §5).** A record's default +surface — full `page` vs `drawer`/`modal` overlay — is *derived* from how heavy the +record is (visible, non-system field count; mobile always pages), not authored. Per +ADR-0085 §2's admission test a `recordSurface` object key would fail: field count is +exactly the kind of fact a machine can infer, and modal-vs-page is pure +re-arrangement, not a business fact. So there is **no new object key** and **no new +ADR** — just a single shared derivation renderers consume as a default (an explicit +form/navigation config still wins), plus a one-line clarification to ADR-0085 §2's +rejected-keys list so `recordSurface` is not re-proposed. Explicit per-object control +remains the sanctioned assigned-page path. + +**`FormField.span: 'auto' | 'full'` (new, replaces absolute `colSpan` as the +primary primitive).** Under a per-surface derived column count (mobile 1 / modal 2 / +page 3-4) an absolute `colSpan: 3` only lines up at the one width the author +imagined — fragile by construction. The relative `span` is decoupled from the column +count: `auto` (default; omit it) sizes by widget type × current columns, `full` takes +the whole row at any count. `colSpan` is retained for back-compat and clamped by the +renderer; `half` was considered and deferred (weakest AI-safety). The rationale lives +here rather than in a new ADR, per the fewer-ADRs convention. + +**`validateFormLayout` (new lint, ADR-0078/0019).** Two advisory rules over authored +form views: `form-field-unknown` (a section references a field not on the bound +object — silently never renders) and `absolute-colspan-discouraged` (steers authors +to `span: 'full'`). Both warnings, with fix hints, held to the same bar for AI and +hand authors. + +All additive: no exports removed, no behavior change for existing metadata. diff --git a/docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md b/docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md index 9cad059f16..e21efa3565 100644 --- a/docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md +++ b/docs/adr/0085-object-semantic-roles-over-surface-hint-blocks.md @@ -65,7 +65,7 @@ A new presentation-intent key enters `ObjectSchema` only if **both** hold: 1. it injects business intent **not inferable** from the schema (the machine cannot know whether `status` is ordinal, or which four fields this business watches); 2. it binds to the object **across surfaces** — if the honest name for the key contains a page name, it fails. -Keys that merely hide/re-arrange correct information (`hideRelatedTab`, `showReferenceRail`, `relatedLayout`, `useFieldGroups`, explicit `detail.sections`) are rejected at this layer. The supported path for per-page control is the one that already exists and is honestly scoped: an **assigned page** (full page schema). If a genuine relationship-level need appears (e.g. "this child object is noise on every parent"), it gets modeled at the relationship layer, not as a page toggle. +Keys that merely hide/re-arrange correct information (`hideRelatedTab`, `showReferenceRail`, `relatedLayout`, `useFieldGroups`, explicit `detail.sections`, or a **modal-vs-page presentation surface** such as a `recordSurface` key) are rejected at this layer. The supported path for per-page control is the one that already exists and is honestly scoped: an **assigned page** (full page schema). A record's *default* surface (full page vs. drawer/modal overlay) is not authored at all: it is **derived** from how heavy the record is — `deriveRecordSurface` (#2578) — because field count is exactly the kind of fact a machine can infer, so a `recordSurface` key fails admission test #1. If a genuine relationship-level need appears (e.g. "this child object is noise on every parent"), it gets modeled at the relationship layer, not as a page toggle. ### 3. Deletions (all evidenced zero-author) diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 14edb10f91..aef1216e83 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -61,3 +61,10 @@ export { SEMANTIC_ROLE_FIELD_UNKNOWN, } from './validate-semantic-roles.js'; export type { SemanticRoleFinding, SemanticRoleSeverity } from './validate-semantic-roles.js'; + +export { + validateFormLayout, + FORM_FIELD_UNKNOWN, + FORM_COLSPAN_ABSOLUTE, +} from './validate-form-layout.js'; +export type { FormLayoutFinding, FormLayoutSeverity } from './validate-form-layout.js'; diff --git a/packages/lint/src/validate-form-layout.test.ts b/packages/lint/src/validate-form-layout.test.ts new file mode 100644 index 0000000000..e0fcb1b5d1 --- /dev/null +++ b/packages/lint/src/validate-form-layout.test.ts @@ -0,0 +1,114 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateFormLayout, + FORM_FIELD_UNKNOWN, + FORM_COLSPAN_ABSOLUTE, +} from './validate-form-layout'; + +const objects = [ + { name: 'contract', fields: { name: {}, amount: {}, status: {}, notes: {} } }, +]; + +describe('validateFormLayout (#2578)', () => { + it('is clean for a well-formed multi-column form (known fields, no colSpan)', () => { + const stack = { + objects, + views: [ + { + name: 'contract_form', + type: 'simple', + data: { provider: 'object', object: 'contract' }, + sections: [ + { label: 'Basics', columns: 2, fields: ['name', 'amount', { field: 'notes', span: 'full' }] }, + ], + }, + ], + }; + expect(validateFormLayout(stack)).toEqual([]); + }); + + it('flags a section field that is not on the bound object', () => { + const stack = { + objects, + views: [ + { + name: 'contract_form', + data: { provider: 'object', object: 'contract' }, + sections: [{ columns: 2, fields: ['name', 'ghost_field'] }], + }, + ], + }; + const findings = validateFormLayout(stack); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(FORM_FIELD_UNKNOWN); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].message).toContain('ghost_field'); + expect(findings[0].path).toBe('views[0].sections[0].fields[1]'); + }); + + it('discourages absolute colSpan and steers to span', () => { + const stack = { + objects, + views: [ + { + name: 'contract_form', + data: { provider: 'object', object: 'contract' }, + sections: [{ columns: 2, fields: ['name', { field: 'amount', colSpan: 2 }] }], + }, + ], + }; + const findings = validateFormLayout(stack); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(FORM_COLSPAN_ABSOLUTE); + expect(findings[0].hint).toContain("span: 'full'"); + expect(findings[0].path).toBe('views[0].sections[0].fields[1].colSpan'); + }); + + it('reports both rules for the same field independently', () => { + const stack = { + objects, + views: [ + { + name: 'contract_form', + data: { provider: 'object', object: 'contract' }, + sections: [{ columns: 2, fields: [{ field: 'ghost', colSpan: 3 }] }], + }, + ], + }; + const rules = validateFormLayout(stack).map(f => f.rule).sort(); + expect(rules).toEqual([FORM_COLSPAN_ABSOLUTE, FORM_FIELD_UNKNOWN].sort()); + }); + + it('skips reference-checking when the bound object cannot be resolved', () => { + const stack = { + objects, + views: [ + { + name: 'orphan_form', + data: { provider: 'object', object: 'does_not_exist' }, + sections: [{ columns: 2, fields: ['whatever', { field: 'x', colSpan: 2 }] }], + }, + ], + }; + const findings = validateFormLayout(stack); + // No form-field-unknown (object unresolved), but colSpan is still flagged. + expect(findings.map(f => f.rule)).toEqual([FORM_COLSPAN_ABSOLUTE]); + }); + + it('ignores non-form views (no sections array)', () => { + const stack = { + objects, + views: [ + { name: 'grid', type: 'grid', data: { object: 'contract' }, columns: ['name', 'ghost'] }, + ], + }; + expect(validateFormLayout(stack)).toEqual([]); + }); + + it('tolerates an empty / shapeless stack', () => { + expect(validateFormLayout({})).toEqual([]); + expect(validateFormLayout({ views: [], objects: [] })).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-form-layout.ts b/packages/lint/src/validate-form-layout.ts new file mode 100644 index 0000000000..f009b43597 --- /dev/null +++ b/packages/lint/src/validate-form-layout.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Build-time form-layout diagnostics (#2578). + * + * Authored form views carry field references and column-layout hints that are + * Zod-valid but can be silently wrong at render time — the "parsed, unmarked, + * silently inert" shape ADR-0078 prohibits. This lint catches the two that + * matter for multi-column, AI-authored forms, uniformly for `os build` / + * `os validate`, MCP authoring and hand authors (ADR-0019). + * + * Both rules are warnings, not errors — nothing is fully broken (an unknown + * field name is skipped; an over-wide colSpan is clamped) — but each is almost + * certainly an authoring mistake worth surfacing at author time: + * + * - `form-field-unknown` — a section references a field that is not on the + * form's bound object, so the field silently does not render. + * - `absolute-colspan-discouraged` — a field uses the absolute `colSpan`. Under + * a per-surface DERIVED column count (mobile 1 / modal 2 / page 3-4) a fixed + * span only lines up at the one width the author imagined; the renderer + * clamps it. The robust primitive is the relative `span: 'full'`. + * + * Scope: top-level form `views` (a `sections` array). Forms embedded inside + * page component trees are a follow-up — the walker deliberately stays shallow + * so it never guesses at an arbitrary component's object binding. + */ + +export const FORM_FIELD_UNKNOWN = 'form-field-unknown'; +export const FORM_COLSPAN_ABSOLUTE = 'absolute-colspan-discouraged'; + +export type FormLayoutSeverity = 'error' | 'warning'; + +export interface FormLayoutFinding { + /** Always `warning` today — both rules are advisory (see module note). */ + severity: FormLayoutSeverity; + /** Diagnostic rule id, e.g. `form-field-unknown`. */ + rule: string; + /** Human-readable location, e.g. `view "contract_form"`. */ + where: string; + /** Config path, e.g. `views[2].sections[0].fields[3]`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +/** Coerce a collection (array or name-keyed map) to an array of records. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +/** A section field entry is either a bare field name or `{ field, colSpan, … }`. */ +function fieldNameOf(entry: unknown): string | null { + if (typeof entry === 'string') return entry.length > 0 ? entry : null; + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + const f = (entry as AnyRec).field; + return typeof f === 'string' && f.length > 0 ? f : null; + } + return null; +} + +/** The object a form view binds to: `data.object` (canonical) or `objectName`. */ +function boundObject(view: AnyRec): string | undefined { + const data = view.data; + if (data && typeof data === 'object' && typeof (data as AnyRec).object === 'string') { + return (data as AnyRec).object as string; + } + return typeof view.objectName === 'string' ? (view.objectName as string) : undefined; +} + +/** + * Validate authored form-view layout. Returns findings (empty = clean). + * Advisory only — the caller must never fail the build on these alone. + */ +export function validateFormLayout(stack: AnyRec): FormLayoutFinding[] { + const findings: FormLayoutFinding[] = []; + + // object name → its field-name set, for reference checking. + const objectFields = new Map>(); + for (const obj of asArray(stack.objects)) { + const name = typeof obj.name === 'string' ? obj.name : undefined; + if (!name) continue; + const fields = (obj.fields && typeof obj.fields === 'object' && !Array.isArray(obj.fields)) + ? Object.keys(obj.fields as AnyRec) + : []; + objectFields.set(name, new Set(fields)); + } + + const views = asArray(stack.views); + for (let i = 0; i < views.length; i++) { + const view = views[i]; + if (!view || typeof view !== 'object') continue; + const sections = Array.isArray(view.sections) ? view.sections : null; + if (!sections) continue; // only form views carry a sections array + + const viewName = typeof view.name === 'string' ? view.name : `(view ${i})`; + const objName = boundObject(view); + // Only reference-check when the bound object resolves; otherwise we can't. + const known = objName ? objectFields.get(objName) : undefined; + const where = `view "${viewName}"`; + const base = `views[${i}]`; + + for (let s = 0; s < sections.length; s++) { + const sec = sections[s]; + const secFields = sec && typeof sec === 'object' && Array.isArray((sec as AnyRec).fields) + ? ((sec as AnyRec).fields as unknown[]) + : []; + for (let f = 0; f < secFields.length; f++) { + const entry = secFields[f]; + const fname = fieldNameOf(entry); + const fpath = `${base}.sections[${s}].fields[${f}]`; + + // ── (a) section field references a real field on the bound object ── + if (fname && known && !known.has(fname)) { + findings.push({ + severity: 'warning', + rule: FORM_FIELD_UNKNOWN, + where, + path: fpath, + message: + `${viewName}: field "${fname}" is not a field on object "${objName}" — ` + + `it is silently skipped and never renders on the form`, + hint: + `Fix the field name, or add "${fname}" to ${objName}. Section field ` + + `references must match the object's field names exactly.`, + }); + } + + // ── (b) absolute colSpan → steer to the surface-independent span ── + const colSpan = entry && typeof entry === 'object' && !Array.isArray(entry) + ? (entry as AnyRec).colSpan + : undefined; + if (colSpan != null) { + findings.push({ + severity: 'warning', + rule: FORM_COLSPAN_ABSOLUTE, + where, + path: `${fpath}.colSpan`, + message: + `${viewName}: field "${fname ?? '?'}" sets absolute colSpan ${String(colSpan)} — ` + + `the form's column count is derived per surface (mobile 1 / modal 2 / page 3-4), ` + + `so a fixed span only aligns at one width`, + hint: + `Prefer span: 'full' (whole row at any column count), or omit for auto ` + + `width. The renderer clamps colSpan to the current column count.`, + }); + } + } + } + } + + return findings; +} diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index c4bcefc8f6..de297ad342 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -363,11 +363,15 @@ "QueryFilterSchema (const)", "QueryInput (type)", "QuerySchema (const)", + "RECORD_SURFACE_PAGE_THRESHOLD (const)", "RangeOperatorSchema (const)", "Reaction (type)", "ReactionSchema (const)", "RecordSubscription (type)", "RecordSubscriptionSchema (const)", + "RecordSurface (type)", + "RecordSurfaceOptions (interface)", + "RecordSurfaceViewport (type)", "ReferenceResolution (type)", "ReferenceResolutionError (type)", "ReferenceResolutionErrorSchema (const)", @@ -450,6 +454,7 @@ "WindowSpec (type)", "WindowSpecSchema (const)", "canonicalizeSqlType (function)", + "countAuthorableFields (function)", "defaultAggregateFor (function)", "defineCube (function)", "defineDatasource (function)", @@ -457,6 +462,7 @@ "defineObjectExtension (function)", "defineSeed (function)", "deriveFieldGroupLayout (function)", + "deriveRecordSurface (function)", "fieldForm (const)", "hasDynamicTokens (function)", "hookForm (const)", diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index 6ec2c6e1ad..77bd95d165 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -53,6 +53,10 @@ export * from './display-name'; // grouping semantics every renderer (form, detail, drawer, designer) applies. export * from './field-group-layout'; +// record-surface derivation (ADR-0085 §5) — the single source for how a record's +// create/edit/detail opens by default (full page vs drawer/modal overlay). +export * from './record-surface'; + // Feed & Activity Protocol export * from './feed.zod'; diff --git a/packages/spec/src/data/record-surface.test.ts b/packages/spec/src/data/record-surface.test.ts new file mode 100644 index 0000000000..9e985a38ad --- /dev/null +++ b/packages/spec/src/data/record-surface.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + deriveRecordSurface, + countAuthorableFields, + RECORD_SURFACE_PAGE_THRESHOLD, +} from './record-surface'; + +/** Build an object def with `n` plain text fields named f0..f(n-1). */ +function objWithFields(n: number, extra: Record = {}) { + const fields: Record = {}; + for (let i = 0; i < n; i++) fields[`f${i}`] = { type: 'text', label: `F${i}` }; + return { name: 'thing', fields: { ...fields, ...extra } }; +} + +describe('deriveRecordSurface (ADR-0085 §5)', () => { + it('opens a light object as a drawer (below threshold)', () => { + expect(deriveRecordSurface(objWithFields(3))).toBe('drawer'); + expect(deriveRecordSurface(objWithFields(RECORD_SURFACE_PAGE_THRESHOLD - 1))).toBe('drawer'); + }); + + it('opens a field-heavy object as a full page (at/above threshold)', () => { + expect(deriveRecordSurface(objWithFields(RECORD_SURFACE_PAGE_THRESHOLD))).toBe('page'); + expect(deriveRecordSurface(objWithFields(60))).toBe('page'); + }); + + it('forces a full page on mobile regardless of field count', () => { + expect(deriveRecordSurface(objWithFields(1), { viewport: 'mobile' })).toBe('page'); + expect(deriveRecordSurface(objWithFields(60), { viewport: 'mobile' })).toBe('page'); + }); + + it('honours an explicit pageThreshold override', () => { + expect(deriveRecordSurface(objWithFields(5), { pageThreshold: 4 })).toBe('page'); + expect(deriveRecordSurface(objWithFields(5), { pageThreshold: 20 })).toBe('drawer'); + }); + + it('does not count hidden or audit/system fields toward "heavy"', () => { + const def = objWithFields(RECORD_SURFACE_PAGE_THRESHOLD - 1, { + created_at: { type: 'datetime' }, + updated_at: { type: 'datetime' }, + organization_id: { type: 'text' }, + secret: { type: 'text', hidden: true }, + }); + // Still below threshold: the 4 extra fields are all system/hidden. + expect(deriveRecordSurface(def)).toBe('drawer'); + }); + + it('tolerates bare / malformed input', () => { + expect(deriveRecordSurface(null)).toBe('drawer'); + expect(deriveRecordSurface(undefined)).toBe('drawer'); + expect(deriveRecordSurface({})).toBe('drawer'); + expect(deriveRecordSurface({ fields: 'nope' } as unknown)).toBe('drawer'); + }); +}); + +describe('countAuthorableFields', () => { + it('counts visible non-system fields only', () => { + expect(countAuthorableFields(objWithFields(5))).toBe(5); + const def = objWithFields(2, { + created_by: { type: 'text' }, + hidden_one: { type: 'text', hidden: true }, + }); + expect(countAuthorableFields(def)).toBe(2); + }); + + it('returns 0 for bare/malformed input', () => { + expect(countAuthorableFields(null)).toBe(0); + expect(countAuthorableFields({})).toBe(0); + }); +}); diff --git a/packages/spec/src/data/record-surface.ts b/packages/spec/src/data/record-surface.ts new file mode 100644 index 0000000000..dcb789fe92 --- /dev/null +++ b/packages/spec/src/data/record-surface.ts @@ -0,0 +1,93 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Record-surface derivation — the single source for *how* an object's + * create / edit / detail record opens by DEFAULT: a full page, or an overlay + * (drawer / modal). ADR-0085 §5 "one shared derivation, every surface". + * + * Why this is a derivation and NOT an authored object key + * ------------------------------------------------------ + * ADR-0085 §2's admission test rejects per-surface presentation toggles on the + * object: modal-vs-page is not a business fact a machine cannot infer — it is + * exactly inferable from how heavy the record is. So the platform derives a + * sensible default here (field-heavy → full page; otherwise a side drawer), + * and AI authors write nothing in the common case. Explicit control, when a + * specific object genuinely needs it, is the sanctioned per-page path: an + * assigned page. The renderer therefore uses this ONLY as the default and lets + * an explicit form/navigation config win. + * + * The rule is deliberately simple and predictable (a single field-count + * threshold + a mobile override) because an unpredictable heuristic is its own + * kind of "silently wrong" for an AI-authored system. + * + * Pure and dependency-free, tolerant of bare/un-parsed metadata records (same + * contract as {@link deriveFieldGroupLayout}) so every consumer can call it. + */ + +import { FIELD_GROUP_SYSTEM_FIELDS } from './field-group-layout'; + +/** The presentation surface a record's create/edit/detail opens on. */ +export type RecordSurface = 'page' | 'modal' | 'drawer'; + +/** Viewport hint; overlays are cramped on phones, so mobile always pages. */ +export type RecordSurfaceViewport = 'mobile' | 'desktop'; + +export interface RecordSurfaceOptions { + /** Viewport hint. `'mobile'` forces `'page'` (overlays are cramped). */ + viewport?: RecordSurfaceViewport; + /** + * Authorable-field count at/above which the record opens as a full page. + * Defaults to {@link RECORD_SURFACE_PAGE_THRESHOLD}. Exposed so a host can + * tune the break point without forking the rule. + */ + pageThreshold?: number; +} + +/** + * Default break point: at/above this many authorable (visible, non-system) + * fields, a record is "heavy" enough to warrant a full page rather than an + * overlay. ~12 fields overflow a comfortable two-column modal; beyond it a + * page (with its own URL, scroll and back button) reads better. + */ +export const RECORD_SURFACE_PAGE_THRESHOLD = 12; + +type AnyRec = Record; + +/** + * Count the fields a user actually fills on a form: visible (not `hidden`) and + * not an audit/system field. Mirrors the visibility rules of the field-group + * layout so the two derivations agree on what "the fields" are. + */ +export function countAuthorableFields(def: unknown): number { + if (!def || typeof def !== 'object' || Array.isArray(def)) return 0; + const fields = (def as AnyRec).fields; + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) return 0; + let n = 0; + for (const [name, f] of Object.entries(fields as Record)) { + if (f?.hidden === true) continue; + if (FIELD_GROUP_SYSTEM_FIELDS.has(name)) continue; + n++; + } + return n; +} + +/** + * Derive the DEFAULT record surface for an object definition (or any bare + * metadata record shaped like one). + * + * Rule: + * - `viewport: 'mobile'` → `'page'` (overlays are cramped on phones); + * - authorable field count ≥ threshold → `'page'` (field-heavy); + * - otherwise → `'drawer'` (a light side overlay — the historical default). + * + * `'modal'` is never emitted by the heuristic; it remains in the return type + * because an explicit form config can still select it and consumers switch on + * the full set. Renderers must treat this as a default only — an explicit + * `formType` / `navigation.mode` wins. + */ +export function deriveRecordSurface(def: unknown, opts: RecordSurfaceOptions = {}): RecordSurface { + if (opts.viewport === 'mobile') return 'page'; + const threshold = opts.pageThreshold ?? RECORD_SURFACE_PAGE_THRESHOLD; + if (countAuthorableFields(def) >= threshold) return 'page'; + return 'drawer'; +} diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 9c32d61df8..29492ff191 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -697,8 +697,13 @@ export const FormFieldSchema: z.ZodType = lazySchema(() => z.object({ immutable: z.boolean().optional().describe('Editable on create, locked once the record exists (e.g. machine names).'), required: z.boolean().optional().describe('Required override'), hidden: z.boolean().optional().describe('Hidden override'), - colSpan: z.number().int().min(1).max(4).optional().describe('Column span in grid layout (1-4)'), - + colSpan: z.number().int().min(1).max(4).optional().describe('[legacy — prefer `span`] Absolute column span (1-4). Fragile when the column count is derived per surface (mobile 1 / modal 2 / page 3-4): a fixed span only lines up at the width the author imagined. The renderer clamps it to the current column count. Prefer `span`.'), + /** + * [#2578] Relative field width — decoupled from the (often auto-derived) + * column count, so it stays correct at 1/2/3/4 columns. + */ + span: z.enum(['auto', 'full']).default('auto').describe("Relative field width. 'auto' (default — omit it): the renderer sizes the field from its widget type × the current column count (wide widgets like textarea/richtext/json/file/subform take the whole row). 'full': whole row at any column count. Prefer this over the absolute `colSpan`."), + /** Custom widget override — only needed when auto-inference is insufficient */ widget: z.string().optional().describe('Custom widget/component name (overrides type-based inference)'), From 5dce42c986a45c5ea41b7236cc25cd69aa00fb90 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sun, 5 Jul 2026 00:38:58 +0800 Subject: [PATCH 2/3] feat(spec): navigation.size bucket replaces pixel width for overlays (#2578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NavigationConfig.size ('auto'|sm|md|lg|xl|full, default 'auto'), aligned with FormView.modalSize; deprecate the pixel `width` and `drawerWidth`. A pixel width can't be authored blind — the author (often AI) doesn't know the client viewport. 'auto' means the renderer derives the size from field count and clamps to the viewport, so AI writes nothing. Additive; api-surface unchanged. Co-Authored-By: Claude Opus 4.8 --- packages/spec/src/ui/view.zod.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 29492ff191..49bfaa49b0 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -479,8 +479,23 @@ export const NavigationConfigSchema = lazySchema(() => z.object({ preventNavigation: z.boolean().default(false).describe('Disable standard navigation entirely'), openNewTab: z.boolean().default(false).describe('Force open in new tab (applies to page mode)'), - /** Dimensions (for modal/drawer) */ - width: z.union([z.string(), z.number()]).optional().describe('Width of the drawer/modal (e.g. "600px", "50%")'), + /** + * [#2578] Overlay size for a drawer/modal detail — coarse T-shirt buckets, + * aligned with `FormView.modalSize` (`page` mode ignores it). `'auto'` + * (default): the renderer derives the size from the object's field count and + * clamps it to the client viewport, so AI writes nothing — it cannot know the + * client width. Explicit buckets are a coarse, viewport-independent override. + */ + size: z.enum(['auto', 'sm', 'md', 'lg', 'xl', 'full']).default('auto') + .describe("[#2578] Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it."), + + /** + * @deprecated [#2578 → `size`] A pixel/percent width cannot be authored blind: + * the author (often an AI) does not know the client viewport. Kept only as a + * renderer fallback for pre-#2578 metadata; new metadata sets `size` (or omits + * it for `auto`). + */ + width: z.union([z.string(), z.number()]).optional().describe('[DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the `size` bucket.'), })); /** @@ -828,7 +843,8 @@ export const FormViewSchema = lazySchema(() => z.object({ splitResizable: z.boolean().optional().describe('Whether the split is resizable (split forms)'), /** Drawer (`type: 'drawer'`). */ drawerSide: z.enum(['top', 'bottom', 'left', 'right']).optional().describe('Drawer side (drawer forms)'), - drawerWidth: z.string().optional().describe('Drawer width, e.g. "480px" (drawer forms)'), + /** @deprecated [#2578 → `modalSize` / size buckets] A pixel width can't be authored blind (unknown client viewport); the renderer derives width from content + viewport. */ + drawerWidth: z.string().optional().describe('[DEPRECATED → size buckets] Drawer width, e.g. "480px". A pixel width cannot be chosen without knowing the client viewport — the renderer derives it.'), /** Modal (`type: 'modal'`). */ modalSize: z.enum(['sm', 'default', 'lg', 'xl', 'full']).optional().describe('Modal size (modal forms)'), From d8316b52d03918c8a084d7273b097762e6cf5aab Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sun, 5 Jul 2026 00:53:55 +0800 Subject: [PATCH 3/3] docs(changeset): note navigation.size (#2578) Co-Authored-By: Claude Opus 4.8 --- .changeset/adaptive-record-surface-span-2578.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.changeset/adaptive-record-surface-span-2578.md b/.changeset/adaptive-record-surface-span-2578.md index 514e7bd841..937d14d757 100644 --- a/.changeset/adaptive-record-surface-span-2578.md +++ b/.changeset/adaptive-record-surface-span-2578.md @@ -37,4 +37,11 @@ object — silently never renders) and `absolute-colspan-discouraged` (steers au to `span: 'full'`). Both warnings, with fix hints, held to the same bar for AI and hand authors. +**`NavigationConfig.size` (new) replaces pixel `width`.** A T-shirt bucket +(`auto`/sm/md/lg/xl/full, default `auto`, aligned with `FormView.modalSize`) for a +drawer/modal detail overlay. `width`/`drawerWidth` (pixel) are deprecated: a pixel +width cannot be authored blind — the author (often an AI) does not know the client +viewport. `auto` means the renderer derives the size from field count and clamps to +the viewport, so AI writes nothing. + All additive: no exports removed, no behavior change for existing metadata.