Skip to content

Commit 941c78a

Browse files
feat(spec)!: a field-heavy task flow on desktop derives a bounded modal; mobile keeps full-screen (#23747)
Fixes #23734 Clause-②: no The spec half of objectstack-ai/objectui#12521 ruling `6203721639` (letter B, maintainer 「B (推荐)」): `deriveRecordFlowSurface` now derives, for a field-heavy task flow on desktop, a **bounded** modal (`size: 'auto'`, so the renderer's `lg` / `xl` bound applies); a mobile posture keeps the full-screen modal. ## What changed - `packages/spec/src/data/record-surface.ts`: the task-flow arm. When the base derivation says `'page'` (field-heavy, or mobile), a task flow (`create` / `edit` / `child-create` / `child-edit`) answers `{ container: 'overlay', surface: 'modal', size }` with `size: 'full'` only for `viewport: 'mobile'` and `size: 'auto'` otherwise. No new parameter, export or type member: the mobile posture was already the `viewport` input, and `RecordFlowSurface.size` was already `'auto' | 'full'`. The docblock states the viewport split and the no-subtable-split reading, and carries a dated amendment line on the #2604 D1 statement it cites (the original D1 sentence is kept, not rewritten). - `packages/spec/src/data/record-surface.test.ts`: the pins that asserted `size: 'full'` for a heavy desktop task flow are flipped to assert `size: 'auto'` (heavy task flows, the fat `child-edit` child, the `pageThreshold` case, now a full `toEqual`). Added: an explicit `viewport: 'desktop'` leg, heavy-on-mobile stays `'full'`, and a heavy form with an embedded line-items table (a `repeater` field) takes the same bounded modal on desktop and full-screen on mobile. - `.changeset/23734-heavy-flow-bounded-modal.md`: `@objectstack/spec` minor, BREAKING line with the FROM / TO table and the one-line fix (`navigation.size: 'full'` or `FormView.modalSize: 'full'` for an object that genuinely needs full-screen on desktop), ADR-0087 disposition `not-required (no-migration-prescription)`. ## Premise measured first (ruling item 2) The ruling asked whether #2604 D1 chose full-screen for a field-heavy task flow with an embedded child or line-items table. Read on objectstack#2604 (design comment `4884632356`, the D1 table and its scope note): > | `'page'`(字段多) | 整页 **route** | **全屏 modal**(`size:'full'`)—— 同样大画布 + overlay 返回语义 | > (此处"子表" = 详情页 related list(`relatedList`,#2594);表单内写侧 `inlineEdit` 网格本就与父表单原子提交,不在本设计范围。) D1 sized task flows by authorable field count alone, and its own scope note puts the in-form `inlineEdit` (line-items) grid outside the design. The derivation's input is the object def, so it never sees an embedded grid either. D1 therefore never chose full-screen *for* that case: the split the ruling conditions on does not apply, and the bounded modal covers every field-heavy desktop task flow. A test states this. No fork is reported to objectui#12521. ## Consumers swept `git grep -n "deriveRecordFlowSurface"` across the repo (`packages`, `examples`, `content/docs`, `skills`): the only callers are `record-surface.ts` and its test. The other hits are the generated `api-surface` / `export-origins` rosters (unchanged: no export moved) and `content/docs/releases/v12.mdx` (release-owned, not edited). The console consumer (objectui `AppContent.tsx`, keyed on `size === 'full'`) is in objectui#12521 / objectui PR 12546; no objectui file is touched here. ## Verification (at `c5defce`) - `pnpm --filter @objectstack/spec build` :: exit 0; `pnpm --filter @objectstack/spec typecheck` :: exit 0. - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/data/record-surface.test.ts` :: 15 passed. - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2` (whole package) :: 21808 passed, 1 todo, exit 0. - `pnpm --filter @objectstack/spec check:generated` :: all 14 generated artifacts up to date (no regeneration needed: the docblock is not a `.describe()`). - Ablation, two legs, through `scripts/ablation-replace.mjs` (anchor hit x1, blob changed, restored to the HEAD blob with `git diff HEAD` empty): size forced to `'full'` everywhere: 4 failed / 11 passed; size forced to `'auto'` everywhere: 2 failed / 13 passed (the mobile legs). - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 84 derived commands, run locally with exit codes recorded; `--ran` reconciliation: 84 accounted, 83 run, 1 NOT MEASURED. Three gates first exited 3 on prerequisites (shallow clone, unbuilt `@objectstack/formula` / `@objectstack/objectql`) and were re-run green after fetching the pinned fixture commit and building those two packages. NOT MEASURED: `pnpm check:dual-build-cjs-loads` (needs every package's `dist`; CI runs it). ## Acceptance notes - `skills/objectstack-ui/rules/navigation.md` (Record Presentation, "Surface (page vs drawer)") says a field-heavy object opens create/edit/detail as a **full page**. That was already inexact before this change (create/edit has been a full-screen modal since #2604), and it is now a bounded modal on desktop. Not edited here: `skills/**` is a governed surface outside this card's claimed file surface. Drift only, no runtime or authoring consequence (the paragraph tells authors not to set the surface). Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_01FaR7ExTuo2XxtaZH3xKJ1e)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FaR7ExTuo2XxtaZH3xKJ1e Co-authored-by: Claude <noreply@anthropic.com>
1 parent 29c7157 commit 941c78a

3 files changed

Lines changed: 73 additions & 11 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: `deriveRecordFlowSurface` derives a bounded modal (`size: 'auto'`) for a field-heavy create / edit / child task flow on desktop; a mobile posture keeps the full-screen modal (`size: 'full'`)
6+
7+
A task flow (`create`, `edit`, `child-create`, `child-edit`) whose base surface is `'page'` (at or above `RECORD_SURFACE_PAGE_THRESHOLD` authorable fields) still opens as an overlay modal, but on desktop its size is now `'auto'`, so the renderer's own `lg` / `xl` bound applies instead of stretching the form to the window edge. With `viewport: 'mobile'` the derivation answers `size: 'full'` exactly as before. A heavy form with an embedded child or line-items table is not split out: it takes the same bounded modal on desktop. The `view` flow and every light-object flow are unchanged.
8+
9+
Clause-②: no
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is added, removed, renamed or re-shaped, and no export is added, removed or narrowed (`RecordFlowSurface.size` was already `'auto' | 'full'`). What changes is the value a published pure function derives for one input class, a desktop field-heavy task flow, so there is nothing for `objectstack migrate meta` to rewrite. The remedy for a host that wants the old size is an explicit `navigation.size` / `FormView.modalSize`, never a rewrite of a package source or a stored row. -->
12+
13+
**BREAKING** (a published derivation's default changes), shipped as `minor` under the launch-window convention for breaking changes.
14+
15+
What changes for you: a consumer that relied on `deriveRecordFlowSurface(def, flow)` answering `size: 'full'` for a field-heavy task flow on desktop now gets `size: 'auto'`.
16+
17+
| call | FROM | TO |
18+
|:--|:--|:--|
19+
| `deriveRecordFlowSurface(heavyDef, 'create')` (also `'edit'`, `'child-create'`, `'child-edit'`; or `{ viewport: 'desktop' }`) | `{ container: 'overlay', surface: 'modal', size: 'full' }` | `{ container: 'overlay', surface: 'modal', size: 'auto' }` |
20+
| the same call with `{ viewport: 'mobile' }` | `{ container: 'overlay', surface: 'modal', size: 'full' }` | unchanged |
21+
22+
The one-line fix, for an object that genuinely needs the full-screen form on desktop: set `navigation.size: 'full'` on its list view (or `FormView.modalSize: 'full'`), which wins over the derived default.

‎packages/spec/src/data/record-surface.test.ts‎

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,26 @@ describe('deriveRecordFlowSurface — viewing a record may route, a create or ed
7070
});
7171
});
7272

73-
it('task flows never route: heavy → full-screen modal overlay', () => {
73+
it('task flows never route: heavy on desktop → a bounded modal overlay (size auto), not full-screen', () => {
7474
for (const flow of TASK_FLOWS) {
7575
expect(deriveRecordFlowSurface(heavy, flow)).toEqual({
76+
container: 'overlay', surface: 'modal', size: 'auto',
77+
});
78+
expect(deriveRecordFlowSurface(heavy, flow, { viewport: 'desktop' })).toEqual({
79+
container: 'overlay', surface: 'modal', size: 'auto',
80+
});
81+
}
82+
});
83+
84+
it('a heavy form with an embedded line-items table takes the same bounded modal on desktop (no subtable split)', () => {
85+
const withLineItems = objWithFields(RECORD_SURFACE_PAGE_THRESHOLD, {
86+
line_items: { type: 'repeater', label: 'Line items', fields: { sku: { type: 'text' } } },
87+
});
88+
for (const flow of TASK_FLOWS) {
89+
expect(deriveRecordFlowSurface(withLineItems, flow)).toEqual({
90+
container: 'overlay', surface: 'modal', size: 'auto',
91+
});
92+
expect(deriveRecordFlowSurface(withLineItems, flow, { viewport: 'mobile' })).toEqual({
7693
container: 'overlay', surface: 'modal', size: 'full',
7794
});
7895
}
@@ -86,28 +103,33 @@ describe('deriveRecordFlowSurface — viewing a record may route, a create or ed
86103
}
87104
});
88105

89-
it('mobile: view routes to a page; task flows become a full-screen modal', () => {
106+
it('mobile: view routes to a page; task flows, light or heavy, become a full-screen modal', () => {
90107
expect(deriveRecordFlowSurface(light, 'view', { viewport: 'mobile' })).toEqual({
91108
container: 'route', surface: 'page', size: 'auto',
92109
});
93110
for (const flow of TASK_FLOWS) {
94111
expect(deriveRecordFlowSurface(light, flow, { viewport: 'mobile' })).toEqual({
95112
container: 'overlay', surface: 'modal', size: 'full',
96113
});
114+
expect(deriveRecordFlowSurface(heavy, flow, { viewport: 'mobile' })).toEqual({
115+
container: 'overlay', surface: 'modal', size: 'full',
116+
});
97117
}
98118
});
99119

100120
it('child-* flows size to the def they are given (the child), independent of any parent', () => {
101121
// A thin child stays a drawer even though its parent (not passed) is heavy.
102122
expect(deriveRecordFlowSurface(objWithFields(3), 'child-create').surface).toBe('drawer');
103-
// A fat child gets the full-screen modal.
123+
// A fat child gets the bounded modal on desktop.
104124
expect(deriveRecordFlowSurface(objWithFields(40), 'child-edit')).toEqual({
105-
container: 'overlay', surface: 'modal', size: 'full',
125+
container: 'overlay', surface: 'modal', size: 'auto',
106126
});
107127
});
108128

109129
it('honours pageThreshold and tolerates bare/malformed input', () => {
110-
expect(deriveRecordFlowSurface(objWithFields(5), 'create', { pageThreshold: 4 }).size).toBe('full');
130+
expect(deriveRecordFlowSurface(objWithFields(5), 'create', { pageThreshold: 4 })).toEqual({
131+
container: 'overlay', surface: 'modal', size: 'auto',
132+
});
111133
expect(deriveRecordFlowSurface(null, 'create')).toEqual({
112134
container: 'overlay', surface: 'drawer', size: 'auto',
113135
});

‎packages/spec/src/data/record-surface.ts‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -128,9 +128,24 @@ export interface RecordFlowSurface {
128128
* state → route-capable; making/changing one is a task → always overlay.
129129
*
130130
* So `view` keeps the #2578 behavior verbatim (`'page'` → route), while the
131-
* task flows map the derived `'page'` to a FULL-SCREEN MODAL — same big
132-
* canvas, overlay return semantics. This mapping is why `'modal'` exists in
133-
* {@link RecordSurface} without the base heuristic ever emitting it.
131+
* task flows map the derived `'page'` to a MODAL — overlay return semantics.
132+
* This mapping is why `'modal'` exists in {@link RecordSurface} without the
133+
* base heuristic ever emitting it. How big that modal is follows the viewport:
134+
* - desktop (the default): a BOUNDED modal, `size: 'auto'` — the renderer's
135+
* own `lg` / `xl` bound applies, so a field-heavy form is not stretched to
136+
* the window edge;
137+
* - `viewport: 'mobile'`: a FULL-SCREEN modal, `size: 'full'` (an overlay on
138+
* a phone has no room to be anything else).
139+
* The split is by viewport only. A field-heavy form with an embedded child /
140+
* line-items table takes the same bounded modal on desktop: #2604 D1 sized
141+
* task flows by field count alone and scoped the in-form `inlineEdit` grid out
142+
* of its design, so it never chose full-screen for that case.
143+
*
144+
* #2604 D1 (2026-07-05) mapped a field-heavy task flow to a full-screen modal
145+
* on every viewport.
146+
* Amended 2026-10-15 by objectui#12521 ruling 6203721639 (letter B): on
147+
* desktop the field-heavy task flow is a bounded modal; mobile keeps
148+
* full-screen.
134149
*
135150
* Like the base derivation this is a DEFAULT only: explicit `navigation.mode`
136151
* / `navigation.size`, `FormView.type` / `modalSize`, or an assigned page win
@@ -145,8 +160,11 @@ export function deriveRecordFlowSurface(
145160
if (flow === 'view') {
146161
return { container: surface === 'page' ? 'route' : 'overlay', surface, size: 'auto' };
147162
}
148-
// Task flows (create / edit / child-*): never a route. Field-heavy (or
149-
// mobile, where the base derivation says 'page') → full-screen modal.
150-
if (surface === 'page') return { container: 'overlay', surface: 'modal', size: 'full' };
163+
// Task flows (create / edit / child-*): never a route. The base derivation
164+
// says 'page' when field-heavy or mobile → a modal: full-screen on mobile,
165+
// bounded (`'auto'`, the renderer's lg/xl bound) on desktop.
166+
if (surface === 'page') {
167+
return { container: 'overlay', surface: 'modal', size: opts.viewport === 'mobile' ? 'full' : 'auto' };
168+
}
151169
return { container: 'overlay', surface, size: 'auto' };
152170
}

0 commit comments

Comments
 (0)