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

nordisk/facett

.nornir/facet-reasoning.md

The Facet contract — reasoning

Class: reasoning. The why behind the Facet contract, the trade-offs, and the alternatives we rejected. The descriptive shape is in facet-design.md. This also holds the testability contract (FC-1 … FC-9) that a component must satisfy to be robot-drivable — the deeper precondition the nornir completeness gate enforces.

Why state_json is mandatory (not optional)

korp and nornir are robot-tested headless — launched with $APP_STATE set, driven, and asserted against the JSON. A component with no state_json is invisible to that harness. So the trait requires it: the introspection contract is part of being a facett component, not an afterthought. Every visible list/status/count must be in state_json, or a test cannot see it. This is the single non-negotiable obligation, and everything else is defaulted around it.

Why the capability surface is defaulted (not required)

caps / scale / copy / paste / kind all have do-nothing defaults. The alternative — make every component implement all ten — would have forced a large, mostly-boilerplate impl on every port and blocked incremental migration. Defaulting them to the opt-out floor means the three required methods stay the whole obligation, a component opts into exactly the capabilities it has, and nothing panics when a facet declares nothing. The deck reading caps() (rather than probing) is what lets it route only the shortcuts a facet honors.

Why the deck owns the OS clipboard (components stay pure)

copy/cut/paste return/accept text; the single OS-touching clipboard::put lives in the deck. Had each component touched the OS clipboard directly, none of them would be unit-testable without a display server, and copy semantics would drift per component. Keeping the components pure (text in, text out) and centralizing the side effect is what makes copy() assertable in a plain #[test].

Why the interaction primitives are reducers, not egui widgets (nor Facets)

Drag-and-drop, time pan/zoom, and an action outbox recur across facets (kanban, gantt, CFD, calendar, timeline). Three shapes were possible: (a) an egui widget each facet drops in, (b) copy-pasted per-facet logic, or (c) an engine-agnostic reducer each facet uses. We chose (c) — dragdrop, time_axis, action_bus as pure serde state machines with one update(Msg) -> Effect path — for the same reason the deck owns the clipboard: an egui widget hides observable state in ui.memory (FC-1) and can't be driven without a display (FC-2/FC-6). A reducer's transitions are a plain #[test] property (FC-7); a drag is Start → HoverZone → Drop Msgs asserted against state_json, no pointer simulation. This is exactly why they sit beside nav::Navigable and scroll_engine::SmoothScroll rather than inside the facets: the shared, tested logic lives once, and the per-facet impl Facet is a thin painter over it.

They deliberately do not impl Facet. A primitive isn't a tab — it's a part of a facet (a gantt is one TimeAxis + one DragDrop, not three tabs). Keeping them trait-free lets one facet compose several, and keeps the deck's {T : impl Facet} enumeration (the completeness gate) counting components, not their internal machinery.

Why the effect is returned as data, and why the bus is deterministic. update hands back DragEffect::Moved / TimeEffect::WindowChanged rather than performing the transition itself, so the host stays the single place a move is committed (and can reject it) — the FC-8 "side work as data" rule. action_bus is the common carrier those effects flow through, and it stamps every BusAction with a monotonic seq that never resets across drains. That ordering is the whole point: without it a headless test could assert what a facet emitted but not in what order two gestures fired — and cross-facet effects (a scrub that scrolls linked facets, a drop that opens a card) are order-sensitive. A deterministic outbox makes the emitted sequence an assertable value (FC-7), which an egui event queue never is.

The testability contract (FC-1 … FC-9)

A component is testable and agent-drivable exactly to the degree that an external process can (a) write its inputs, (b) read its outputs, and (c) observe it semantically and visually — without simulating a human, a mouse, or a screen. egui is immediate-mode and will happily hide state inside widget memory; this contract forbids that. Keywords per RFC 2119.

id invariant the smell it forbids
FC-1 no hidden state — all observable state in the Model ui.memory_mut / ctx.data_mut holding observable state
FC-2 writable input surface — every input expressible as a Msg an input reachable only by raw pointer/keyboard simulation
FC-3 readable output surface — Model fully serializable, state(&self) exposed a field that never round-trips through serde
FC-4 semantic exposure — every interactive/value element emits an AccessKit node a custom-painted widget invisible to the a11y tree
FC-5 stable identity — ids keyed off a stable domain id, never list index / time / RNG Id::new(index); duplicate labels within a Surface
FC-6 picturable in isolation — renders headless through the offscreen path a render that needs a presented swapchain
FC-7 determinism — same Model + Msgs → same tree + same frame ambient Instant::now() / rand in view/update
FC-8 explicit quiescence — idle/pending is observable, no wall-clock sleeps thread::sleep to "wait for the UI"
FC-9 pure view — view(&Model, &mut Ui) emits Msgs, holds no state a self.x = … inside view

Anti-patterns to flag (each maps to the violated FC): observable state in egui memory → FC-1; a painted interactive element with no WidgetInfo/AccessKit node → FC-4; an id derived from loop index / timestamp → FC-5; ambient time or RNG in view/update → FC-7; blocking I/O or .await inside update → FC-8; a render path requiring a presented window → FC-6.

Two-speed adoption (how we got here)

The Facet trait as it stands is fn ui(&mut self, ui: &mut Ui) — it mutates self. That is not the Model/Msg/Effect/pure-view split FC-2/FC-9 demand. So adoption ran two speeds: FC-1, 3, 4, 5, 6, 7, 8 were enforceable on the existing trait immediately (the first compliance pass), while FC-2 + FC-9 (the full Elm shape) landed as a deliberate architectural migration across ~16 components, wave by wave, not as a silent relaxation. The bridge is the elm macro impl_facet_via_elm!, which writes the mutating Facet::ui from a pure Elm view+update — so a component can be internally pure (FC-9) while the host still sees the simple ui(&mut self) surface. That macro seam is why the two speeds could coexist in one compiling codebase.

Relationship to the nornir completeness gate

This contract is the per-component precondition; the nornir test-matrix completeness gate is the library-wide enforcer. The gate enumerates components as {T : impl Facet for T} ∩ registry(), then reads FC-3 (state(&self)) and FC-4 (AccessKit labels) to assert real data and run the sentinel-ban; "no silent gaps" = registry_covers_all_components. Thin/fat parity needs FC-3's serializable state so both sides compare equal on the same data; FC-7 determinism is what makes the gate's screenshot-proof reproducible.