Skip to content

Commit 0426d27

Browse files
os-zhuangclaude
andauthored
feat(spec): deriveRecordFlowSurface — flow-aware record-surface derivation (#2604 Step 1) (#2622)
* docs(design): record create/edit/subtable surface + return-flow model (#2604) Decides the three open questions from #2604 (follow-up to #2578): - D1: create/edit are ALWAYS overlays, never routes — the derived 'page' surface maps to a full-screen modal (size 'full'); light objects keep a drawer. Deep-linkability belongs to state (detail route, shipped), not to transient tasks. - D2: detail → edit reuses the same edit overlay over the detail route — one edit surface everywhere; in-place field editing deferred, not rejected. - D3: subtable child create/edit = overlay over the parent detail, never a route; size derived from the CHILD object's own field count. Specifies the return-flow contract (cancel invariant, save invariant — edit never moves you, create lands on the record, child tasks never leave the parent — dirty guard, full-screen-modal history integration), the Step 1 spec helper deriveRecordFlowSurface as the one shared derivation (ADR-0085 §5), the objectui wiring plan, and the browser-verification matrix. Zero new authorable keys (ADR-0085 §2); docs-only, empty changeset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019zKcAtbtxuF9SXYgSzjk9v * Revert "docs(design): record create/edit/subtable surface + return-flow model (#2604)" This reverts commit 1439cec. * feat(spec): deriveRecordFlowSurface — flow-aware record-surface derivation (#2604) Step 1 of #2604 (design decided in the issue thread; follow-up to #2578): - deriveRecordFlowSurface(def, flow, opts): 'view' keeps the shipped #2578 behavior verbatim (heavy → route/page, light → drawer overlay); task flows (create/edit/child-create/child-edit) are ALWAYS overlays — never routes — with derived 'page' mapped to a full-screen modal (size 'full'). child-* flows take the CHILD def; mobile task flows are full-screen modals. - One shared derivation (ADR-0085 §5); renderers use it as the DEFAULT only — explicit navigation.mode/size, FormView.type/modalSize, assigned page win. No new authorable key (ADR-0085 §2). - 6 new unit tests (threshold × flow × mobile × child independence × bare input); spec suite 6690 green; api-surface regenerated (+4 exports); minor changeset. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019zKcAtbtxuF9SXYgSzjk9v --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0a9232a commit 0426d27

4 files changed

Lines changed: 133 additions & 0 deletions

File tree

.changeset/record-flow-surface.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec): `deriveRecordFlowSurface(def, flow, opts)` — flow-aware record-surface derivation (#2604, extends #2578's `deriveRecordSurface`, ADR-0085 §5 one-shared-derivation).
6+
7+
Decides the default surface per record FLOW: `view` keeps the shipped behavior verbatim (field-heavy → `route`/page, light → drawer overlay); the task flows (`create` / `edit` / `child-create` / `child-edit`) are ALWAYS overlays — never routes — with the derived `'page'` mapped to a full-screen modal (`size: 'full'`) and light objects staying a drawer. `child-*` flows take the CHILD object's def (the overlay sizes to the record being edited; the return target is always the parent detail). Mobile task flows are full-screen modals.
8+
9+
Rationale: viewing a record is shareable state (deep-link belongs there); making/changing one is a transient task whose URL is a false promise (refresh loses the draft) and whose invariant is lossless return to the origin. Renderers treat the result as the DEFAULT only — explicit `navigation.mode`/`size`, `FormView.type`/`modalSize`, or an assigned page still win. No new authorable key (ADR-0085 §2). Additive, no breaking changes.

packages/spec/api-surface.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,9 @@
367367
"RangeOperatorSchema (const)",
368368
"Reaction (type)",
369369
"ReactionSchema (const)",
370+
"RecordFlow (type)",
371+
"RecordFlowContainer (type)",
372+
"RecordFlowSurface (interface)",
370373
"RecordSubscription (type)",
371374
"RecordSubscriptionSchema (const)",
372375
"RecordSurface (type)",
@@ -462,6 +465,7 @@
462465
"defineObjectExtension (function)",
463466
"defineSeed (function)",
464467
"deriveFieldGroupLayout (function)",
468+
"deriveRecordFlowSurface (function)",
465469
"deriveRecordSurface (function)",
466470
"fieldForm (const)",
467471
"hasDynamicTokens (function)",

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import { describe, it, expect } from 'vitest';
44
import {
55
deriveRecordSurface,
6+
deriveRecordFlowSurface,
67
countAuthorableFields,
78
RECORD_SURFACE_PAGE_THRESHOLD,
9+
type RecordFlow,
810
} from './record-surface';
911

1012
/** Build an object def with `n` plain text fields named f0..f(n-1). */
@@ -54,6 +56,65 @@ describe('deriveRecordSurface (ADR-0085 §5)', () => {
5456
});
5557
});
5658

59+
describe('deriveRecordFlowSurface (#2604)', () => {
60+
const TASK_FLOWS: RecordFlow[] = ['create', 'edit', 'child-create', 'child-edit'];
61+
const heavy = objWithFields(RECORD_SURFACE_PAGE_THRESHOLD);
62+
const light = objWithFields(RECORD_SURFACE_PAGE_THRESHOLD - 1);
63+
64+
it("view keeps the #2578 behavior verbatim: heavy → route('page'), light → overlay('drawer')", () => {
65+
expect(deriveRecordFlowSurface(heavy, 'view')).toEqual({
66+
container: 'route', surface: 'page', size: 'auto',
67+
});
68+
expect(deriveRecordFlowSurface(light, 'view')).toEqual({
69+
container: 'overlay', surface: 'drawer', size: 'auto',
70+
});
71+
});
72+
73+
it('task flows never route: heavy → full-screen modal overlay', () => {
74+
for (const flow of TASK_FLOWS) {
75+
expect(deriveRecordFlowSurface(heavy, flow)).toEqual({
76+
container: 'overlay', surface: 'modal', size: 'full',
77+
});
78+
}
79+
});
80+
81+
it('task flows on a light object stay a drawer overlay', () => {
82+
for (const flow of TASK_FLOWS) {
83+
expect(deriveRecordFlowSurface(light, flow)).toEqual({
84+
container: 'overlay', surface: 'drawer', size: 'auto',
85+
});
86+
}
87+
});
88+
89+
it('mobile: view routes to a page; task flows become a full-screen modal', () => {
90+
expect(deriveRecordFlowSurface(light, 'view', { viewport: 'mobile' })).toEqual({
91+
container: 'route', surface: 'page', size: 'auto',
92+
});
93+
for (const flow of TASK_FLOWS) {
94+
expect(deriveRecordFlowSurface(light, flow, { viewport: 'mobile' })).toEqual({
95+
container: 'overlay', surface: 'modal', size: 'full',
96+
});
97+
}
98+
});
99+
100+
it('child-* flows size to the def they are given (the child), independent of any parent', () => {
101+
// A thin child stays a drawer even though its parent (not passed) is heavy.
102+
expect(deriveRecordFlowSurface(objWithFields(3), 'child-create').surface).toBe('drawer');
103+
// A fat child gets the full-screen modal.
104+
expect(deriveRecordFlowSurface(objWithFields(40), 'child-edit')).toEqual({
105+
container: 'overlay', surface: 'modal', size: 'full',
106+
});
107+
});
108+
109+
it('honours pageThreshold and tolerates bare/malformed input', () => {
110+
expect(deriveRecordFlowSurface(objWithFields(5), 'create', { pageThreshold: 4 }).size).toBe('full');
111+
expect(deriveRecordFlowSurface(null, 'create')).toEqual({
112+
container: 'overlay', surface: 'drawer', size: 'auto',
113+
});
114+
expect(deriveRecordFlowSurface({ fields: 'nope' } as unknown, 'view').surface).toBe('drawer');
115+
});
116+
});
117+
57118
describe('countAuthorableFields', () => {
58119
it('counts visible non-system fields only', () => {
59120
expect(countAuthorableFields(objWithFields(5))).toBe(5);

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,62 @@ export function deriveRecordSurface(def: unknown, opts: RecordSurfaceOptions = {
9191
if (countAuthorableFields(def) >= threshold) return 'page';
9292
return 'drawer';
9393
}
94+
95+
/**
96+
* The record flow being opened. `view` shows state; the other four perform a
97+
* task (create/change a record). For `child-*` flows — a subtable / related-
98+
* list child created or edited from its PARENT's detail — pass the CHILD
99+
* object's def: the overlay sizes to the record being edited, while the
100+
* return target is always the parent (#2604 D3).
101+
*/
102+
export type RecordFlow = 'view' | 'create' | 'edit' | 'child-create' | 'child-edit';
103+
104+
/** How the surface is mounted: a navigated route, or an overlay over the origin. */
105+
export type RecordFlowContainer = 'route' | 'overlay';
106+
107+
export interface RecordFlowSurface {
108+
/**
109+
* `'route'` only ever for flow `'view'` (a record is shareable state —
110+
* deep-linkable, refresh-safe). Every task flow is an `'overlay'`: close
111+
* returns to the origin with its context (scroll / filters / tab) intact,
112+
* which is the #2604 return-flow invariant — and a create/edit URL would be
113+
* a false promise anyway (refresh loses the draft).
114+
*/
115+
container: RecordFlowContainer;
116+
surface: RecordSurface;
117+
/** Maps onto `navigation.size` / `FormView.modalSize`; routes ignore it. */
118+
size: 'auto' | 'full';
119+
}
120+
121+
/**
122+
* Derive the DEFAULT surface for a record FLOW (#2604; extends
123+
* {@link deriveRecordSurface}, ADR-0085 §5 "one shared derivation").
124+
*
125+
* Rule — the two axes are independent:
126+
* - how BIG (field count, via {@link deriveRecordSurface}) is unchanged;
127+
* - whether it ROUTES is decided by what the flow *is*: viewing a record is
128+
* state → route-capable; making/changing one is a task → always overlay.
129+
*
130+
* 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.
134+
*
135+
* Like the base derivation this is a DEFAULT only: explicit `navigation.mode`
136+
* / `navigation.size`, `FormView.type` / `modalSize`, or an assigned page win
137+
* (the sanctioned per-object overrides — no new authorable key, ADR-0085 §2).
138+
*/
139+
export function deriveRecordFlowSurface(
140+
def: unknown,
141+
flow: RecordFlow,
142+
opts: RecordSurfaceOptions = {},
143+
): RecordFlowSurface {
144+
const surface = deriveRecordSurface(def, opts);
145+
if (flow === 'view') {
146+
return { container: surface === 'page' ? 'route' : 'overlay', surface, size: 'auto' };
147+
}
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' };
151+
return { container: 'overlay', surface, size: 'auto' };
152+
}

0 commit comments

Comments
 (0)