# View Rendering Architecture

**One pattern library. Three consumers. Same dev GUI.**

This guide explains how SpecVerse renders dev GUIs (list, detail, form, dashboard, board, timeline, calendar, analytics, …) across three different execution models without duplicating the underlying design.

## The problem

A `.specly` spec describes WHAT a system does — its models, controllers, events, views. From that spec we need a working dev GUI in three contexts:

1. **App-demo** (the runtime interpreter): load a spec → see a running GUI, no build step. Fast feedback loop for spec authors.
2. **Realized app, runtime mode**: produce a thin, deployable app whose dev GUI renders at browser-runtime from a bundled spec. Minimal generated code; pattern updates via `npm update @specverse/runtime`.
3. **Realized app, starter-kit mode**: produce a full React codebase the user can read, fork, and extend. Idiomatic source files, each view a real component, no hidden magic.

If each context reimplements "what a list view looks like", they drift — and a fix that lands in app-demo doesn't reach the realized apps, and vice versa. This has happened repeatedly during the 2026-04 runtime extraction.

**The rule**: there is exactly one source of truth for dev GUI design. All three consumers read from it. They differ only in *when* rendering happens and *what shape* the output takes.

---

## The pattern library — single source of truth

Lives in `@specverse/runtime/views/core`. Purely framework-agnostic TypeScript. No React, no Vue, no browser APIs.

```
runtime/src/runtime/views/core/
├── composite-patterns.ts          # list / detail / form / dashboard / board / timeline / ...
├── atomic-components-registry.ts  # 49 atomic UI element types (table, card, input, badge, …)
├── composite-pattern-types.ts     # typed descriptors
├── field-classification.ts        # business / relationship / lifecycle / metadata
├── entity-display.ts              # FK → display-name resolution
└── pattern-engine.ts              # pattern detection and composition
```

Everything downstream — React adapter, Tailwind adapter, starter-kit emitter — consumes from this directory. Nothing here imports from React, Tailwind, or any of the consumer packages.

A **composite pattern** describes a view at the structural level: "a list view has a search bar, a filter panel, a data table with columns derived from model attributes, and an action toolbar." An **atomic component** is a leaf element (table, card, form-group, badge). The library defines how atomic components compose into a composite pattern for each view type, and how fields map to components.

**Inference rules** that sit alongside the pattern library live in `specverse-engines/entities/src/core/views/inference/`:

- `view-rules.json` — which dev-default views to auto-generate from models (profile-aware form view, automatic CRUD views)
- `component-mappings.json` — how model attribute types map to atomic components
- `specialist-views.json` — expansion templates for dashboard / analytics / board / timeline / etc.

The inference engine (`@specverse/engines/inference`) consumes these rules to expand a spec: for every model without a user-defined view, it generates the dev defaults. User-defined views from the `.specly` file merge on top. The three consumers all receive the same expanded spec as input.

---

## The build-time renderer

Lives in `@specverse/runtime/views/tailwind`. Takes a pattern + context, returns Tailwind-styled HTML strings. Framework-agnostic — no React, no DOM, just string composition.

```typescript
import { createUniversalTailwindAdapter } from '@specverse/runtime/views/tailwind';

const adapter = createUniversalTailwindAdapter({ darkMode: true });
const html = adapter.components.table.render({
  properties: { columns: [...], data: [...] }
});
// → '<table class="w-full text-left ..."> ... </table>'
```

This adapter is what lets us render a pattern at **build time** (as a string) as well as at **runtime** (as React elements). Both consumers of the renderer call into the same composite-patterns + atomic-components-registry; they just produce different outputs.

---

## The three consumers

### Consumer 1 — App-demo (runtime interpreter)

**Location**: `specverse-app-demo/frontend-react/`.

**Flow**: load a `.specly` file → backend parses + exposes via `/api/spec` → frontend fetches expanded spec → React tree renders using the React pattern adapter.

**Key imports** (from `@specverse/runtime/views/react`):

- `DevShell` — top-level layout
- `RuntimeViewProvider` — context for runtime services (queries, mutations, entity sync)
- `ViewRouter` — routes between views
- `RuntimeView` — renders a display view (list/detail/dashboard) by looking up the pattern and calling `react-pattern-adapter`
- `FormView` — renders an interactive form view
- `ModelManager` — CRUD UI
- `ReactPatternAdapter` (internal) — the React-specific implementation of the pattern library

**What it doesn't do**: hold its own patterns, its own field classification, its own entity-display logic. Every one of those concerns is imported from `@specverse/runtime/views/core`.

**When rendering happens**: browser-runtime, every render cycle. Hot spec reloads re-render live.

### Consumer 2 — Realized app, runtime mode

**Factory**: `ReactAppRuntime` (`specverse-engines/engines/libs/instance-factories/applications/react-app-runtime.yaml`).

**Flow**: `spv realize` generates a *slim* React app (~10 files). The generated `App.tsx` imports `DevShell` + `RuntimeViewProvider` from `@specverse/runtime/views/react` and wires in instance-specific API hooks (REST / GraphQL / depending on backend factory). The inferred spec is bundled as `dev.specly` and imported at app startup. From then on, the app runs the same code path as app-demo.

**What's generated**:

```
frontend/
├── src/
│   ├── App.tsx                   # slim — imports DevShell, wires useEntitySync
│   ├── main.tsx
│   ├── hooks/useApi.ts           # generated: REST hooks (instance-factory-specific)
│   ├── lib/apiClient.ts          # generated: REST endpoints
│   ├── types/api.ts              # generated: TypeScript types from the spec
│   └── dev.specly                # bundled: inferred spec with dev defaults + user views
├── package.json                  # depends on @specverse/runtime
├── vite.config.ts
├── tsconfig.json
└── index.html
```

**What isn't generated**: view components, pattern adapters, field helpers, relationship fields, Tailwind adapters — all of that is imported from `@specverse/runtime`.

**When rendering happens**: browser-runtime, same as app-demo. The pattern library is evaluated fresh on each render.

**Update path**: fix a bug in a pattern → publish new `@specverse/runtime` → user runs `npm update` in the generated app → fix applies. No regenerate needed.

### Consumer 3 — Realized app, starter-kit mode

**Factory**: `ReactAppStarter` (live since engines@4.2.0).

**Flow**: `spv realize` generates a *full* React codebase. For every view in the expanded spec (dev defaults + user views), the factory runs the pattern through the Tailwind adapter **at build time**, wraps the rendered HTML in an idiomatic React component, and emits a `.tsx` source file. Users can read, fork, and edit these files.

**What's generated**:

```
frontend/
├── src/
│   ├── App.tsx                             # wires routing between generated views
│   ├── views/
│   │   ├── PostListView.tsx                # ~120 lines of idiomatic React
│   │   ├── PostDetailView.tsx
│   │   ├── PostFormView.tsx
│   │   ├── CommentListView.tsx
│   │   ├── CommentDetailView.tsx
│   │   ├── CommentFormView.tsx
│   │   └── ...                             # one file per view
│   ├── lib/                                # generated: local utilities, inlined from pattern library
│   │   ├── entity-display.ts               # getEntityDisplayName etc.
│   │   ├── field-helpers.ts                # isAutoGeneratedField etc.
│   │   └── apiClient.ts
│   ├── hooks/useApi.ts
│   └── types/api.ts
├── .specverse-gen/
│   └── manifest.json                       # content hashes of every generated file (for regeneration safety)
├── package.json                            # NO @specverse/runtime dep — fully standalone
└── (same infra as above)
```

**Fully standalone output**: Factory B's generated code has *no* dependency on `@specverse/runtime`. Every utility the rendered views need — `getEntityDisplayName`, `isAutoGeneratedField`, `classifyFields` etc. — is emitted into `src/lib/` as local TypeScript source, rendered from the same pattern library at build time. This means the user can fork the project, delete `@specverse/runtime` entirely, and walk away with a working React app they fully own. The tradeoff is that pattern library updates don't reach them automatically — they have to re-run `spv realize` to regenerate (which is the whole point of the "starter kit" mode).

**A generated view file looks like this** (sketch):

```tsx
// PostListView.tsx — generated starter code. Safe to edit.
import { useState, useMemo } from 'react';
import { usePostsQuery, useDeletePostMutation } from '../hooks/useApi';
import type { Post } from '../types/api';

interface PostListViewProps {
  onSelect?: (post: Post) => void;
}

export function PostListView({ onSelect }: PostListViewProps) {
  const { data: posts = [], isLoading } = usePostsQuery();
  const deletePost = useDeletePostMutation();
  const [searchTerm, setSearchTerm] = useState('');

  const filtered = useMemo(
    () => posts.filter(p => p.title.toLowerCase().includes(searchTerm.toLowerCase())),
    [posts, searchTerm]
  );

  if (isLoading) return <div className="p-4">Loading…</div>;

  return (
    <div className="p-6">
      <div className="mb-4 flex justify-between">
        <input
          type="search"
          placeholder="Search posts…"
          value={searchTerm}
          onChange={e => setSearchTerm(e.target.value)}
          className="rounded border px-3 py-2 ..."
        />
        <button className="rounded bg-blue-600 px-4 py-2 text-white ...">New post</button>
      </div>

      <table className="w-full text-left ...">
        <thead>
          <tr>
            <th className="...">Title</th>
            <th className="...">Author</th>
            <th className="...">Status</th>
            <th className="...">Created</th>
            <th className="..."></th>
          </tr>
        </thead>
        <tbody>
          {filtered.map(post => (
            <tr key={post.id} onClick={() => onSelect?.(post)} className="...">
              <td className="...">{post.title}</td>
              <td className="...">{post.authorName /* resolved from authorId */}</td>
              <td className="..."><span className="rounded bg-green-100 px-2 py-1 ...">{post.status}</span></td>
              <td className="...">{formatDate(post.createdAt)}</td>
              <td className="..."><button onClick={() => deletePost.mutate(post.id)}>Delete</button></td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
```

The structure, classNames, field order, relationship resolution, and lifecycle-badge colouring all come from the pattern library — rendered once at build time and frozen into source. The component itself is idiomatic React that a human can read and edit without knowing anything about SpecVerse.

**When rendering happens**: build time. The browser just runs a normal React app with hand-shaped components; it doesn't need to know about patterns, adapters, or inference.

**Update path**: fix a bug in a pattern → publish new `@specverse/runtime` → developer runs `spv realize` again → files regenerate → developer diffs and chooses what to keep. Updates are deliberate, not automatic.

**User extensions**: since the generated code is idiomatic React, the user can add a filter, reshape the table, introduce new columns, etc. Regeneration respects these edits via **content hashing** — see "Regeneration safety" below.

### Regeneration safety (Factory B)

At every generation, Factory B writes an SHA-256 hash of each file it produces to `.specverse-gen/hashes.json`. On the next `spv realize`:

1. For each file the factory would emit, compute the hash of what's currently on disk.
2. If the disk hash matches the recorded "last-generated" hash → the user hasn't touched it. Safe to overwrite with the new rendering.
3. If the disk hash differs → the user edited the file. Skip it with a warning:
   `⚠  Skipped src/views/PostListView.tsx — user-edited since last generation.`
   `   Run \`spv realize --force PostListView\` to overwrite, or delete the file to opt into regeneration.`
4. For files that don't exist yet → write them and record the hash as usual.
5. Update the manifest with hashes of all written files.

This gives the user the simple mental model: *"files I've touched won't be overwritten; files I haven't are fair game."* No merge conflicts, no surprises. If they want to adopt an upstream pattern change for a file they've edited, they delete their local copy and regenerate — or they do the merge manually.

The `.specverse-gen/` directory sits alongside the generated code and is checked into version control. Deleting it causes the next `spv realize` to treat all files as user-edited (safe default — never clobber work).

---

## Data flow

```
  ┌────────────────┐
  │  user.specly   │
  └───────┬────────┘
          │
          ▼
  ┌────────────────┐
  │ @specverse/    │
  │ engines/parser │   parses + convention-processes
  └───────┬────────┘
          │
          ▼
  ┌────────────────────────┐
  │ @specverse/            │
  │ engines/inference      │   merges dev-default views + user views
  │                        │   using rules from entities/core/views/inference/
  └───────┬────────────────┘
          │
          │       (expanded spec — same shape delivered to all three consumers)
          │
          ├──────────────┬──────────────────┬──────────────────┐
          │              │                  │                  │
          ▼              ▼                  ▼                  ▼
     ┌─────────┐   ┌────────────┐    ┌──────────────┐    ┌──────────────┐
     │ app-    │   │ Factory A  │    │ Factory B    │    │  (others —   │
     │ demo    │   │ ReactApp   │    │ ReactApp     │    │   Vue,       │
     │         │   │ Runtime    │    │ Starter      │    │   Svelte,    │
     │         │   │            │    │              │    │   …)         │
     └────┬────┘   └─────┬──────┘    └──────┬───────┘    └──────────────┘
          │              │                  │
          │ browser-     │ browser-         │ build-time
          │ runtime      │ runtime          │ rendering
          │ render       │ render (slim     │ → idiomatic
          │              │  shell imports   │   React source
          │              │  runtime)        │   files
          │              │                  │
          └──────────────┴──────────────────┘
                         │
                         ▼
               same dev GUI rendered,
               same HTML structure,
               same field ordering,
               same Tailwind classes
```

The unifying invariant: **every consumer takes the expanded spec + the pattern library and produces equivalent HTML.** They differ in execution model, not in design.

---

## Invariants (the architectural rules)

These are the invariants that keep the system "one pattern library, three consumers." Every change should preserve them.

**I1 — No consumer reimplements pattern logic.**
Field classification, entity display resolution, atomic component rendering, pattern composition — all of it comes from `@specverse/runtime/views/core` or `@specverse/runtime/views/tailwind`. A consumer that reimplements any of it is creating drift.

**I2 — Inference produces one expanded spec for all consumers.**
`view-rules.json`, `component-mappings.json`, `specialist-views.json` are read by the inference engine once. The expanded spec is the API contract between inference and consumers. No consumer re-runs inference-shaped logic on its side.

**I3 — The pattern library has no framework dependencies.**
`runtime/views/core` imports nothing from React, Vue, Tailwind, DOM, or any package that assumes a browser. This is what makes it consumable from a build-time generator.

**I4 — The Tailwind adapter is the canonical build-time renderer.**
Anything that needs to render a pattern without a browser uses `@specverse/runtime/views/tailwind`. Factory B uses it. Documentation generators can use it. Screenshot tooling can use it. Same output everywhere.

**I5 — Factory generators are consumers of the pattern library, not reimplementations of it.**
A factory's TypeScript generator MUST import pattern library modules and call them. If it hardcodes JSX strings or duplicates pattern constants, that's a bug.

**I6 — Factory output is tested for parity with runtime rendering.**
A test harness runs the same spec through app-demo and through each realize factory, captures rendered HTML, and compares. Drift = test failure. See `tests/integration/view-parity.test.ts` (to be added).

---

## Extension points

### Adding a new composite view pattern

Add it to the pattern library in one place:

1. Define the pattern structure in `runtime/src/runtime/views/core/composite-patterns.ts`. Describe which atomic components compose it and how.
2. Register an expansion template in `entities/src/core/views/inference/specialist-views.json` so inference knows how to expand it for a given model.
3. Add a rendering rule in the React adapter (`runtime/src/runtime/views/react/react-pattern-adapter.tsx`) if the pattern needs React-specific behaviour (hooks, context).
4. Add Tailwind rendering in `runtime/src/runtime/views/tailwind/universal-adapter.ts` if any atomic pieces are new.
5. All three consumers now support the pattern. No factory changes needed.

### Adding a new atomic component

Add it to `runtime/src/runtime/views/core/atomic-components-registry.ts` and register its Tailwind rendering in `runtime/src/runtime/views/tailwind/universal-adapter.ts`. The React adapter picks it up via the registry.

### Adding a new consumer (e.g., Vue frontend)

Create a factory (`ReactAppRuntime` is the model for runtime-mode; `ReactAppStarter` is the model for starter-kit mode). The factory's generators must import from `@specverse/runtime/views/core` + `@specverse/runtime/views/tailwind`. If runtime mode, ship the consumer a Vue adapter that consumes the same pattern library at browser-runtime (new sibling of `runtime/views/react` under `runtime/views/vue`).

### Filtering view types from the sidebar — `hideTypes` prop

`ViewRouter` accepts an optional `hideTypes?: string[]` prop (since `@specverse/runtime` 5.2.0) for consumer-side view-type filtering. Matching types are hidden from the sidebar AFTER ViewRouter's auto-detail-generation runs — so models that have only `list`/`form` in the spec still surface their auto-generated `{Model}DetailView` in the sidebar even when the consumer hides those types.

```tsx
import { ViewRouter, FormView, OperationView } from '@specverse/runtime/views/react'

// app-demo's Views tab: hide list+form (covered by the Models tab's per-model CRUD)
<ViewRouter
  views={views}
  hideTypes={['list', 'form']}
  FormView={FormView as any}
  OperationView={OperationView as any}
/>
```

Use case: dedupe view types when one tab specializes in CRUD (covers list+form via FormView) and another shows the canonical detail/dashboard views from the spec. Default `hideTypes={undefined}` preserves the original behavior — all view types shown.

---

## Library-backed view renderers — `view.<type>` factories

Rich visual views (currently **timeline**; later calendar / kanban / chart / map / gantt) aren't built from the atomic-component library — they wrap a mature third-party renderer. SpecVerse selects that renderer the same way it selects a database or web framework: a **capability → instance factory** mapping. The spec says WHAT, the manifest says HOW. See `docs/proposals/in-progress/2026-05-31-VIEW-COMPONENT-FACTORIES.md`.

**Declare the view (spec — WHAT):**

```yaml
views:
  RoomTimeline:
    type: timeline
    model: Booking        # the event entity (carries the date range)
```

The role binding (lane / event / start / end / via / label) is derived from the model — the first `belongsTo` is the lane resource (e.g. `Booking.room` → Room lanes), the first two Date/DateTime attributes are the start/end, the natural content field is the bar label — and can be overridden per-role on the view. The same binding drives both the library renderer and the zero-dependency fallback.

**Select the renderer (manifest — HOW):**

```yaml
capabilityMappings:
  - capability: "view.timeline"
    implementation: "VisTimeline"      # or "TimelineGridCustom"
```

- **`VisTimeline`** — the `vis-timeline` (MIT/Apache-2.0) renderer (`runtime/views/react/components/VisTimelineView.tsx`). Lazy-imported; the realize package-json generator adds the `vis-timeline`/`vis-data` deps **only** when this factory is selected.
- **`TimelineGridCustom`** — a dependency-free rooms×dates grid (`TimelineGrid.tsx`); the automatic fallback when no renderer is registered or the library fails to load.

If the manifest omits `view.timeline`, a timeline view defaults to `VisTimeline`.

**Interaction model (VisTimeline):**

| Gesture | Action |
|---|---|
| mouse wheel / two-finger scroll | pan through dates (`horizontalScroll`) |
| lane scrollbar | scroll resources when they overflow (`verticalScroll`) |
| ctrl + wheel | zoom the time axis (`zoomKey`) |
| drag the background | pan |
| click a bar | navigate to that event's CRUD view (`onNavigate` → `ViewRouter`) |
| double-tap an empty cell | create a new event (FK + date fields pre-filled) |

Scrolling and editing are deliberately separated: drag-to-**reschedule** was removed (runtime 5.12.12) because a drag that started on a bar hijacked the drag-to-**pan** gesture. Rescheduling is done by opening the booking (click → CRUD form), not by dragging the bar.

---

## Parity verification

Three parity tests keep the invariants honest:

**P1 — Pattern library self-contained.**
CI test that greps `runtime/views/core` for any import from `react`, `react-dom`, a DOM API, or any sibling `views/react`/`views/tailwind` directory. Must be zero.

**P2 — Factory generators consume runtime, not forks.**
CI test that greps every factory generator under `engines/libs/instance-factories/applications/` and `engines/libs/instance-factories/views/` for imports from `@specverse/runtime/views/core` or `@specverse/runtime/views/tailwind`. Every view-related generator must import from at least one. Anything that doesn't is a reimplementation and fails the test.

**P3 — Rendered output equivalence.**
Integration test: take a reference spec, run inference, feed the expanded spec to all three consumers, diff the rendered HTML. Allow structural differences between runtime-mode (React-mounted) and starter-kit-mode (server-rendered) but equate class names, field ordering, relationship display strings. Diffs that aren't structural-only fail.

These three together prevent the "one pattern library" contract from rotting.

---

## Current state (2026-04-22)

| Element | Status |
|---|---|
| Pattern library (`runtime/views/core`) | Live, canonical |
| Tailwind build-time renderer (`runtime/views/tailwind`) | Live |
| React runtime renderer (`runtime/views/react`) | Live |
| App-demo consuming runtime | Live — `ViewRenderer` is a thin wrapper over `@specverse/runtime/views/react`'s `ViewRouter` |
| Factory A (`ReactAppRuntime`) — yaml declaration | Live |
| Factory A — generators written | Live |
| Factory A — wired into self's manifest | **Live** — shipped in engines@4.2.0, self@4.1.0 |
| Factory B (`ReactAppStarter`) — pattern-library-consuming | **Live** — shipped in engines@4.2.0 |
| Forks deleted | **Done** — `engines/libs/instance-factories/views/templates/` fork tree (~20,100 lines) removed in engines@4.2.0 |
| Parity tests | **Live** — P1 (library self-contained), P2 (starter imports from runtime, not forks), P3 (rendered-output equivalence between app-demo + static emitter) |
| Handlebars rewrite of rule templates | **Live** — shipped across engines@4.3.x and folded into engines@5.0.0. Every inference rule renders via real Handlebars + `yaml.load`; zero `generate*Spec` TS shortcuts remain. |

See [../plans/2026-04-17-HANDLEBARS-TEMPLATE-ENGINE-REWRITE.md](../plans/2026-04-17-HANDLEBARS-TEMPLATE-ENGINE-REWRITE.md) for the completed Handlebars rewrite plan (Cycles 1-11) and the decision frame (retire / port-disabled / port-active) captured as Golden Rule R16d.

---

## FAQ

**Why is Factory B generated code "fatter" than Factory A's slim shell?**
Factory A optimises for update-in-place: generated code is a thin shell, pattern library updates via `npm update`. Factory B optimises for editability: user gets real React components they can fork. Same architecture, two different value propositions. Pick based on whether your users want an updatable dependency or a starting-point codebase.

**Can a project use both Factory A and Factory B?**
Not the same project — they're alternate instance factories for the `app.frontend` capability, chosen in the manifest. A monorepo could have one workspace using each.

**How does a user-defined view in .specly flow through?**
Same as dev defaults. Inference merges user views with generated dev defaults into one expanded spec. From the consumers' perspective, they're identical inputs.

**What if a user edits Factory B's generated code and then regenerates?**
Factory B uses content hashing to protect user edits. See "Regeneration safety" above. Short version: every generated file's hash is recorded in `.specverse-gen/hashes.json` at write time. On regeneration, files whose on-disk hash differs from the recorded hash are treated as user-edited and skipped with a warning; untouched files are overwritten.

**Why is app-demo separate from the realized apps?**
App-demo's backend is a runtime spec interpreter: load any spec → get an API without generating code. Realized apps are static backends generated from a spec. Both are valid tools with different tradeoffs (iteration speed vs. deployability). They share only the frontend pattern library via `@specverse/runtime`.

**What's the role of the inference engine in this picture?**
Inference expands a minimal user spec into a complete spec with dev-default views. The three consumers consume the expanded spec; they don't run inference themselves. Inference lives in `@specverse/engines/inference` and is invoked once per realize or per app-demo spec load.
