Skip to content

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.

  • ux-prototype worktree on jmpicnic/image-upload-frontend branch (rebased onto seb/inline-card-image-upload)
  • documentation worktree on jmpicnic/image-upload-frontend branch
  • 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:

  1. 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).

  2. State machine — PlantUML state diagram showing the EditPhase transitions (idleeditingconfirmingerror) 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)
  3. 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 contextErrors flow downward while confirmed values flow upward
  4. 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
  5. 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
  6. useDraft<T> hook — the core hook with its interface, behavior contract, and usage examples (leaf component, parent component with contextual validation)

  7. 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)

  8. 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.

  9. Future extensions — brief pointers to the deferred framework (#77): useComposedDraft<T>, createCellEditorFactory<T>, EditablePanel<T>, validation function convention

Source material for this documentation:

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 model
  • edit-lifecycle/types.md — type catalog, useDraft<T> hook reference
  • edit-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):

  • FieldError has required field and message string fields
  • FieldError.severity accepts 'error' and 'warning' only
  • ValidationResult.valid is boolean, errors is FieldError[]
  • Validator<T> is callable with T and returns ValidationResult
  • EditPhase is a union of exactly 4 string literals
  • EditLifecycleCallbacks<T> has optional onChange, onConfirm, onCancel
  • EditableComponentProps<T> extends EditLifecycleCallbacks<T> — confirm that a value satisfying EditableComponentProps<string> also satisfies EditLifecycleCallbacks<string>
  • EditableComponentProps<T>.contextErrors accepts FieldError[]

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 initialValue changes. Uses isEqual if provided; otherwise uses reference equality (Object.is). When using reference equality, the parent must memoize initialValue to prevent unwanted resets on re-render.
  • Runs validate() on every update()/updateField() call
  • Merges contextErrors into allErrors
  • confirm() only succeeds if isValid is true
  • cancel() resets draft to initialValue and calls onCancel
  • errorsFor(field) filters allErrors by field path prefix
  • Uses setNestedField from T-3 for updateField()

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:

  • value matches initialValue
  • phase is 'idle'
  • dirty is false
  • intrinsicValidation reflects validation of initial value
  • isValid reflects validation of initial value
  • allErrors matches intrinsic errors (no contextErrors)

update():

  • After update(newValue): value is newValue, phase is 'editing', dirty is true
  • Validation runs: updating to invalid value produces errors in intrinsicValidation
  • onChange callback 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 field
  • updateField('postalCode', '12345') updates another field
  • Validation runs after field update
  • Sibling fields are preserved

confirm() — valid:

  • When draft is valid: onConfirm called with current value
  • phase becomes 'confirming'

confirm() — invalid:

  • When draft is invalid: onConfirm NOT called
  • phase becomes 'error'
  • allErrors populated

cancel():

  • value resets to initialValue
  • phase becomes 'idle'
  • dirty becomes false
  • onCancel callback called

reset():

  • value resets to initialValue
  • phase becomes 'idle'
  • onCancel NOT called (difference from cancel)

initialValue change:

  • When parent changes initialValue (re-render with new prop): draft resets to new value, phase becomes 'idle', dirty is false

isEqual option:

  • Custom isEqual prevents reset when structurally equal value has new reference: if isEqual(a, b) returns true, the draft is NOT reset even though Object.is(a, b) is false
  • Without isEqual, new reference triggers reset even if structurally equal: two distinct objects with identical fields cause a reset

contextErrors merge:

  • Providing contextErrors adds them to allErrors
  • isValid is false when contextErrors contain non-warning errors
  • isValid is true when contextErrors contain only warnings
  • errorsFor('city') returns matching contextual errors

errorsFor() filtering:

  • Returns errors where field matches exactly or starts with field + '.'
  • 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 in allErrors
  • Error with severity: 'warning' does NOT block confirm() (isValid remains true)
  • Error with severity: 'error' (or no severity) blocks confirm()

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).

Terminal window
make check # lint + typecheck
make 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-1 produces documentation in the documentation worktree.

Terminal window
# In the documentation worktree
make pr-checks # lint + preview build + link check + smoke tests

Commit documentation changes referencing Phase 3.2.

  • 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>, EditPhase types exported
  • setNestedField utility implemented with unit tests passing
  • useDraft<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, siblings
    • useDraft<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-checks passes, changes committed

STOP: Review architecture documentation, lifecycle types, and useDraft<T> hook implementation before proceeding to Phase 3.3 (component updates).

#QuestionOptionsRecommendationDecision
1Location of useDraft: types/canary/utilities/ vs. dedicated hooks/ dirA: alongside types in utilities/, B: new src/hooks/canary/A (colocate with types it depends on, simpler)Decided: A
2updateField deep clone strategyA: shallow spread per level, B: structuredClone, C: immerA (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.
3Architecture doc: single file or split?A: single edit-lifecycle.md, B: split by concernDecide based on length — split if > 300 linesDeferred to implementation

Copyright: (c) Arda Systems 2025-2026, All rights reserved