Gunnar

nordisk/facett

.nornir/facet-design.md

# The Facet contract — design

> **Class:** `design` · **Function axis:** `f0` the deck (the whole) + `f1` the `Facet` trait · `f2` `FacetCaps` · `f3` clipboard & cross-instance clone · `f4` `FacetDeck` · `f5` the shared interaction primitives.
> Strict: this file states *only the shape we run*. Every *why* and every rejected shape lives in [`facet-reasoning.md`](facet-reasoning.md); the how-to is [`facet-guide.md`](facet-guide.md).
> The **internal** component loop (`Model`/`Msg`/`update`/pure `view`) is a separate function — see [`elm-design.md`](elm-design.md). `Facet` is the **host-facing** side the macro writes from an `Elm` impl.

![Facet render circuit](assets/facet_render_circuit.svg)

## f0 — the deck (the whole)

Every facett component is one value implementing **`Facet`** (`facett-core/src/lib.rs:228`).
A host never knows a component's concrete type: it holds `Box<dyn Facet>` and gets three
guarantees — a **title**, a way to **draw**, and an **observable `state_json`**. A
**`FacetDeck`** (`lib.rs:312`) is a `Vec<Box<dyn Facet>>` + an `active` index: it paints a
tab bar + the active facet, routes zoom/clipboard to it, and composes *every* facet's
`state_json` under its title — so whole-app introspection is free.

The data path: `input → concrete component (builds its Scene / grid / map) → Facet::ui →
egui pixels`, with a parallel `Facet::state_json` spur that emits every visible count/status
so the same UI is assertable headless (`$APP_STATE`). The deck is what turns *N* components
into one app.

## f1 — the `Facet` trait

`pub trait Facet` (`facett-core/src/lib.rs:228`). Three required members, the rest defaulted:

```rust
fn title(&self) -> &str;                        // :229  tab label / heading
fn ui(&mut self, ui: &mut egui::Ui);            // :230  draw into egui
fn state_json(&self) -> serde_json::Value;      // :231  every visible count/status
```

| law | statement | enforced by |
|---|---|---|
| **title** | a component names itself for the tab bar / heading | `title(&self) -> &str` (`lib.rs:229`) |
| **draw** | a component paints through the `egui::Ui` it is handed, and only there | `ui(&mut self, …)` (`lib.rs:230`) |
| **observe** | every visible list/status/count is in `state_json` — the headless test surface | `state_json` (`lib.rs:231`) |

The `state_json` obligation is non-negotiable: it is the headless robot-testing contract
korp + nornir live by (`$APP_STATE`), so a UI is asserted without a display.

## f2 — `FacetCaps` (the defaulted capability surface)

`pub struct FacetCaps` (`facett-core/src/caps.rs:10`) is a `Copy` flag struct of twelve booleans:
`scalable`, `copyable`, `pasteable`, `cuttable`, `selectable`, `themeable`, `resizable`,
`searchable`, `editable`, `navigable`, plus `draggable` and `time_scrollable`. The last two
point a host at the two shared interaction primitives: `draggable` = the facet's items can be
dragged onto zones via `crate::dragdrop` (cards→columns, bars→lanes); `time_scrollable` = its
content lays on a pannable/zoomable `crate::time_axis` (gantt/CFD/calendar/timeline), several of
which a host can link to one scrubber. It is built from `FacetCaps::NONE` with chained
`const fn` builders, e.g. `FacetCaps::NONE.selectable().draggable()`. A component declares its
caps by overriding `Facet::caps` (defaults to `NONE`).

The deck reads `caps()` to decide which toolbar buttons and shortcuts to route to the
*active* facet, and folds it into the deck's `state_json` (`caps.to_json`, `caps.rs:138`), so
the introspection advertises what each component can do.

| defaulted member | signature | role |
|---|---|---|
| `caps` | `caps(&self) -> FacetCaps` (`lib.rs:236`) | declare capabilities |
| `scale` / `set_scale` | `scale(&self)->f32` / `set_scale(&mut self,f32)` (`lib.rs:241`,`245`) | uniform zoom; deck drives Ctrl-+/-/0 |
| `selection_json` | `-> serde_json::Value` (`lib.rs:249`) | current selection (also folded into `state_json`) |
| `as_any_mut` | `-> Option<&mut dyn Any>` (`lib.rs:272`) | typed downcast for hosts that need a concrete facet |

## f3 — clipboard & cross-instance clone

**Text clipboard.** `copy` / `cut` / `paste` (`lib.rs:255`,`259`,`263`) are pure, defaulted
hooks: `copy` returns the payload as text, `cut` delegates to `copy` then drops the
selection, `paste` consumes text and returns whether it was handled. The deck makes the
single OS-touching call; the component's `copy`/`cut` stay unit-testable. Row-oriented
facets serialize identically via `facett_core::clipboard::rows_to_tsv`.

**Type-tagged state clone.** On top of the text layer sits a state layer (`lib.rs:287`–`303`):
`kind(&self) -> &'static str` (a stable component-type id, `""` = opted out), `portable_state`
(`-> Option<Value>`, the round-trippable subset a sibling can adopt), and `load_state`
(`&Value -> bool`). Two instances with the **same** `kind()` exchange state through the OS
clipboard envelope (`clipboard::encode_component` / `decode_component`); a mismatch raises the
deck's component-toast (`FacetDeck::toast`, `lib.rs:324`).

## f4 — `FacetDeck`

`pub struct FacetDeck` (`facett-core/src/lib.rs:312`): `facets: Vec<Box<dyn Facet>>`,
`active: usize`, opt-in `fx: DeckFx` (`lib.rs:317`, `OFF` by default), an optional `raven`,
and a transient `toast`. Constructors: `FacetDeck::new(Vec<Box<dyn Facet>>)` (`lib.rs:331`)
and `push` (`lib.rs:337`) to build a deck pane-by-pane. Its `ui` (`lib.rs:560`) draws the tab
bar + the active facet and routes zoom/clipboard by the active facet's `caps()`; its
`state_json` (`lib.rs:895`) composes every facet's state under its title — the whole-app
introspection, free.

**Reuse map (what the Facet subsystem `uses`):** it depends only on `egui`, `serde_json`, and
facett-core's own `caps`/`clipboard`/`deckfx` modules. Every component crate (the grid, map,
graph, docview, … families) is *used by* it; the [`elm`](elm-design.md) macro is what writes
a component's `Facet` impl from its `Elm` impl, and the [`harness`](harness-design.md) drives
`Facet` headless.

## f5 — the shared interaction primitives

Three interaction reducers live in facett-core beside [`nav::Navigable`] and
[`scroll_engine::SmoothScroll`]: **engine-agnostic state machines** — no egui, no rendering —
that a facet *uses* rather than reimplements. Each is a serde `Model` mutated only through one
`update(Msg) -> Vec<Effect>` path, exposes its own `state_json`, and returns side work **as
data** (FC-8); none of them `impl Facet` (the consuming facet owns that). They back the two
new `f2` caps: `draggable` ⇒ `dragdrop`, `time_scrollable` ⇒ `time_axis`.

| primitive | type / module | `Msg` → `Effect` (the mutation path) | used by |
|---|---|---|---|
| **dragdrop** (DND-1) | `DragDrop` (`facett-core/src/dragdrop.rs:58`) | `DragMsg::{Start,HoverZone,LeaveZone,Drop,Cancel}` (`dragdrop.rs:40`) → `DragEffect::Moved(Move)` (`dragdrop.rs:32`) on a committed cross-zone drop | items-onto-zones facets: cards→columns (kanban), bars→lanes (gantt), files→folders |
| **time_axis** (TIME-1) | `TimeAxis` (`facett-core/src/time_axis.rs:47`) | `TimeMsg::{Pan,Zoom{factor,pivot},Fit,SetBounds}` (`time_axis.rs:31`) → `TimeEffect::WindowChanged{start,end}` (`time_axis.rs:23`) iff the window actually moved | time-laid-out facets: gantt, CFD columns, calendar weeks, timeline events |
| **action_bus** (BUS-1) | `ActionBus` (`facett-core/src/action_bus.rs:43`) | `BusMsg::{Dispatch{source,kind,payload},Clear}` (`action_bus.rs:33`) → the assigned monotonic `seq` (`update` returns `Option<u64>`, `action_bus.rs:93`) | the common outbox the other two primitives' effects flow through to the host |

**dragdrop.** `DragDrop` (`dragdrop.rs:58`) holds the item→zone `placement`, the in-flight
`dragging`/`hover`, and the `moves` committed since the host last drained. The host seeds the
board with `place` (`dragdrop.rs:76`), reads a zone via `items_in` (`dragdrop.rs:86`), runs a
gesture as a `Start → HoverZone → Drop` `DragMsg` sequence through `update` (`dragdrop.rs:117`),
and applies the returned `DragEffect::Moved` as a real transition (which it may reject, then
re-`place`). `state_json` (`dragdrop.rs:165`) publishes the placement + in-flight drag.
Re-exported as `dragdrop::{DragDrop, DragEffect, DragMsg, Move as DragMove}` (`lib.rs:58`).

**time_axis.** `TimeAxis` (`time_axis.rs:47`) is `{start, end, min, max, min_span}` — the
temporal sibling of `Navigable`: where `Navigable` pans/zooms 2-D space, this pans/zooms a 1-D
time window. Time is an `f64` in whatever unit the host chooses (epoch s, days, ms); the axis
only does affine arithmetic. `zoom` (`time_axis.rs:130`) is **pivot-fixed** — the pivot time
stays at the same on-screen fraction (the map/plot zoom feel). Several facets **link** to one
scrubber via `sync_from` (`time_axis.rs:166`), which copies another axis's visible window
(keeping its own bounds) — one scrubber scrolls them together. Re-exported as
`time_axis::{TimeAxis, TimeEffect, TimeMsg}` (`lib.rs:69`).

**action_bus.** `ActionBus` (`action_bus.rs:43`) is a FIFO of `BusAction`
(`action_bus.rs:23`, `{seq, source, kind, payload}`) plus a monotonic `next_seq`. `dispatch`
(`action_bus.rs:56`) stamps each action with a `seq` that never resets — even across a
`drain` (`action_bus.rs:86`) — so a headless test asserts the *exact order* a facet emitted
(FC-7). The host drains the queue each frame; the actions themselves *are* the side work.
Re-exported as `action_bus::{ActionBus, BusAction, BusMsg}` (`lib.rs:56`).

All three follow the `nav`/`scroll_engine` convention — pure, serde, `state_json`,
headless-tested — so the `impl Facet` a consumer writes stays a thin painter over a
robot-drivable reducer. The demo's `PrimitivesDemo`
(`facett-demo/src/primitives_tab.rs:22`) is the reference host: a cards→columns board, a
pan/zoom scrubber, and the outbox both gestures dispatch onto, all folded into one
`state_json` — see [`facet-guide.md`](facet-guide.md) `f5`.