# Component pattern (idiomatic Effect views) Status: **Implemented** (2026-08-07) — reference implementation: `packages/frontend/src/editor/lut-bar/`. Everything in this plan was applied in the same session; the audit table below records what changed. ## Goal Make every frontend view, update loop, and helper read like `editor/lut-bar/`: expression pipelines over Effect combinators, `Match` for union dispatch, `Option` for conditional rendering, and return-type annotations only when they earn their keep — the type-checker infers the rest. The lut-bar directory is the canonical example of every rule below. ## The pattern (the checklist) A component is a **directory**, not a file: - `index.ts` — barrel: re-exports the public surface only (`lut-bar/index.ts`). - One file per role: the top-level view (`bar.ts`), pure view atoms (`tab.ts`, `thumb.ts`), pure data logic (`catalog.ts`, `target.ts`), and Effect mounts (`wheel.ts`). - A view is a **pure function of `(h, model)`** — no state, no DOM access, no side effects. Side effects live in `Mount.define`/`Mount.defineStream` blocks (`wheel.ts`, the `PanZoom` family in `canvas-stage.ts`). Composition rules: - **Conditional rendering is an Option pipeline**: `Option.all([...])` → `Option.filter` → `Option.map` → `Option.getOrNull` for "render only when everything present" (the bar's visibility). View-local absence is a plain `null` child (foldkit renders null children); never a fabricated empty `h.div([], [])`. - **Union dispatch is `Match`**: `Match.value(x).pipe(Match.withReturnType(), Match.when(S.is(...), ...), Match.orElse(...))` — with schema guards (`S.is`) for domain unions. Never `switch` statements. `Match.tagsExhaustive` for message-union update loops (both `update.ts` files already did this). Plain `_tag ===` predicates stay only for boolean helpers (`hasImage`, `canPickTool`). - **Data plumbing is `pipe`**: `Array.groupBy`/`Record.toEntries`, `Array.findFirst`, `Option.orElse`/`Option.match`/`Option.contains`, `Option.fromNullishOr` for `Schema.NullOr` model fields. - **Schema guards for narrowing**: `S.is(LutLayer)`, `S.is(Drafting)` at Match arms; manual `type`/`_tag` checks only where the value is already statically narrowed (a typed `Layer` union narrows with `layer.type === 'lut'` for free). Annotation policy: - **No return-type annotation when TS infers it** — that is most functions. `lutBar`, `tab`, `thumb`, `groupByCategory`, `lookup`, `lutTarget`, `currentLutId`: all unannotated. - **Keep** annotations only for: `Match.withReturnType()` (Match cannot infer branch unions), exported contract types (`UpdateReturn`, `InitReturn`, `Document`), Effect error-channel signatures (command/store/GPU internals), named types that must exist (`LutTarget`, `Model`), and error-contract helpers (`asHtmlElement`, `fieldBounds`). - Parameters are always annotated; `import type` for type-only imports. Comments carry the **why**: invariants, unreachable branches, and fallbacks are documented at the definition site (`currentLutId`'s unreachable-from-bar note, `recentsEntries`' stale-reference drop, `groupByCategory`'s first-seen-order note). ## Decisions ### D1 — Annotation policy Drop every inferable return-type annotation; keep the list above. Evidence this is safe: the reference file exports `lutTarget` and `currentLutId` unannotated and `tsc --noEmit` stays green; the migration below dropped ~15 annotations with zero type errors. ### D2 — `switch` is banned for union dispatch The codebase had exactly two `switch` dispatchers left (`root/update.ts` on `message._tag`, `root/init.ts` on `route._tag`) plus an if/else chain (`root/view.ts` `activeRoute`). All converted to `Match.tagsExhaustive` / `Match.when(S.is(...))` / `Match.orElse`. The only remaining `switch` is `components/icon.ts`'s tag-name string dispatch, which is the right tool there. ### D3 — Empty-div placeholders are banned `export-dialog.ts` `qualitySection` returned `h.div([], [])` for lossless formats. Replaced with a `null` child (ternary), matching `canvas-stage.ts`'s `compareMode === 'split' ? splitDivider(...) : null` precedent and lut-bar's `Option.getOrNull` boundary. ### D4 — Typed dispatch instead of casts `export-dialog.ts` `segmentedRow` was string-typed, forcing two `consistent-type-assertions` suppressions. Made generic (``) so the format and scale rows dispatch with statically-typed values — both suppressions deleted. ### D5 — Status text is a data pipeline `top-bar.ts` `saveStatusText` (if/else + `return null`) and `export-dialog.ts` `statusSection` (`let content: string` + if/else) became `Match` / Option pipelines over the model's status unions. ## Current state (audit, 2026-08-07) | File | Before | After | |---|---|---| | `editor/lut-bar/` | — | The reference; already conformant | | `editor/update.ts`, `gallery/update.ts` | `Match.tagsExhaustive`/`M.tags` + `exhaustive` ✅ | Conformant; dropped 2 redundant annotations (`ensureFieldIndex`, `presentState`) | | `editor/phase.ts`, `model.ts`, `message.ts`, `command.ts`, `luts/store.ts`, `gpu/*`, `encode/*` | Effect/Schema idioms ✅ | Conformant; dropped `hasImage`'s `: boolean` | | `editor/view.ts`, `tool-panel.ts`, `layer-drawer.ts` | Close | Conformant; dropped `canPickTool: boolean`, `num: number`, `summary: string` | | `editor/top-bar.ts` | `saveStatusText` if/else + bare `null` | `Match.value(model.saveStatus)` + `withReturnType()` | | `editor/export-dialog.ts` | `let content` if/else; empty-div placeholder; string-typed `segmentedRow` + 2 casts | `exportStatusText` Option pipeline; `null` section; generic `segmentedRow`; dropped `fmtBytes: string` | | `editor/canvas-stage.ts` | stage pick ternary chain | `Match.value(model.phase)` with `S.is(Empty/Loading/ErrorState)` | | `root/update.ts` | `switch (message._tag)` + nested `switch (route._tag)` | `Match.tagsExhaustive` + `applyRoute` helper with `Match.when(S.is(...))`; dropped `withRoute: Model` | | `root/view.ts` | `activeRoute` if/else chain | `Match.value(model.route)` with `S.is(GalleryRoute/EditorRoute)` | | `root/init.ts` | IIFE-wrapped `switch (route._tag)` | `Match.when(S.is(...))` + `Match.orElse(() => [])` | | `gallery/view.ts` | `const grid: AsyncData.AsyncData<...>` annotation | `const grid = model.grid` | | `editor/layer-meta.ts` | `: string` on formatters, `lutName`, `num` | Dropped | ## Verification - `pnpm --filter @lutra/frontend typecheck` — clean. - `pnpm lint` — clean (the one remaining `switch` is `components/icon.ts`'s tag-name dispatch, kept by D2). - `pnpm test` — engine 60 + frontend 123, all green. - Pure style refactor: no behavior change anywhere (no message, command, model, or view output was altered).