Specification: Phase 3.2 — Lifecycle Framework
Introduce the minimal lifecycle framework types, useDraft<T> hook, and
architectural documentation in the ux-prototype repository and the
documentation repository. This is the foundation that Phase 3.3 (component
updates) builds on.
Scope is minimal per FD-03: only types and hooks needed by the image upload
components. The full framework (useComposedDraft<T>, createCellEditorFactory,
EditablePanel<T>, validation composition) is deferred to
#77.
Entry Criteria
Section titled “Entry Criteria”ux-prototypeworktree onjmpicnic/image-upload-frontendbranch (rebased ontoseb/inline-card-image-upload)documentationworktree onjmpicnic/image-upload-frontendbranch- All existing checks pass:
make check,make test,make build
T-1: Architecture documentation (documentation repo)
Section titled “T-1: Architecture documentation (documentation repo)”Create the Edit Lifecycle architecture documentation in
src/content/docs/current-system/architecture/user-interaction/. This is
the permanent reference documentation for the pattern, separate from the
project-specific exploration documents.
Follow the document-writing skill for formatting, front matter, and path
conventions. Use the plantuml-diagrams skill for all diagrams (state
machine, activity, class, composition).
The documentation should include:
File: edit-lifecycle.md — main document covering:
-
Overview — the hierarchical render-edit-validate-update cycle as an architectural pattern for the Arda frontend. Explain the problem it solves (nested editable domain objects, layered validation, draft management) and the design principles (generic over specific, composable, framework- agnostic, opt-in complexity, observable).
-
State machine — PlantUML state diagram showing the
EditPhasetransitions (idle→editing→confirming→error) with triggers and guards. Include all transitions:idle → editing(update/updateField)editing → editing(re-validates on each change)editing → confirming(confirm when valid)editing → error(confirm when invalid)editing → idle(cancel)confirming → idle(parent updates initialValue)confirming → error(parent rejects)error → editing(user edits to fix)error → idle(cancel)idle → idle(initialValue changes)
-
Activity diagram — PlantUML activity diagram showing how an edit cycle propagates through a hierarchy:
- User edits a leaf field → leaf validates intrinsically → leaf pushes
to parent via
onChange→ parent validates contextually → parent pushes to its parent → … → top-level confirms → persist - Include the cancel path and error recovery path
- Show how
contextErrorsflow downward while confirmed values flow upward
- User edits a leaf field → leaf validates intrinsically → leaf pushes
to parent via
-
Validation model — explain the two-layer validation approach:
- Intrinsic validation: component validates based on its own nature
- Contextual validation: parent validates based on sibling/parent context
- How errors are namespaced by dot-path for routing to the correct level
- How
errorsFor(field)filters by prefix
-
Type catalog — the types introduced in this phase with their relationships:
FieldError,ValidationResult,Validator<T>EditPhase,EditLifecycleCallbacks<T>,EditableComponentProps<T>- PlantUML class diagram showing inheritance/composition relationships
-
useDraft<T>hook — the core hook with its interface, behavior contract, and usage examples (leaf component, parent component with contextual validation) -
Hierarchical composition — PlantUML diagram showing how components compose in a concrete example (e.g., Order → Supplier → Address) with data flow annotations (initialValue down, onConfirm up, contextErrors down)
-
Relationship to FD-01 — how the edit lifecycle is orthogonal to the typed data provider pattern. The provider sources initial data; the lifecycle manages the draft. They compose at the boundary.
-
Future extensions — brief pointers to the deferred framework (#77):
useComposedDraft<T>,createCellEditorFactory<T>,EditablePanel<T>, validation function convention
Source material for this documentation:
- abstract-component-lifecycle.md
- hierarchical-edit-lifecycle-exploration.md
- tanstack-component-binding-analysis.md (FD-01 relationship)
The documentation should be authoritative reference — not a project artifact. Write it as if explaining the pattern to an engineer who hasn’t seen the exploration documents. Use the exploration docs as source material but produce standalone documentation.
If the content is too large for a single file, split into a subdirectory:
edit-lifecycle/index.md— overview, state machine, activity diagram, validation modeledit-lifecycle/types.md— type catalog,useDraft<T>hook referenceedit-lifecycle/composition.md— hierarchical composition, FD-01 relationship
T-2: Lifecycle types (src/types/canary/utilities/edit-lifecycle.ts)
Section titled “T-2: Lifecycle types (src/types/canary/utilities/edit-lifecycle.ts)”Follow the typescript-coding skill for strict mode conventions and the
ui-component skill for the Static/Init/Runtime config separation pattern.
Create the new types file in ux-prototype:
/** A single validation error on a specific field. */export interface FieldError { /** Dot-path to the field (e.g., "street1", "supplier.address.postalCode"). */ field: string; /** Human-readable error message. */ message: string; /** Machine-readable code for testing and i18n. */ code?: string; /** Severity — 'error' blocks confirm; 'warning' allows but shows message. */ severity?: 'error' | 'warning';}
/** Result of validating a value. */export interface ValidationResult { /** True if no errors (warnings are allowed). */ valid: boolean; /** List of field-level errors and warnings. */ errors: FieldError[];}
/** A function that validates a value and returns a result. */export type Validator<T> = (value: T) => ValidationResult;
/** The phase of an edit session. */export type EditPhase = 'idle' | 'editing' | 'confirming' | 'error';
/** Callbacks for the edit lifecycle. */export interface EditLifecycleCallbacks<T> { /** Called on every draft change with current validation result. */ onChange?: (value: T, validation: ValidationResult) => void; /** Called when the user confirms and intrinsic validation passes. */ onConfirm?: (value: T) => void; /** Called when the user cancels the edit. */ onCancel?: () => void;}
/** Standard props for any editable component. */export interface EditableComponentProps<T> extends EditLifecycleCallbacks<T> { /** Initial value — from parent's draft or from a data provider. */ initialValue: T; /** Contextual errors injected by the parent after its own validation. */ contextErrors?: FieldError[]; /** Component is disabled (parent is saving, or a sibling edit is active). */ disabled?: boolean;}Export from the canary types barrel (src/types/canary/index.ts or
equivalent).
Unit tests (src/types/canary/utilities/edit-lifecycle.test.ts):
Type-level structural tests using expectTypeOf (from vitest):
FieldErrorhas requiredfieldandmessagestring fieldsFieldError.severityaccepts'error'and'warning'onlyValidationResult.validis boolean,errorsisFieldError[]Validator<T>is callable withTand returnsValidationResultEditPhaseis a union of exactly 4 string literalsEditLifecycleCallbacks<T>has optionalonChange,onConfirm,onCancelEditableComponentProps<T>extendsEditLifecycleCallbacks<T>— confirm that a value satisfyingEditableComponentProps<string>also satisfiesEditLifecycleCallbacks<string>EditableComponentProps<T>.contextErrorsacceptsFieldError[]
T-3: setNestedField utility (src/types/canary/utilities/set-nested-field.ts)
Section titled “T-3: setNestedField utility (src/types/canary/utilities/set-nested-field.ts)”Pure function for dot-path field updates. Shallow-copies each intermediate object (no mutation, per React conventions).
/** * Set a value at a dot-separated path in an object, creating intermediate * objects as needed. Returns a new object — does not mutate the input. */export function setNestedField<T>(obj: T, path: string, value: unknown): T;Unit tests (src/types/canary/utilities/set-nested-field.test.ts):
- Shallow path:
setNestedField({ name: 'a' }, 'name', 'b')→{ name: 'b' } - Nested path:
setNestedField({ address: { city: 'a' } }, 'address.city', 'b')→{ address: { city: 'b' } } - Deep path:
setNestedField({ a: { b: { c: 1 } } }, 'a.b.c', 2)→{ a: { b: { c: 2 } } } - Creates intermediate objects:
setNestedField({}, 'a.b.c', 1)→{ a: { b: { c: 1 } } } - Does not mutate original: verify
Object.is(original, result)is false - Does not mutate intermediate objects: verify original nested objects are unchanged
- Single-segment path:
setNestedField({ x: 1 }, 'x', 2)→{ x: 2 } - Preserves sibling fields:
setNestedField({ a: 1, b: 2 }, 'a', 3)→{ a: 3, b: 2 }
T-4: useDraft<T> hook (src/types/canary/utilities/use-draft.ts)
Section titled “T-4: useDraft<T> hook (src/types/canary/utilities/use-draft.ts)”Implement the hook per the design in the architecture documentation (T-1)
and abstract-component-lifecycle.md
section 3. Follow the unit-tests-frontend skill for test conventions
(Vitest + React Testing Library in ux-prototype).
Interface:
interface UseDraftOptions<T> { /** Initial value — resets the draft when this value changes. */ initialValue: T; /** Intrinsic validator — called on every draft change. */ validate: Validator<T>; /** Contextual errors from parent — merged into the validation display. */ contextErrors?: FieldError[]; /** Lifecycle callbacks — forwarded from EditableComponentProps. */ onChange?: (value: T, validation: ValidationResult) => void; onConfirm?: (value: T) => void; onCancel?: () => void; /** Custom equality check for initialValue. When provided, draft resets only when isEqual returns false. When omitted, uses reference equality (Object.is) — callers must memoize initialValue to prevent unwanted resets. */ isEqual?: (a: T, b: T) => boolean;}
interface DraftState<T> { /** Current draft value. */ value: T; /** Intrinsic validation result of the current draft. */ intrinsicValidation: ValidationResult; /** All errors: intrinsic + contextual, for display. */ allErrors: FieldError[]; /** Whether the draft differs from initialValue. */ dirty: boolean; /** Whether the draft is valid (intrinsic + no contextual errors). */ isValid: boolean; /** Current lifecycle phase. */ phase: EditPhase;
/** Update the draft. Runs validation and notifies parent via onChange. */ update: (updater: T | ((prev: T) => T)) => void; /** Update a single field by dot-path. */ updateField: (path: string, value: unknown) => void; /** Confirm the draft. Calls onConfirm if valid. */ confirm: () => void; /** Cancel the edit. Resets draft to initialValue and calls onCancel. */ cancel: () => void; /** Reset draft to initialValue without calling onCancel. */ reset: () => void; /** Get errors for a specific field (for rendering inline errors). */ errorsFor: (field: string) => FieldError[];}Key behaviors:
- Resets draft when
initialValuechanges. UsesisEqualif provided; otherwise uses reference equality (Object.is). When using reference equality, the parent must memoizeinitialValueto prevent unwanted resets on re-render. - Runs
validate()on everyupdate()/updateField()call - Merges
contextErrorsintoallErrors confirm()only succeeds ifisValidis truecancel()resets draft toinitialValueand callsonCancelerrorsFor(field)filtersallErrorsby field path prefix- Uses
setNestedFieldfrom T-3 forupdateField()
Unit tests (src/types/canary/utilities/use-draft.test.ts):
Use renderHook from @testing-library/react and act for state updates.
Test a sample type:
interface TestAddress { street: string; city: string; postalCode: string;}
const validateAddress: Validator<TestAddress> = (addr) => { const errors: FieldError[] = []; if (!addr.street) errors.push({ field: 'street', message: 'Required' }); if (!addr.city) errors.push({ field: 'city', message: 'Required' }); if (addr.postalCode && !/^\d{5}$/.test(addr.postalCode)) errors.push({ field: 'postalCode', message: 'Invalid format' }); return { valid: errors.length === 0, errors };};Test cases:
Initial state:
valuematchesinitialValuephaseis'idle'dirtyisfalseintrinsicValidationreflects validation of initial valueisValidreflects validation of initial valueallErrorsmatches intrinsic errors (no contextErrors)
update():
- After
update(newValue):valueisnewValue,phaseis'editing',dirtyistrue - Validation runs: updating to invalid value produces errors in
intrinsicValidation onChangecallback is called with(newValue, validationResult)
update() with function updater:
update(prev => ({ ...prev, city: 'New York' }))correctly applies the updater
updateField():
updateField('city', 'Springfield')updates nested fieldupdateField('postalCode', '12345')updates another field- Validation runs after field update
- Sibling fields are preserved
confirm() — valid:
- When draft is valid:
onConfirmcalled with current value phasebecomes'confirming'
confirm() — invalid:
- When draft is invalid:
onConfirmNOT called phasebecomes'error'allErrorspopulated
cancel():
valueresets toinitialValuephasebecomes'idle'dirtybecomesfalseonCancelcallback called
reset():
valueresets toinitialValuephasebecomes'idle'onCancelNOT called (difference fromcancel)
initialValue change:
- When parent changes
initialValue(re-render with new prop): draft resets to new value,phasebecomes'idle',dirtyisfalse
isEqual option:
- Custom
isEqualprevents reset when structurally equal value has new reference: ifisEqual(a, b)returnstrue, the draft is NOT reset even thoughObject.is(a, b)isfalse - Without
isEqual, new reference triggers reset even if structurally equal: two distinct objects with identical fields cause a reset
contextErrors merge:
- Providing
contextErrorsadds them toallErrors isValidisfalsewhen contextErrors contain non-warning errorsisValidistruewhen contextErrors contain only warningserrorsFor('city')returns matching contextual errors
errorsFor() filtering:
- Returns errors where
fieldmatches exactly or starts withfield + '.' errorsFor('address')matches'address','address.city','address.postalCode'but not'addressLine2'- Returns empty array when no errors match
Warning severity:
- Error with
severity: 'warning'appears inallErrors - Error with
severity: 'warning'does NOT blockconfirm()(isValidremainstrue) - Error with
severity: 'error'(or no severity) blocksconfirm()
T-5: Export from canary barrel
Section titled “T-5: Export from canary barrel”Add exports to the canary entry point so they’re available as:
import { useDraft } from '@arda-cards/design-system/canary';import type { ValidationResult, FieldError, EditableComponentProps, EditPhase } from '@arda-cards/design-system/types/canary';Also export setNestedField from the canary barrel (useful for components
that build on the framework).
T-6: Full checks (ux-prototype)
Section titled “T-6: Full checks (ux-prototype)”make check # lint + typecheckmake test # unit tests (must include new tests from T-2, T-3, T-4)make build # Storybook build (library build not needed until Phase 3.4)T-7: Documentation checks and commit
Section titled “T-7: Documentation checks and commit”T-1 produces documentation in the documentation worktree.
# In the documentation worktreemake pr-checks # lint + preview build + link check + smoke testsCommit documentation changes referencing Phase 3.2.
Exit Criteria
Section titled “Exit Criteria”- Architecture documentation published in
documentation/src/content/docs/current-system/architecture/user-interaction/with state machine, activity diagram, validation model, type catalog, hook reference, and composition diagrams ValidationResult,FieldError,EditLifecycleCallbacks<T>,EditableComponentProps<T>,Validator<T>,EditPhasetypes exportedsetNestedFieldutility implemented with unit tests passinguseDraft<T>hook implemented with all specified behaviors- All unit tests pass:
- Type-level structural tests for lifecycle types
setNestedField— 8+ test cases covering paths, immutability, siblingsuseDraft<T>— 15+ test cases covering all state transitions, validation, callbacks, error merging, warning severity
- Full local checks pass:
make check,make test,make build - No existing test regressions
- Documentation worktree:
make pr-checkspasses, changes committed
STOP: Review architecture documentation, lifecycle types, and useDraft<T>
hook implementation before proceeding to Phase 3.3 (component updates).
Open Questions and Decisions
Section titled “Open Questions and Decisions”| # | Question | Options | Recommendation | Decision |
|---|---|---|---|---|
| 1 | Location of useDraft: types/canary/utilities/ vs. dedicated hooks/ dir | A: alongside types in utilities/, B: new src/hooks/canary/ | A (colocate with types it depends on, simpler) | Decided: A |
| 2 | updateField deep clone strategy | A: shallow spread per level, B: structuredClone, C: immer | A (lightweight, matches React conventions) | Decided: A — shallow spread per level following React best practices. Only objects on the changed path are copied; sibling subtrees retain referential identity, which is a correctness requirement for React.memo and dependency arrays. |
| 3 | Architecture doc: single file or split? | A: single edit-lifecycle.md, B: split by concern | Decide based on length — split if > 300 lines | Deferred to implementation |
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved