Note: Moved from #36. Not yet committed to doing this — preserved for future consideration.
Summary
Create a front-end application that connects the form contracts (designed in Storybook) to the mock API server via the generated TypeScript clients. The same .form.yaml files authored and previewed in Storybook become the runtime form configuration, with data fetched from the API instead of static YAML fixtures.
Motivation
All the pieces exist but aren't wired together:
| Layer |
Package |
Status |
| OpenAPI specs |
@safety-net/contracts |
5 specs (applications, persons, households, incomes, users) |
| Mock server |
@safety-net/mock-server |
Express + SQLite on port 1080, CRUD routes auto-generated from specs |
| TypeScript API clients |
@safety-net/clients |
Generated from specs with Zod validation |
| Form contracts + renderer |
@safety-net/harness |
YAML-driven forms with composable layouts |
The harness loads test data from static YAML imports. The mock server exposes REST endpoints. The generated clients provide typed fetch functions. The front end is the glue that connects them.
Design
Contract-driven workflow
Author form contract in Storybook (with static fixtures)
↓
Same .form.yaml drives the front-end app
↓
Data fetched from API via generated TypeScript clients
↓
FormRenderer renders the contract with live data
↓
Submissions write back through the API
The form contracts are the single source of truth for UI layout. Storybook is the design environment; the front end is the runtime environment. Same contracts, different data sources.
App structure
A React app shell with routing that maps URLs to contract-driven views:
/applications → list of applications (data-table)
/applications/:id → FormRenderer with contract + API data
/applications/new → FormRenderer with contract (empty, create mode)
/households → list of households
/households/:id → FormRenderer with contract + API data
Data flow
// List view — fetch from API, render as table
const { data } = await ApplicationsService.listApplications();
// Table component renders rows, click navigates to detail
// Detail view — fetch by ID, render with FormRenderer
const application = await ApplicationsService.getApplication({ id });
<FormRenderer
contract={intakeContract} // same .form.yaml from Storybook
schema={ApplicationZodSchema} // same Zod schema
defaultValues={application} // data from API, not YAML fixture
onSubmit={(values) => ApplicationsService.updateApplication({ id, body: values })}
/>
Scope
Phase 1: Minimal app shell
Phase 2: List views + navigation
Phase 3: Full workflow
Dependencies
Architecture Decisions
The following decisions were made after discussing the open questions in the original issue.
1. New mock-app package (not in harness)
Decision: Create a new packages/mock-app workspace package. Rename packages/harness to packages/form-designer.
Why: Harness has a clear purpose — it's the Storybook-based design environment for authoring and previewing contracts with static fixtures. Adding a runtime app with routing, API calls, and state management muddies that focus. The two packages also have different dependencies (the app needs React Router, API client integration; the designer doesn't) and different build targets (Storybook vs Vite dev server).
Alternatives considered:
- Extend harness — Would bloat harness with routing/API deps, couple design-time and run-time concerns, and create confusion about what harness is for. Rejected.
Resulting package structure:
| Package |
Purpose |
contracts |
OpenAPI specs |
clients |
Generated API clients |
mock-server |
Fake backend (port 1080) |
form-designer |
Storybook — author/preview contracts (renamed from harness) |
mock-app |
React app — contracts + live API |
Both form-designer and mock-app consume the same .form.yaml contracts and Zod schemas. The contracts are the shared interface — authored in the designer, executed in the app.
mock-server stays as its own package because the mock app consumes its API but they run as separate processes with different concerns. The server is also used independently for integration testing in clients.
2. Explicit app-level route manifest (no convention-based routing)
Decision: An explicit routes.yaml manifest in the mock-app package maps URL paths to contract IDs. Contracts stay route-free — no route field in .form.yaml.
Why: Routes are a developer/app concern, not something a form designer should think about. Keeping contracts route-free means they stay purely about what to render, while the manifest handles where to render it. Multiple apps could use the same contracts with different route structures (e.g., an applicant-facing app and a caseworker portal both referencing application-intake at different paths). The manifest is also easy to scan — a developer can see the app's full surface area in one file.
Example:
# packages/mock-app/routes.yaml
routes:
- path: /applications
contract: case-list
- path: /applications/new
contract: application-intake
- path: /applications/:id
contract: caseworker-review
Alternatives considered:
- Convention-based (derive routes from schema/ID) — Sounds appealing but fragile. Deriving
/applications from schema: Application requires pluralization and case conversion that breaks easily (what route does schema: ExpeditedSNAPInfo produce?). Also makes the app's routing implicit — you can't see what routes exist without understanding the convention rules. Rejected.
- Route field in the contract YAML — Mixes app-level concerns into designer-authored files. Not designer-friendly, and couples contracts to a specific app's URL structure. This is atypical — CMS-style systems (Strapi, Contentful, React Admin) all keep routing in app code, not in content/schema definitions. Rejected.
3. Mock-only for now, but don't hardcode it
Decision: The app targets the mock server only. No abstraction layers for hypothetical future backends.
Why: There's no real backend to connect to yet. Building abstraction layers for one would be premature — we'd be guessing at the interface. However, the architecture naturally avoids tight coupling: the app imports from @safety-net/clients (typed service calls like ApplicationsService.getApplication()), not raw fetch('http://localhost:1080/...'). The base URL comes from environment config (VITE_API_URL). If a real backend appears later, changing the env var is all that's needed — the app code doesn't change.
The name mock-app sets the right expectation: this is a reference implementation and living specification for how contracts connect to APIs, not a production app.
Alternatives considered:
- Build backend abstraction layer now — Would require guessing at the interface of a backend that doesn't exist yet. Premature abstraction. The generated clients already provide the right level of indirection. Rejected.
- Support real + mock from the start — No real backend exists. The generated clients + env-based URL config already make switching trivial when the time comes. No extra work needed now. Rejected.
Summary
Create a front-end application that connects the form contracts (designed in Storybook) to the mock API server via the generated TypeScript clients. The same
.form.yamlfiles authored and previewed in Storybook become the runtime form configuration, with data fetched from the API instead of static YAML fixtures.Motivation
All the pieces exist but aren't wired together:
@safety-net/contracts@safety-net/mock-server@safety-net/clients@safety-net/harnessThe harness loads test data from static YAML imports. The mock server exposes REST endpoints. The generated clients provide typed fetch functions. The front end is the glue that connects them.
Design
Contract-driven workflow
The form contracts are the single source of truth for UI layout. Storybook is the design environment; the front end is the runtime environment. Same contracts, different data sources.
App structure
A React app shell with routing that maps URLs to contract-driven views:
Data flow
Scope
Phase 1: Minimal app shell
mock-appworkspace package)routes.yamlmaps URL paths to contract IDs.form.yamlcontracts at build time@safety-net/clientsto FormRendererPhase 2: List views + navigation
Phase 3: Full workflow
Dependencies
npm start/ port 1080)npm run clients:generate)Architecture Decisions
The following decisions were made after discussing the open questions in the original issue.
1. New
mock-apppackage (not in harness)Decision: Create a new
packages/mock-appworkspace package. Renamepackages/harnesstopackages/form-designer.Why: Harness has a clear purpose — it's the Storybook-based design environment for authoring and previewing contracts with static fixtures. Adding a runtime app with routing, API calls, and state management muddies that focus. The two packages also have different dependencies (the app needs React Router, API client integration; the designer doesn't) and different build targets (Storybook vs Vite dev server).
Alternatives considered:
Resulting package structure:
contractsclientsmock-serverform-designermock-appBoth
form-designerandmock-appconsume the same.form.yamlcontracts and Zod schemas. The contracts are the shared interface — authored in the designer, executed in the app.mock-serverstays as its own package because the mock app consumes its API but they run as separate processes with different concerns. The server is also used independently for integration testing inclients.2. Explicit app-level route manifest (no convention-based routing)
Decision: An explicit
routes.yamlmanifest in the mock-app package maps URL paths to contract IDs. Contracts stay route-free — noroutefield in.form.yaml.Why: Routes are a developer/app concern, not something a form designer should think about. Keeping contracts route-free means they stay purely about what to render, while the manifest handles where to render it. Multiple apps could use the same contracts with different route structures (e.g., an applicant-facing app and a caseworker portal both referencing
application-intakeat different paths). The manifest is also easy to scan — a developer can see the app's full surface area in one file.Example:
Alternatives considered:
/applicationsfromschema: Applicationrequires pluralization and case conversion that breaks easily (what route doesschema: ExpeditedSNAPInfoproduce?). Also makes the app's routing implicit — you can't see what routes exist without understanding the convention rules. Rejected.3. Mock-only for now, but don't hardcode it
Decision: The app targets the mock server only. No abstraction layers for hypothetical future backends.
Why: There's no real backend to connect to yet. Building abstraction layers for one would be premature — we'd be guessing at the interface. However, the architecture naturally avoids tight coupling: the app imports from
@safety-net/clients(typed service calls likeApplicationsService.getApplication()), not rawfetch('http://localhost:1080/...'). The base URL comes from environment config (VITE_API_URL). If a real backend appears later, changing the env var is all that's needed — the app code doesn't change.The name
mock-appsets the right expectation: this is a reference implementation and living specification for how contracts connect to APIs, not a production app.Alternatives considered: