Gunnar

nordisk/facett

.nornir/facet-guide.md

# The Facet contract — guide

> **Class:** `guide` · audience: a techie adding a new `facett-<name>` component or driving a `FacetDeck`.
> The *what* is in [`facet-design.md`](facet-design.md); the *why* in [`facet-reasoning.md`](facet-reasoning.md).
> If your component is built the Elm way (recommended), the macro writes the `Facet` impl for you — see [`elm-guide.md`](elm-guide.md).

## f0 — a new component, end to end

The recipe every port follows — one component at a time:

1. **New crate** `facett-<name>` — add it to `members` in the workspace `Cargo.toml`. Depend on
   `facett-core` (+ `facett-graph` if it's a graph; + `arrow-array` if it eats Arrow). Inherit
   `version` / `edition` / `license` from `[workspace.package]`.
2. **A state type** holding what the component shows, plus a `title: String`.
3. **Build the visual** with facett primitives where possible — a node/edge graph is a
   `facett_core::Scene` + `facett_core::draw` with a `Layout`; anything else is an egui painter
   in `ui()`. Keep render-only logic here; **no** data-fetching / connection code (that stays in
   the consumer, e.g. knut).
4. **`impl Facet`** — `title`, `ui`, `state_json`, plus `caps()` and any capability methods you
   opt into.
5. **Test headless** — see `f3`.

## f1 — the three you owe

```rust
impl facett_core::Facet for MyView {
    fn title(&self) -> &str { &self.title }
    fn ui(&mut self, ui: &mut egui::Ui) { /* draw; theme via theme(ui) */ }
    fn state_json(&self) -> serde_json::Value {
        // EVERY visible list / status / count / selection goes here.
        serde_json::json!({ "rows": self.rows.len(), "selected": self.selected })
    }
}
```

Implement those three and your component drops into a `FacetDeck` and into korp/nornir's
`$APP_STATE` introspection automatically.

## f2 — opting into capabilities

Every other trait method is defaulted, so the three above are the whole *obligation* — but
overriding these is how you get zoom + clipboard for free through the deck:

```rust
fn caps(&self) -> FacetCaps {
    // table: selectable rows, copyable cells, searchable, scalable, resizable columns.
    FacetCaps::NONE.selectable().copyable().searchable().scalable().resizable()
}
fn scale(&self) -> f32 { self.scale }
fn set_scale(&mut self, s: f32) { self.scale = s.clamp(0.25, 4.0); } // deck drives Ctrl-+/-/0
```

The deck reads `caps()` to route only the buttons/shortcuts you honor — a non-copyable facet
is never asked to copy. Fold `"scale": self.scale()` into `state_json` so the harness can
assert Ctrl-+ actually changed it.

## f3 — clipboard, and testing headless

Implement `copy()` to return your payload as text (the deck makes the single OS-touching
`clipboard::put`, so your `copy`/`cut` stay pure):

- **table / grid** → TSV via `facett_core::clipboard::rows_to_tsv`;
- **form** → the bound model as pretty JSON (round-trips with `paste`);
- **graph / pipeline** → newline-joined node labels; **map** → `"<lon>,<lat>"`.

Then unit-test without a window:

```rust
// drive a selection programmatically, then assert the introspection + clipboard:
let mut v = MyView::new(/* … */);
v.select(id_a);
assert_eq!(v.state_json()["selected"], serde_json::json!(id_a));
assert!(v.copy().unwrap().contains("expected cell"));
// and assert it actually paints, offscreen:
assert!(facett_core::harness::headless_render(&mut v).drew());
```

When `$FACETT_TRAIL` is set, every render/state line the harness emits is also appended to
that file — externally observable, the robot-test net. See [`harness-guide.md`](harness-guide.md).

## f4 — composing a deck

```rust
use facett_core::FacetDeck;
let mut deck = FacetDeck::new(vec![
    Box::new(grid) as Box<dyn facett_core::Facet>,
    Box::new(map),
]);
deck.push(Box::new(graph));            // build pane-by-pane
// in your egui update: deck.ui(ui);   // tab bar + active facet + routed zoom/clipboard
let app_state = deck.state_json();     // every facet's state under its title — free
```

A host (korp / nornir) is a window around a `FacetDeck` instead of hand-rolled tabs + a state
dump.

## f5 — reusing the shared interaction primitives

Before hand-rolling drag or time-scroll logic, reach for the three facett-core reducers (the
*what* is [`facet-design.md`](facet-design.md) `f5`). Each is a pure serde `Model` you own a
field of; you drive it with a `Msg`, apply the returned effect, and fold its `state_json` into
yours — so the robot drives your facet with no display.

```rust
use facett_core::{ActionBus, DragDrop, DragMsg, FacetCaps, TimeAxis, TimeMsg};

// A cards→columns board (kanban / gantt lanes): seed, drag, apply the move.
let mut board = DragDrop::new();
board.place("SUPP-1", "todo");                 // seed from your data
board.update(DragMsg::Start("SUPP-1".into())); // a gesture = Start → HoverZone → Drop
board.update(DragMsg::HoverZone("doing".into()));
for effect in board.update(DragMsg::Drop) {
    let facett_core::DragEffect::Moved(m) = effect;  // apply as a real transition (may reject)
    bus.dispatch("kanban", "move", serde_json::json!({ "card": m.item, "to": m.to }));
}

// A pannable/zoomable time window (gantt/CFD/calendar): pivot-fixed zoom, then link siblings.
let mut axis = TimeAxis::new(0.0, 100.0);
axis.update(TimeMsg::Zoom { factor: 2.0, pivot: 50.0 });  // narrow, keeping t=50 fixed
other_axis.sync_from(&axis);                              // one scrubber scrolls several facets

// The typed outbox both gestures hand work to the host through.
let mut bus = ActionBus::new();                 // seq is monotonic, never reset — order is asserted
for action in bus.drain() { host.run(action); } // read what the facets emitted this frame
```

Then advertise the capabilities so the deck/host route the right gestures, and expose every
reducer's state:

```rust
fn caps(&self) -> FacetCaps {
    FacetCaps::NONE.themeable().resizable().draggable().time_scrollable()
}
fn state_json(&self) -> serde_json::Value {
    serde_json::json!({
        "dragdrop":   self.board.state_json(),
        "time_axis":  self.axis.state_json(),
        "action_bus": self.bus.state_json(),
    })
}
```

`facett-demo`'s `primitives` tab (`facett-demo/src/primitives_tab.rs`) is the worked example —
a board, a scrubber, and the outbox, driven headless through its typed `advance_leading_card()`
/ `zoom_in()` API.

## Cross-instance clone (optional)

To let two same-component panes exchange state through the OS clipboard, implement the trio:
`kind()` (a stable id like `"table"`), `portable_state()` (the round-trippable subset), and
`load_state()`. A mismatch is rejected with the deck's toast — the caller never has to check.