From 1439cec5fe564226a1b362e91b6b3229d2727819 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 02:46:55 +0000 Subject: [PATCH 1/3] docs(design): record create/edit/subtable surface + return-flow model (#2604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019zKcAtbtxuF9SXYgSzjk9v --- .../record-surface-return-flows-design.md | 4 + docs/design/record-surface-return-flows.md | 172 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 .changeset/record-surface-return-flows-design.md create mode 100644 docs/design/record-surface-return-flows.md diff --git a/.changeset/record-surface-return-flows-design.md b/.changeset/record-surface-return-flows-design.md new file mode 100644 index 0000000000..11fc828555 --- /dev/null +++ b/.changeset/record-surface-return-flows-design.md @@ -0,0 +1,4 @@ +--- +--- + +docs(design): record create/edit/subtable surface + return-flow model — decides #2604 (create/edit = derived overlay, never a route; detail edit = same overlay; subtable child tasks = overlay over parent) and specifies the cancel/save return invariants, dirty guard and history integration. Docs-only, no package release. diff --git a/docs/design/record-surface-return-flows.md b/docs/design/record-surface-return-flows.md new file mode 100644 index 0000000000..e8ba906e73 --- /dev/null +++ b/docs/design/record-surface-return-flows.md @@ -0,0 +1,172 @@ +# Design — Record create / edit / subtable surfaces + the return-flow model + +**Issue**: [#2604](https://github.com/objectstack-ai/framework/issues/2604) (follow-up to [#2578](https://github.com/objectstack-ai/framework/issues/2578), shipped in framework#2595 + objectui#2237 + framework#2599) +**Builds on**: [ADR-0085](../adr/0085-object-semantic-roles-over-surface-hint-blocks.md) §2 (admission test — presentation surface is *not* an authorable key) and §5 (one shared derivation, every surface); [ADR-0078](../adr/0078-no-silently-inert-metadata.md) (no silent no-ops) +**Audience**: implementing agent. Scope: decide the three open questions from #2604 and specify the return-flow contract precisely enough to wire and browser-verify. + +**North star (inherited from #2578):** all metadata is AI-authored, so surface *and* return behavior are **platform defaults derived at runtime — zero AI authoring**. Presentation is not metadata. This design adds **no object keys, no view keys, no new ADR** — it is renderer semantics inside the boundary ADR-0085 already drew. + +--- + +## 1. Current state (verified, post-#2578) + +What shipped: + +- `deriveRecordSurface(def, opts)` (`packages/spec/src/data/record-surface.ts`) — the single derivation of a record's default surface from authorable-field count: ≥ 12 → `'page'`, else `'drawer'`; `viewport: 'mobile'` → `'page'`. `'modal'` is in the `RecordSurface` type but never emitted by the heuristic. +- The **detail (view)** flow consumes it in objectui `packages/app-shell/src/views/ObjectView.tsx` (`detailNavigation` default): field-heavy → full page **route**, light → **drawer** over the list (URL-addressable via `?recordId=`). Browser-verified on `field_zoo` (57 fields → page) and `product` (6 → drawer). +- Explicit overrides already exist and win over the derivation: `navigation.mode` / `navigation.size` on the list view, `FormView.type` / `modalSize`, or an assigned record `Page`. + +What #2578 deliberately did **not** design (this document's scope): + +| Flow | Behavior today | Gap | +|---|---|---| +| List → **New** (create) | hardcoded large modal | ignores the derivation; return-on-save unspecified | +| Detail → **Edit** | modal, not wired to the derivation | surface + return undesigned | +| Subtable (related list) → New / Edit **child** | untouched | surface + return undesigned | +| **Return** from any of the above | ad-hoc | no contract, never browser-verified | + +("Subtable" here = the parent detail page's **related list** of `master_detail`/`lookup` children — the `relatedList` role, #2594. The *write-side* `inlineEdit` grid lives **inside** the parent's own form and is atomic with it; it is untouched by this design — see §7.) + +--- + +## 2. The one decision that drives everything: route vs overlay + +A **route** buys deep-linkability, refresh-safety and browser-back — properties of **state you'd share or revisit**. It costs explicit return wiring (origin state), and nested flows need a return *stack*. + +An **overlay** (modal/drawer) buys trivial, lossless return — close and you are exactly where you were, scroll + filters + tab intact. It costs deep-linkability, which a **transient task** doesn't need: a URL to a half-filled form is not shareable state (refresh loses the draft regardless), and nobody bookmarks "the act of editing". + +So the split falls out of what each flow *is*: + +> **Viewing a record is state → route-capable. Making/changing a record is a task → always an overlay.** + +This is also the cheapest correct system: the return-flow invariants in §4 come *for free* from overlays, while the route alternative would require origin-state wiring for create/edit plus a return stack for subtable flows — machinery whose only payoff is deep-linking a transient task. + +### Decisions (the three questions in #2604) + +**D1 — Create + Edit surface: overlay, never a route.** The *size* of the overlay still follows the #2578 derivation — that is what makes create/edit "consistent with the shipped detail behavior" without copying its routing: + +| `deriveRecordSurface(def)` | Detail (view) — shipped | Create / Edit — this design | +|---|---|---| +| `'page'` (field-heavy) | full-page **route** | **full-screen modal** (`size: 'full'`) — same big canvas, overlay return semantics | +| `'drawer'` (light) | drawer over the list | drawer/modal overlay, size `'auto'` | +| mobile (any) | page | full-screen modal | + +i.e. one rule: *create/edit maps the derived `'page'` surface to a full-screen **modal**.* This is exactly why `'modal'` exists in the `RecordSurface` type. The route alternative for create/edit is **rejected** (see §8). + +**D2 — Detail → Edit: the same edit overlay, opened over the detail route.** Not an in-place view↔edit mode. One edit surface everywhere (list-row edit, detail edit, subtable child edit) means one code path, one return contract, one thing to verify — the right shape for a zero-config platform where no human tunes divergent surfaces per object. Save/cancel closes the overlay back onto the detail view state — never anywhere else. In-place (field-level inline) editing is a valuable *orthogonal* enhancement, deferred, not rejected (§7). + +**D3 — Subtable child create/edit: overlay over the parent detail. Never a route.** Confirmed as the issue recommends. The return target of a child task is *always* the parent detail with the subtable refreshed; a child route would discard the parent context (scroll, active tab) and force a return stack. The child overlay's size derives from the **child object's** own field count (a heavy child gets the full-screen modal, a thin child a drawer) — same rule as D1, applied to the child's definition. + +--- + +## 3. Why not a route for create/edit — the argument, recorded + +1. **Return is the invariant; deep-link is the nice-to-have.** Every flow in §4 must end back at its origin with context intact. Overlays satisfy this by construction. Routes satisfy it only with origin-state plumbing — which exists for detail ("← all records") but would need to grow a *stack* for parent → child → (lookup-create…) nesting. +2. **A create/edit URL is a false promise.** Refresh or share it and the draft is gone — the URL names the *task*, not the *state*. Deep-linking `/record/:id` (shipped) already covers the shareable thing. +3. **Browser-back is handled, not lost** (§4.4): the full-screen modal pushes one history entry so Back = close (with dirty guard). Users on a full-screen surface *will* press Back; that must not abandon the origin or silently drop a draft. +4. **Salesforce-shaped precedent:** record create/edit are modals over the origin; the record page is the route. Users' muscle memory matches D1. +5. **Zero authoring stays zero.** No `recordSurface`-like key, no return config. Per-object override remains the sanctioned pair: `navigation.mode` (explicit `page` forces routed create/edit for whoever truly wants it) and assigned Pages. ADR-0085 §2 is unchanged. + +--- + +## 4. The return-flow contract + +Three invariants, stated once, applying to every flow: + +- **Cancel invariant.** Cancel / X / Esc / Back → overlay closes → the origin surface *exactly* as it was: scroll, filters, pagination, selected tab, drawer state. Nothing refetched, nothing written. +- **Save invariant.** *Edit never moves you; create takes you to the record you made; child tasks never leave the parent.* Precisely: + - **Create (top-level):** overlay closes → navigate to the **new record's detail** on *its* derived surface. For a light object that is the drawer **over the still-intact list**; for a heavy object it is the detail route (which already carries the "← all records" origin affordance). Rationale: post-create work continues *on* the record — most immediately, populating its subtables, which per D3 happen over its detail. The record is also the immediate visual proof of what was saved. + - **Edit (from anywhere):** overlay closes → **origin, refreshed** (detail → that detail refetched; list-row edit → the list refetched). Same position, same context. + - **Child create/edit (subtable):** overlay closes → **parent detail untouched except the subtable refetches**. Never the child's own detail, never a route change. Parent scroll and active tab preserved. +- **Dirty guard.** Any close gesture (Esc, X, Back, cancel) on a form with unsaved changes asks for confirmation before discarding. Outside-click never closes a form overlay (full-screen modals have no outside; drawers/modals disable it for *forms* — read-only detail drawers keep it). + +### 4.4 Browser history integration + +- **Full-screen modal** (`size: 'full'`): opening pushes **one** history entry; Back requests close (dirty guard applies); after close (or save) the entry is consumed — Back again navigates the underlying route as normal. No URL change is rendered — the entry exists only to catch Back. +- **Drawer / non-full modal:** no history entry (standard overlay semantics — Esc/X close; Back navigates the underlying route, dirty guard still intercepts if the form is dirty). +- **Nested overlays** (child form over parent detail drawer, lookup "create new" over a form): each full-screen layer pushes its own entry; Back peels one layer at a time. This *is* the "return stack" — the browser owns it; we never persist one. + +### 4.5 The flows, end to end + +| # | Origin | Action | Surface (light / heavy child or record) | Cancel → | Save → | +|---|---|---|---|---|---| +| 1 | List | New | drawer / **full-screen modal** | list, untouched | **new record's detail** (drawer over list / route) | +| 2 | List row | Edit | drawer / full-screen modal | list, untouched | list, refetched, position kept | +| 3 | Detail (route or drawer) | Edit | overlay over it | detail view state, untouched | detail view state, **refetched** | +| 4 | Parent detail subtable | New / Edit child | overlay over parent (size from **child** def) | parent, untouched | parent; **subtable refetches**, tab + scroll kept | +| 5 | Any form | lookup "create new" | one more overlay layer | back to the form, field empty | back to the form, field filled with the new record | + +Flow 5 is listed for completeness because it is the same primitive (a create task overlaying its origin) — it must not regress; its return target is the *form field*, not a detail page. + +--- + +## 5. Implementation plan + +### Step 1 — framework (`@objectstack/spec`, additive, unit-tested, independently mergeable) + +Extend `packages/spec/src/data/record-surface.ts` with the flow-aware mapping, so the D1–D3 table is **one shared derivation** (ADR-0085 §5) instead of a convention each renderer re-implements: + +```ts +export type RecordFlow = 'view' | 'create' | 'edit' | 'child-create' | 'child-edit'; + +export interface RecordFlowSurface { + /** 'route' only ever for flow 'view'; every task flow is an overlay. */ + container: 'route' | 'overlay'; + surface: RecordSurface; // 'page' | 'modal' | 'drawer' + size: 'auto' | 'full'; // maps onto navigation.size / modalSize +} + +export function deriveRecordFlowSurface( + def: unknown, // the CHILD def for child-* flows + flow: RecordFlow, + opts?: RecordSurfaceOptions, +): RecordFlowSurface; +``` + +Mapping (pure, total): `view` → today's `deriveRecordSurface` verbatim (`'page'` ⇒ `container: 'route'`); `create`/`edit`/`child-*` → `container: 'overlay'`, with derived `'page'` ⇒ `{ surface: 'modal', size: 'full' }` and `'drawer'` ⇒ `{ surface: 'drawer', size: 'auto' }`; mobile ⇒ task flows get `{ 'modal', 'full' }`. Renderers treat the result as the **default only** — explicit `navigation.mode`/`size`, `FormView.type`/`modalSize`, or an assigned Page win, exactly as today. + +Unit tests: threshold boundary × each flow, mobile override, child def independence, bare/un-parsed defs. Plus a changeset (minor, additive). + +*No lint work:* nothing new is authorable, so there is nothing new to misauthor (the #2595 lints already steer `colSpan`→`span` etc.). + +### Step 2 — objectui (consumes Step 1; one PR; browser-verified) + +1. **Create/edit wiring** (`packages/app-shell/src/views/ObjectView.tsx` — the layer where #2578's detail wiring had to land to take effect in console): replace the hardcoded create/edit modal default with `deriveRecordFlowSurface(def, 'create' | 'edit', { viewport })`. +2. **Detail Edit button** → the same edit overlay over the detail route/drawer (D2); save → refetch detail. +3. **Subtable** (`RecordDetailView` related lists): child New / row Edit → overlay from `deriveRecordFlowSurface(childDef, 'child-*')`; on save close + refetch **only** the related-list query. +4. **Return + guards:** cancel/save per §4; dirty-guard on all close gestures; history entry for full-screen modals (§4.4). +5. Until objectui's pinned `@objectstack/spec` includes Step 1, mirror the helper locally with a `TODO` to re-import — same pattern #2578 used for `deriveRecordSurface` (and swap both together when the pin moves). + +### Step 3 — browser verification (dogfood, blocking; the #2578 objects) + +Using `app-showcase`: heavy = `field_zoo` (57 authorable fields), light = `product` (6); a parent with a `relatedList` child for flow 4. + +- [ ] List → New (heavy): full-screen modal; **Cancel** → list scroll + filter intact; **Save** → new record's detail page; "← all records" returns to the original list state. +- [ ] List → New (light): drawer/modal; Save → detail drawer over the intact list. +- [ ] Detail → Edit (heavy + light): overlay over the detail; Cancel → view state untouched; Save → view state refetched (changed value visible). +- [ ] Parent detail → subtable New child → Save: parent never navigates; subtable shows the new child; active tab + scroll preserved. Same for child row Edit. +- [ ] Browser **Back** with a full-screen create modal open: modal closes (dirty guard if dirty), origin intact; Back again leaves the page normally. +- [ ] Esc / X with dirty form: confirmation appears; confirm-discard → cancel invariant holds. +- [ ] Mobile viewport: create/edit/child open full-screen; same returns. + +--- + +## 6. What this deliberately does *not* add + +- **No new spec keys.** `recordSurface`, `returnTo`, `afterSave`, per-object return config — all fail ADR-0085 §2 (machine-inferable and/or page-scoped). The derivation *is* the config. +- **No new ADR.** Same reasoning as #2578: the whole design lives inside ADR-0085's boundary (derived default + assigned-page/`navigation.mode` override). This document + the changeset are the record. +- **No return stack persistence.** The browser history is the stack (§4.4). + +## 7. Non-goals / deferred (not rejected) + +- **In-place (field-level inline) detail editing** — orthogonal to surfaces/returns; revisit when dogfood demands it. +- **"Save & New"** bulk-entry affordance on the create modal — additive later; thin-child bulk entry already has `inlineEdit: 'grid'`. +- **Draft persistence across refresh** for open forms — separate feature; until then the refresh-loses-draft boundary is accepted and is one more reason create/edit are not routes. +- **`inlineEdit` in-form child grids** — already atomic inside the parent form; unaffected here. + +## 8. Alternatives considered + +- **Create/edit as routes (`/new`, `/record/:id/edit`)** — rejected: pays origin-state + return-stack wiring to deep-link a transient task whose URL is a false promise (§3). Anyone who genuinely wants it can set `navigation.mode: 'page'` — the escape hatch already exists and stays. +- **In-place edit mode for D2** — deferred (§7): a second edit surface with its own return semantics; the modal reuses the one already required for D1/D3. +- **Stay-on-list after create-save (+ toast with a "view" link)** — considered; rejected as the *default* because the dominant post-create action on this platform is continuing on the record (filling subtables per D3), light objects keep the list visible anyway (drawer), and a toast link is a weaker affordance than being there. Revisit via dogfood if bulk create-from-list shows up as a real pattern. +- **Child create/edit as a route with a return stack** — rejected outright (the issue's own analysis): loses parent context, refetches the parent, and builds a stack the browser already provides. From 81b2ba1e16e69c8d99e1449cb07751c7e88b0851 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 04:44:47 +0000 Subject: [PATCH 2/3] Revert "docs(design): record create/edit/subtable surface + return-flow model (#2604)" This reverts commit 1439cec5fe564226a1b362e91b6b3229d2727819. --- .../record-surface-return-flows-design.md | 4 - docs/design/record-surface-return-flows.md | 172 ------------------ 2 files changed, 176 deletions(-) delete mode 100644 .changeset/record-surface-return-flows-design.md delete mode 100644 docs/design/record-surface-return-flows.md diff --git a/.changeset/record-surface-return-flows-design.md b/.changeset/record-surface-return-flows-design.md deleted file mode 100644 index 11fc828555..0000000000 --- a/.changeset/record-surface-return-flows-design.md +++ /dev/null @@ -1,4 +0,0 @@ ---- ---- - -docs(design): record create/edit/subtable surface + return-flow model — decides #2604 (create/edit = derived overlay, never a route; detail edit = same overlay; subtable child tasks = overlay over parent) and specifies the cancel/save return invariants, dirty guard and history integration. Docs-only, no package release. diff --git a/docs/design/record-surface-return-flows.md b/docs/design/record-surface-return-flows.md deleted file mode 100644 index e8ba906e73..0000000000 --- a/docs/design/record-surface-return-flows.md +++ /dev/null @@ -1,172 +0,0 @@ -# Design — Record create / edit / subtable surfaces + the return-flow model - -**Issue**: [#2604](https://github.com/objectstack-ai/framework/issues/2604) (follow-up to [#2578](https://github.com/objectstack-ai/framework/issues/2578), shipped in framework#2595 + objectui#2237 + framework#2599) -**Builds on**: [ADR-0085](../adr/0085-object-semantic-roles-over-surface-hint-blocks.md) §2 (admission test — presentation surface is *not* an authorable key) and §5 (one shared derivation, every surface); [ADR-0078](../adr/0078-no-silently-inert-metadata.md) (no silent no-ops) -**Audience**: implementing agent. Scope: decide the three open questions from #2604 and specify the return-flow contract precisely enough to wire and browser-verify. - -**North star (inherited from #2578):** all metadata is AI-authored, so surface *and* return behavior are **platform defaults derived at runtime — zero AI authoring**. Presentation is not metadata. This design adds **no object keys, no view keys, no new ADR** — it is renderer semantics inside the boundary ADR-0085 already drew. - ---- - -## 1. Current state (verified, post-#2578) - -What shipped: - -- `deriveRecordSurface(def, opts)` (`packages/spec/src/data/record-surface.ts`) — the single derivation of a record's default surface from authorable-field count: ≥ 12 → `'page'`, else `'drawer'`; `viewport: 'mobile'` → `'page'`. `'modal'` is in the `RecordSurface` type but never emitted by the heuristic. -- The **detail (view)** flow consumes it in objectui `packages/app-shell/src/views/ObjectView.tsx` (`detailNavigation` default): field-heavy → full page **route**, light → **drawer** over the list (URL-addressable via `?recordId=`). Browser-verified on `field_zoo` (57 fields → page) and `product` (6 → drawer). -- Explicit overrides already exist and win over the derivation: `navigation.mode` / `navigation.size` on the list view, `FormView.type` / `modalSize`, or an assigned record `Page`. - -What #2578 deliberately did **not** design (this document's scope): - -| Flow | Behavior today | Gap | -|---|---|---| -| List → **New** (create) | hardcoded large modal | ignores the derivation; return-on-save unspecified | -| Detail → **Edit** | modal, not wired to the derivation | surface + return undesigned | -| Subtable (related list) → New / Edit **child** | untouched | surface + return undesigned | -| **Return** from any of the above | ad-hoc | no contract, never browser-verified | - -("Subtable" here = the parent detail page's **related list** of `master_detail`/`lookup` children — the `relatedList` role, #2594. The *write-side* `inlineEdit` grid lives **inside** the parent's own form and is atomic with it; it is untouched by this design — see §7.) - ---- - -## 2. The one decision that drives everything: route vs overlay - -A **route** buys deep-linkability, refresh-safety and browser-back — properties of **state you'd share or revisit**. It costs explicit return wiring (origin state), and nested flows need a return *stack*. - -An **overlay** (modal/drawer) buys trivial, lossless return — close and you are exactly where you were, scroll + filters + tab intact. It costs deep-linkability, which a **transient task** doesn't need: a URL to a half-filled form is not shareable state (refresh loses the draft regardless), and nobody bookmarks "the act of editing". - -So the split falls out of what each flow *is*: - -> **Viewing a record is state → route-capable. Making/changing a record is a task → always an overlay.** - -This is also the cheapest correct system: the return-flow invariants in §4 come *for free* from overlays, while the route alternative would require origin-state wiring for create/edit plus a return stack for subtable flows — machinery whose only payoff is deep-linking a transient task. - -### Decisions (the three questions in #2604) - -**D1 — Create + Edit surface: overlay, never a route.** The *size* of the overlay still follows the #2578 derivation — that is what makes create/edit "consistent with the shipped detail behavior" without copying its routing: - -| `deriveRecordSurface(def)` | Detail (view) — shipped | Create / Edit — this design | -|---|---|---| -| `'page'` (field-heavy) | full-page **route** | **full-screen modal** (`size: 'full'`) — same big canvas, overlay return semantics | -| `'drawer'` (light) | drawer over the list | drawer/modal overlay, size `'auto'` | -| mobile (any) | page | full-screen modal | - -i.e. one rule: *create/edit maps the derived `'page'` surface to a full-screen **modal**.* This is exactly why `'modal'` exists in the `RecordSurface` type. The route alternative for create/edit is **rejected** (see §8). - -**D2 — Detail → Edit: the same edit overlay, opened over the detail route.** Not an in-place view↔edit mode. One edit surface everywhere (list-row edit, detail edit, subtable child edit) means one code path, one return contract, one thing to verify — the right shape for a zero-config platform where no human tunes divergent surfaces per object. Save/cancel closes the overlay back onto the detail view state — never anywhere else. In-place (field-level inline) editing is a valuable *orthogonal* enhancement, deferred, not rejected (§7). - -**D3 — Subtable child create/edit: overlay over the parent detail. Never a route.** Confirmed as the issue recommends. The return target of a child task is *always* the parent detail with the subtable refreshed; a child route would discard the parent context (scroll, active tab) and force a return stack. The child overlay's size derives from the **child object's** own field count (a heavy child gets the full-screen modal, a thin child a drawer) — same rule as D1, applied to the child's definition. - ---- - -## 3. Why not a route for create/edit — the argument, recorded - -1. **Return is the invariant; deep-link is the nice-to-have.** Every flow in §4 must end back at its origin with context intact. Overlays satisfy this by construction. Routes satisfy it only with origin-state plumbing — which exists for detail ("← all records") but would need to grow a *stack* for parent → child → (lookup-create…) nesting. -2. **A create/edit URL is a false promise.** Refresh or share it and the draft is gone — the URL names the *task*, not the *state*. Deep-linking `/record/:id` (shipped) already covers the shareable thing. -3. **Browser-back is handled, not lost** (§4.4): the full-screen modal pushes one history entry so Back = close (with dirty guard). Users on a full-screen surface *will* press Back; that must not abandon the origin or silently drop a draft. -4. **Salesforce-shaped precedent:** record create/edit are modals over the origin; the record page is the route. Users' muscle memory matches D1. -5. **Zero authoring stays zero.** No `recordSurface`-like key, no return config. Per-object override remains the sanctioned pair: `navigation.mode` (explicit `page` forces routed create/edit for whoever truly wants it) and assigned Pages. ADR-0085 §2 is unchanged. - ---- - -## 4. The return-flow contract - -Three invariants, stated once, applying to every flow: - -- **Cancel invariant.** Cancel / X / Esc / Back → overlay closes → the origin surface *exactly* as it was: scroll, filters, pagination, selected tab, drawer state. Nothing refetched, nothing written. -- **Save invariant.** *Edit never moves you; create takes you to the record you made; child tasks never leave the parent.* Precisely: - - **Create (top-level):** overlay closes → navigate to the **new record's detail** on *its* derived surface. For a light object that is the drawer **over the still-intact list**; for a heavy object it is the detail route (which already carries the "← all records" origin affordance). Rationale: post-create work continues *on* the record — most immediately, populating its subtables, which per D3 happen over its detail. The record is also the immediate visual proof of what was saved. - - **Edit (from anywhere):** overlay closes → **origin, refreshed** (detail → that detail refetched; list-row edit → the list refetched). Same position, same context. - - **Child create/edit (subtable):** overlay closes → **parent detail untouched except the subtable refetches**. Never the child's own detail, never a route change. Parent scroll and active tab preserved. -- **Dirty guard.** Any close gesture (Esc, X, Back, cancel) on a form with unsaved changes asks for confirmation before discarding. Outside-click never closes a form overlay (full-screen modals have no outside; drawers/modals disable it for *forms* — read-only detail drawers keep it). - -### 4.4 Browser history integration - -- **Full-screen modal** (`size: 'full'`): opening pushes **one** history entry; Back requests close (dirty guard applies); after close (or save) the entry is consumed — Back again navigates the underlying route as normal. No URL change is rendered — the entry exists only to catch Back. -- **Drawer / non-full modal:** no history entry (standard overlay semantics — Esc/X close; Back navigates the underlying route, dirty guard still intercepts if the form is dirty). -- **Nested overlays** (child form over parent detail drawer, lookup "create new" over a form): each full-screen layer pushes its own entry; Back peels one layer at a time. This *is* the "return stack" — the browser owns it; we never persist one. - -### 4.5 The flows, end to end - -| # | Origin | Action | Surface (light / heavy child or record) | Cancel → | Save → | -|---|---|---|---|---|---| -| 1 | List | New | drawer / **full-screen modal** | list, untouched | **new record's detail** (drawer over list / route) | -| 2 | List row | Edit | drawer / full-screen modal | list, untouched | list, refetched, position kept | -| 3 | Detail (route or drawer) | Edit | overlay over it | detail view state, untouched | detail view state, **refetched** | -| 4 | Parent detail subtable | New / Edit child | overlay over parent (size from **child** def) | parent, untouched | parent; **subtable refetches**, tab + scroll kept | -| 5 | Any form | lookup "create new" | one more overlay layer | back to the form, field empty | back to the form, field filled with the new record | - -Flow 5 is listed for completeness because it is the same primitive (a create task overlaying its origin) — it must not regress; its return target is the *form field*, not a detail page. - ---- - -## 5. Implementation plan - -### Step 1 — framework (`@objectstack/spec`, additive, unit-tested, independently mergeable) - -Extend `packages/spec/src/data/record-surface.ts` with the flow-aware mapping, so the D1–D3 table is **one shared derivation** (ADR-0085 §5) instead of a convention each renderer re-implements: - -```ts -export type RecordFlow = 'view' | 'create' | 'edit' | 'child-create' | 'child-edit'; - -export interface RecordFlowSurface { - /** 'route' only ever for flow 'view'; every task flow is an overlay. */ - container: 'route' | 'overlay'; - surface: RecordSurface; // 'page' | 'modal' | 'drawer' - size: 'auto' | 'full'; // maps onto navigation.size / modalSize -} - -export function deriveRecordFlowSurface( - def: unknown, // the CHILD def for child-* flows - flow: RecordFlow, - opts?: RecordSurfaceOptions, -): RecordFlowSurface; -``` - -Mapping (pure, total): `view` → today's `deriveRecordSurface` verbatim (`'page'` ⇒ `container: 'route'`); `create`/`edit`/`child-*` → `container: 'overlay'`, with derived `'page'` ⇒ `{ surface: 'modal', size: 'full' }` and `'drawer'` ⇒ `{ surface: 'drawer', size: 'auto' }`; mobile ⇒ task flows get `{ 'modal', 'full' }`. Renderers treat the result as the **default only** — explicit `navigation.mode`/`size`, `FormView.type`/`modalSize`, or an assigned Page win, exactly as today. - -Unit tests: threshold boundary × each flow, mobile override, child def independence, bare/un-parsed defs. Plus a changeset (minor, additive). - -*No lint work:* nothing new is authorable, so there is nothing new to misauthor (the #2595 lints already steer `colSpan`→`span` etc.). - -### Step 2 — objectui (consumes Step 1; one PR; browser-verified) - -1. **Create/edit wiring** (`packages/app-shell/src/views/ObjectView.tsx` — the layer where #2578's detail wiring had to land to take effect in console): replace the hardcoded create/edit modal default with `deriveRecordFlowSurface(def, 'create' | 'edit', { viewport })`. -2. **Detail Edit button** → the same edit overlay over the detail route/drawer (D2); save → refetch detail. -3. **Subtable** (`RecordDetailView` related lists): child New / row Edit → overlay from `deriveRecordFlowSurface(childDef, 'child-*')`; on save close + refetch **only** the related-list query. -4. **Return + guards:** cancel/save per §4; dirty-guard on all close gestures; history entry for full-screen modals (§4.4). -5. Until objectui's pinned `@objectstack/spec` includes Step 1, mirror the helper locally with a `TODO` to re-import — same pattern #2578 used for `deriveRecordSurface` (and swap both together when the pin moves). - -### Step 3 — browser verification (dogfood, blocking; the #2578 objects) - -Using `app-showcase`: heavy = `field_zoo` (57 authorable fields), light = `product` (6); a parent with a `relatedList` child for flow 4. - -- [ ] List → New (heavy): full-screen modal; **Cancel** → list scroll + filter intact; **Save** → new record's detail page; "← all records" returns to the original list state. -- [ ] List → New (light): drawer/modal; Save → detail drawer over the intact list. -- [ ] Detail → Edit (heavy + light): overlay over the detail; Cancel → view state untouched; Save → view state refetched (changed value visible). -- [ ] Parent detail → subtable New child → Save: parent never navigates; subtable shows the new child; active tab + scroll preserved. Same for child row Edit. -- [ ] Browser **Back** with a full-screen create modal open: modal closes (dirty guard if dirty), origin intact; Back again leaves the page normally. -- [ ] Esc / X with dirty form: confirmation appears; confirm-discard → cancel invariant holds. -- [ ] Mobile viewport: create/edit/child open full-screen; same returns. - ---- - -## 6. What this deliberately does *not* add - -- **No new spec keys.** `recordSurface`, `returnTo`, `afterSave`, per-object return config — all fail ADR-0085 §2 (machine-inferable and/or page-scoped). The derivation *is* the config. -- **No new ADR.** Same reasoning as #2578: the whole design lives inside ADR-0085's boundary (derived default + assigned-page/`navigation.mode` override). This document + the changeset are the record. -- **No return stack persistence.** The browser history is the stack (§4.4). - -## 7. Non-goals / deferred (not rejected) - -- **In-place (field-level inline) detail editing** — orthogonal to surfaces/returns; revisit when dogfood demands it. -- **"Save & New"** bulk-entry affordance on the create modal — additive later; thin-child bulk entry already has `inlineEdit: 'grid'`. -- **Draft persistence across refresh** for open forms — separate feature; until then the refresh-loses-draft boundary is accepted and is one more reason create/edit are not routes. -- **`inlineEdit` in-form child grids** — already atomic inside the parent form; unaffected here. - -## 8. Alternatives considered - -- **Create/edit as routes (`/new`, `/record/:id/edit`)** — rejected: pays origin-state + return-stack wiring to deep-link a transient task whose URL is a false promise (§3). Anyone who genuinely wants it can set `navigation.mode: 'page'` — the escape hatch already exists and stays. -- **In-place edit mode for D2** — deferred (§7): a second edit surface with its own return semantics; the modal reuses the one already required for D1/D3. -- **Stay-on-list after create-save (+ toast with a "view" link)** — considered; rejected as the *default* because the dominant post-create action on this platform is continuing on the record (filling subtables per D3), light objects keep the list visible anyway (drawer), and a toast link is a weaker affordance than being there. Revisit via dogfood if bulk create-from-list shows up as a real pattern. -- **Child create/edit as a route with a return stack** — rejected outright (the issue's own analysis): loses parent context, refetches the parent, and builds a stack the browser already provides. From 9c7284c765edd7f518b7b14b061866cce348bfd5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 04:51:42 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat(spec):=20deriveRecordFlowSurface=20?= =?UTF-8?q?=E2=80=94=20flow-aware=20record-surface=20derivation=20(#2604)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019zKcAtbtxuF9SXYgSzjk9v --- .changeset/record-flow-surface.md | 9 +++ packages/spec/api-surface.json | 4 ++ packages/spec/src/data/record-surface.test.ts | 61 +++++++++++++++++++ packages/spec/src/data/record-surface.ts | 59 ++++++++++++++++++ 4 files changed, 133 insertions(+) create mode 100644 .changeset/record-flow-surface.md diff --git a/.changeset/record-flow-surface.md b/.changeset/record-flow-surface.md new file mode 100644 index 0000000000..31781e8baf --- /dev/null +++ b/.changeset/record-flow-surface.md @@ -0,0 +1,9 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): `deriveRecordFlowSurface(def, flow, opts)` — flow-aware record-surface derivation (#2604, extends #2578's `deriveRecordSurface`, ADR-0085 §5 one-shared-derivation). + +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. + +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. diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index de297ad342..03f092895a 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -367,6 +367,9 @@ "RangeOperatorSchema (const)", "Reaction (type)", "ReactionSchema (const)", + "RecordFlow (type)", + "RecordFlowContainer (type)", + "RecordFlowSurface (interface)", "RecordSubscription (type)", "RecordSubscriptionSchema (const)", "RecordSurface (type)", @@ -462,6 +465,7 @@ "defineObjectExtension (function)", "defineSeed (function)", "deriveFieldGroupLayout (function)", + "deriveRecordFlowSurface (function)", "deriveRecordSurface (function)", "fieldForm (const)", "hasDynamicTokens (function)", diff --git a/packages/spec/src/data/record-surface.test.ts b/packages/spec/src/data/record-surface.test.ts index 9e985a38ad..7f919a1f15 100644 --- a/packages/spec/src/data/record-surface.test.ts +++ b/packages/spec/src/data/record-surface.test.ts @@ -3,8 +3,10 @@ import { describe, it, expect } from 'vitest'; import { deriveRecordSurface, + deriveRecordFlowSurface, countAuthorableFields, RECORD_SURFACE_PAGE_THRESHOLD, + type RecordFlow, } from './record-surface'; /** Build an object def with `n` plain text fields named f0..f(n-1). */ @@ -54,6 +56,65 @@ describe('deriveRecordSurface (ADR-0085 §5)', () => { }); }); +describe('deriveRecordFlowSurface (#2604)', () => { + const TASK_FLOWS: RecordFlow[] = ['create', 'edit', 'child-create', 'child-edit']; + const heavy = objWithFields(RECORD_SURFACE_PAGE_THRESHOLD); + const light = objWithFields(RECORD_SURFACE_PAGE_THRESHOLD - 1); + + it("view keeps the #2578 behavior verbatim: heavy → route('page'), light → overlay('drawer')", () => { + expect(deriveRecordFlowSurface(heavy, 'view')).toEqual({ + container: 'route', surface: 'page', size: 'auto', + }); + expect(deriveRecordFlowSurface(light, 'view')).toEqual({ + container: 'overlay', surface: 'drawer', size: 'auto', + }); + }); + + it('task flows never route: heavy → full-screen modal overlay', () => { + for (const flow of TASK_FLOWS) { + expect(deriveRecordFlowSurface(heavy, flow)).toEqual({ + container: 'overlay', surface: 'modal', size: 'full', + }); + } + }); + + it('task flows on a light object stay a drawer overlay', () => { + for (const flow of TASK_FLOWS) { + expect(deriveRecordFlowSurface(light, flow)).toEqual({ + container: 'overlay', surface: 'drawer', size: 'auto', + }); + } + }); + + it('mobile: view routes to a page; task flows become a full-screen modal', () => { + expect(deriveRecordFlowSurface(light, 'view', { viewport: 'mobile' })).toEqual({ + container: 'route', surface: 'page', size: 'auto', + }); + for (const flow of TASK_FLOWS) { + expect(deriveRecordFlowSurface(light, flow, { viewport: 'mobile' })).toEqual({ + container: 'overlay', surface: 'modal', size: 'full', + }); + } + }); + + it('child-* flows size to the def they are given (the child), independent of any parent', () => { + // A thin child stays a drawer even though its parent (not passed) is heavy. + expect(deriveRecordFlowSurface(objWithFields(3), 'child-create').surface).toBe('drawer'); + // A fat child gets the full-screen modal. + expect(deriveRecordFlowSurface(objWithFields(40), 'child-edit')).toEqual({ + container: 'overlay', surface: 'modal', size: 'full', + }); + }); + + it('honours pageThreshold and tolerates bare/malformed input', () => { + expect(deriveRecordFlowSurface(objWithFields(5), 'create', { pageThreshold: 4 }).size).toBe('full'); + expect(deriveRecordFlowSurface(null, 'create')).toEqual({ + container: 'overlay', surface: 'drawer', size: 'auto', + }); + expect(deriveRecordFlowSurface({ fields: 'nope' } as unknown, 'view').surface).toBe('drawer'); + }); +}); + describe('countAuthorableFields', () => { it('counts visible non-system fields only', () => { expect(countAuthorableFields(objWithFields(5))).toBe(5); diff --git a/packages/spec/src/data/record-surface.ts b/packages/spec/src/data/record-surface.ts index dcb789fe92..d35bca646c 100644 --- a/packages/spec/src/data/record-surface.ts +++ b/packages/spec/src/data/record-surface.ts @@ -91,3 +91,62 @@ export function deriveRecordSurface(def: unknown, opts: RecordSurfaceOptions = { if (countAuthorableFields(def) >= threshold) return 'page'; return 'drawer'; } + +/** + * The record flow being opened. `view` shows state; the other four perform a + * task (create/change a record). For `child-*` flows — a subtable / related- + * list child created or edited from its PARENT's detail — pass the CHILD + * object's def: the overlay sizes to the record being edited, while the + * return target is always the parent (#2604 D3). + */ +export type RecordFlow = 'view' | 'create' | 'edit' | 'child-create' | 'child-edit'; + +/** How the surface is mounted: a navigated route, or an overlay over the origin. */ +export type RecordFlowContainer = 'route' | 'overlay'; + +export interface RecordFlowSurface { + /** + * `'route'` only ever for flow `'view'` (a record is shareable state — + * deep-linkable, refresh-safe). Every task flow is an `'overlay'`: close + * returns to the origin with its context (scroll / filters / tab) intact, + * which is the #2604 return-flow invariant — and a create/edit URL would be + * a false promise anyway (refresh loses the draft). + */ + container: RecordFlowContainer; + surface: RecordSurface; + /** Maps onto `navigation.size` / `FormView.modalSize`; routes ignore it. */ + size: 'auto' | 'full'; +} + +/** + * Derive the DEFAULT surface for a record FLOW (#2604; extends + * {@link deriveRecordSurface}, ADR-0085 §5 "one shared derivation"). + * + * Rule — the two axes are independent: + * - how BIG (field count, via {@link deriveRecordSurface}) is unchanged; + * - whether it ROUTES is decided by what the flow *is*: viewing a record is + * state → route-capable; making/changing one is a task → always overlay. + * + * So `view` keeps the #2578 behavior verbatim (`'page'` → route), while the + * task flows map the derived `'page'` to a FULL-SCREEN MODAL — same big + * canvas, overlay return semantics. This mapping is why `'modal'` exists in + * {@link RecordSurface} without the base heuristic ever emitting it. + * + * Like the base derivation this is a DEFAULT only: explicit `navigation.mode` + * / `navigation.size`, `FormView.type` / `modalSize`, or an assigned page win + * (the sanctioned per-object overrides — no new authorable key, ADR-0085 §2). + */ +export function deriveRecordFlowSurface( + def: unknown, + flow: RecordFlow, + opts: RecordSurfaceOptions = {}, +): RecordFlowSurface { + const surface = deriveRecordSurface(def, opts); + if (flow === 'view') { + return { container: surface === 'page' ? 'route' : 'overlay', surface, size: 'auto' }; + } + // Task flows (create / edit / child-*): never a route. Field-heavy (or + // mobile, where the base derivation says 'page') → full-screen modal. + if (surface === 'page') return { container: 'overlay', surface: 'modal', size: 'full' }; + return { container: 'overlay', surface, size: 'auto' }; +}