Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

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; the how-to is facet-guide.md. The internal component loop (Model/Msg/update/pure view) is a separate function — see elm-design.md. Facet is the host-facing side the macro writes from an Elm impl.

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:

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 macro is what writes a component's Facet impl from its Elm impl, and the harness 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 f5.