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

nordisk/facett

.nornir/app-design.md

facett-app — the application-framework — design

Class: design · Function axis: f0 (the shell as one) · f1 NavModel/Router · f2 NavShell + nav widgets · f3 the demo/WASM harness. Strict: this file states only the shape we run. Every why, every alternative, lives in app-reasoning.md.

f0 — the app shell as one (the whole)

facett-app turns a set of Facets into a whole app: one navigation model drives every nav surface, a router turns a route into an active view, and a shell composes topbar + rail + content + statusbar around it. Immediate-mode, canvas-only, headless-addressable — the crate doc states it outright (facett-app/src/lib.rs:1-11).

The reuse graph is linear and one-directional:

NavModel (f1)  →  Router (f1)  →  Sidebar/Tree/NavPalette/menu bridge (f2)  →  NavShell / AppShell (f2)

NavShell and AppShell are themselves Facet implementors (facett-app/src/shell.rs:96, facett-app/src/appshell.rs:96), so they compose like any other component: facett-demo mounts one of each as deck tabs (facett-demo/src/lib.rs:361, facett-demo/src/lib.rs:396) and the live web demo (f3) hosts the resulting FacetDeck in a browser canvas over WASM. Every module is re-exported flat from the crate root (facett-app/src/lib.rs:34-51): AppShell, NavShell, NavModel/NavItem, Router, Sidebar, Dropdown, Modal, Toasts/Level, Tree, TabView, SplitPane, Popover, ColorPicker, DatePicker, NavPalette, Accordion/Stepper/Pagination.

Every stateful type in the crate keeps its whole observable state in its own struct — open, selected, expanded, page — never in egui memory (FC-1); dropdown.rs:1-5 states this explicitly as the reason Dropdown exists instead of egui::ComboBox. That single rule is what makes every surface headless-drivable (select/navigate/invoke/show) and observable (state_json), which is what lets facett-demo's mega-matrix and robot-UI tests drive nav, shell, overlay and widgets as ordinary data (facett-demo/tests/mega_matrix.rs:370-439).

f1 — NavModel + Router: one model, many surfaces

NavModel (facett-app/src/nav.rs:65) is a tree of NavItem (facett-app/src/nav.rs:11): { id, label, icon, route, enabled, badge, children }. A leaf targets a route; a node with children is a section. route_or_id() (nav.rs:58) is the resolved target — route, defaulting to id.

method signature does
flat (&self) -> Vec<&NavItem> (nav.rs:75) pre-order walk, sections + leaves
routes (&self) -> Vec<&str> (nav.rs:88) every enabled navigable route id
first_route (&self) -> Option<&str> (nav.rs:93) the router's start route
find / find_route (&self, id/route: &str) -> Option<&NavItem> (nav.rs:98, :103) id/route lookup
breadcrumb (&self, route: &str) -> Vec<String> (nav.rs:109) root→item label trail
breadcrumb_items (&self, route: &str) -> Vec<(String, String)> (nav.rs:128) trail with routes, for clickable crumbs
state_json (&self) -> Value (nav.rs:144) the whole tree, recursively

Router (facett-app/src/router.rs:10) holds current + a back/fwd stack — the generalisation of the deck's set_active_by_title and the wasm #TabName deep-link (router.rs:1-4). navigate(route) (router.rs:28) pushes history and clears fwd (browser semantics); it is a no-op if already on that route. back()/forward() (router.rs:46, :57) move between the stacks; can_back/can_forward/back_depth (router.rs:38, :41, :68) expose the affordance state. to_hash() (router.rs:74) and route_from_hash / sync_from_hash (router.rs:80, :88) are the wasm-hash deep-link codec, pure on native and wasm alike. state_json (router.rs:95) reports current, hash, can_back, can_forward, back_depth, forward_depth.

NavModel and Router are used together everywhere in f2: a shell resolves model.breadcrumb_items(router.current()) for its crumb and sidebar.show(ui, model, active) for its rail, then feeds any click back into router.navigate(route).

f2 — NavShell, AppShell, and the nav widgets

Sidebar (facett-app/src/sidebar.rs:12) renders a NavModel as a persistent rail: sections fold/unfold (BTreeSet<String> of expanded ids, seeded once via seed, sidebar.rs:29), a collapse toggle narrows it to icon-only (collapsed: bool), and show(ui, model, active_route) -> Option<String> (sidebar.rs:41) returns the clicked route.

NavShell (facett-app/src/shell.rs:14) is the first app-shell slice: a left Sidebar drives a Router; the content area renders back/forward buttons, a clickable breadcrumb (the standalone breadcrumb(ui, labels) -> Option<usize> fn, shell.rs:79, reused beyond NavShell), and per-route body text or an optional (route, label, Dropdown) control (route_dropdown, shell.rs:24). navigate/back/current_route/breadcrumb/model (shell.rs:60-74) give headless CLI/robot/test parity. state_json (shell.rs:176) folds in route, breadcrumb, collapsed, router.state_json(), nav.state_json(), and the attached dropdown's state.

AppShell (facett-app/src/appshell.rs:20) is the Fas-2 payoff: NavShell shows body text, AppShell mounts real Box<dyn Facet> panes per route (panes: BTreeMap<String, Box<dyn Facet>>, appshell.rs:26), so the active route renders a grid, a chart, a 3D model — swapped by navigation. Its ui (appshell.rs:101) lays out a topbar (app title + optional facett_menu::menu_bar fed by crate::menu::nav_commands + back/forward + clickable breadcrumb), a bottom statusbar, a left rail (Sidebar), and the central mounted pane (falling back to bodies text, then to a "no pane mounted" message). with_pane (appshell.rs:66) is the composition primitive; with_command_palette (appshell.rs:53) attaches an optional NavPalette overlay; with_menubar (appshell.rs:60) turns the same NavModel into a classic menubar via the bridge below. state_json (appshell.rs:200) folds in the active pane's own state_json under active_pane — whole-app introspection through one call.

The NavModel → facett-menu bridge (facett-app/src/menu.rs): nav_commands(model: &NavModel) -> Vec<facett_menu::Command> (menu.rs:13) turns every enabled leaf into a Command whose id is the route and whose group is the parent section's label ("Go" at top level), icons preserved. This is what lets the same nav tree drive AppShell's menubar, NavPalette's Ctrl-K fuzzy search, and Sidebar's rail through one source of truth.

NavPalette (facett-app/src/palette.rs:36) is the fuzzy Ctrl-K command palette over a NavModel: fuzzy(needle, hay) -> Option<i32> (palette.rs:12) scores subsequence matches (contiguous-run + leading-match bonuses); hits (palette.rs:64) ranks and returns (route, label); handle_hotkeys (palette.rs:84) wires Ctrl-K open/close + Esc; invoke (palette.rs:76) is the headless pick-by-id path.

Tree (facett-app/src/tree.rs:12) renders a NavModel as an expand/collapse navigator where any node — not just leaves — is selectable (a data/object picker), distinct from Sidebar's route-only leaves.

The remaining nav-input primitives keep the same FC-1 discipline — state in the struct, never in egui memory:

type file:symbol state headless entry
Dropdown dropdown.rs:10 options, selected, open select(id) (:44)
Modal modal.rs:10 open, buttons, last_action open()/close()/take_action() (:44-56)
Toasts toast.rs:50 items: Vec<Toast>, per-toast remaining push/push_sticky/tick(dt)/dismiss(id) (:62-93)
Popover popover.rs:10 open open()/close()/toggle() (:22-29)
TabView tabview.rs:9 active, switches select(id) (:26)
SplitPane splitpane.rs:9 vertical, ratio, two Box<dyn Facet> children itself a Facet (:35)
ColorPicker colorpicker.rs:8 rgba, swatches set_color/show (:25, :35)
DatePicker datepicker.rs:37 year, month, selected select/prev_month/next_month (:59-79)
Accordion widgets.rs:14 open: BTreeSet<String> set_open/toggle (:31, :41)
Stepper widgets.rs:69 value (clamped min..=max) set_value (:93)
Pagination widgets.rs:120 page, total, page_size set_page/next_page/prev_page (:136-160)

Modal generalises the facett-menu CommandPalette overlay pattern — a scrim egui::Area at Order::Background plus a dialog egui::Area at Order::Foreground, Esc-dismissable (modal.rs:1-5, :72-118). Toasts generalises the deck's single component-clone toast into a stacked, levelled (Level::{Info,Success,Warn,Error}, toast.rs:11) queue with per-toast TTL driven by tick(dt) (toast.rs:93) — injected in tests, the ambient frame dt in show() (toast.rs:104-109).

f3 — the demo/WASM harness

facett-demo is the reference host: DemoState (facett-demo/src/lib.rs:238) builds a FacetDeck of every Facet component — including four facett-app framework tabs, each one LEGEND entry (facett-demo/src/lib.rs:112-115):

tab mounts crate:symbol
nav a NavModel → Sidebar + Router inside one NavShell facett-demo/src/lib.rs:361-389 → facett_app::NavShell
shell an AppShell hosting real Model3D panes (incl. a SplitPane compare route) + Ctrl-K palette + menubar facett-demo/src/lib.rs:395-421 → facett_app::AppShell
overlay OverlayDemo: a Modal + Toasts + Popover facett-demo/src/overlay_demo.rs:8
widgets WidgetGallery: a TabView switching an Accordion(Stepper+Pagination)/Tree/DatePicker/ColorPicker facett-demo/src/widget_gallery.rs:8

DemoState::new() (facett-demo/src/lib.rs:275) constructs the whole deck (FacetDeck::new, facett-demo/src/lib.rs:751) with no window. DemoState::draw_app(&mut self, ui) (facett-demo/src/lib.rs:1138) is the one render path shared by the native eframe binary and the robot-UI test — it drives the sim tick, applies the active LookTheme, draws the toolbar (look-preset switcher, native-platform toggle, palette picker, glow/raven/commands/ legend buttons), the syschart node-select toolbar, self.deck.ui(ui), the region-picker modal, and the command-palette overlay last. run_command(id: &str) (facett-demo/src/lib.rs:915) is the single command sink shared by toolbar buttons, the command palette, and the robot-UI driver. select_syschart_node (facett-demo/src/lib.rs:952) and set_active_tab (facett-demo/src/lib.rs:964) are the typed/tab-title host-driving helpers state_json and the robot depend on.

facett-demo/src/main.rs wraps DemoState for two targets from one DemoApp (facett-demo/src/main.rs:159):

The wasm build is produced by cargo xtask demo-wasm. That gh-pages demo/deploy FLOW is byte-identical across four constellation repos, so per LAW #5 it lives ONCE in the edda leaf nornir-pages (consumed as a path+version sibling dep, both publish = false); the generic xtask primitives (which/run/fresh_dir/target_dir/ensure_wasm_target/ensure_wasm_bindgen) live one crate down in nornir-xtask-support. facett's xtask/src/main.rs keeps only its PagesConfig knobs (pages_cfg(): canvas facett_demo, state __facett_state, extra_build_args = ["--no-default-features"], …) and calls nornir_pages::{demo_wasm, deploy_pages}(&pages_cfg()) — facett is the reference impl, so its rendered index.html is the canonical template bytes (byte-identical before/after the extraction). The leaf ensures the wasm32-unknown-unknown target, ensures a wasm-bindgen CLI matching the locked crate version, runs cargo build --release -p facett-demo --target wasm32-unknown-unknown --no-default-features, runs wasm-bindgen --target web into dist/, and writes dist/index.html plus the Codeberg-Pages .domains/.nojekyll files; cargo xtask deploy-pages builds dist/ then force-publishes it to the pages branch (orphan-worktree squash). The --no-default-features build drops the native-only region-clip/osm-katana and the map-wgpu/map3d-wgpu wgpu passthroughs (declared in facett-demo/Cargo.toml); the browser runs eframe's glow/WebGL backend and the CPU painters draw the maps. The facett-only cargo xtask wrap-wasm + cargo xtask publish-pip path stays in xtask/src/main.rs and re-uses the same nornir-xtask-support primitives rather than twinning them.

The OSM 3D viewer: ONE viewer, two render backends (native ≠ web is a backend, not a fork)

The web demo's OSM 3D tab is the same viewer as native — osm_tabs::osm3d_view builds one canonical facett_map3d::Map3D (from_geometry_relief_roofed + town-core crop + Sigtuna tour) for both targets; there is no separate wasm 3D path. Map3D draws through the swappable RenderBackend seam (facett-map3d/src/backend.rs), and that is where native and web differ:

History (the "buggy, totally different" web OSM 3D Rickard remembers — it was real, and is fixed): the wasm demo once forked from native and each fix landed on only one side. e45dc3f briefly switched wasm to eframe-wgpu / WebGPU (real GPU 3D on the web) but WebGPU's browser support is too narrow, so it was reverted to glow/WebGL + CPU painter. The CPU lane was then genuinely broken — muddy-brown houses + depth tearing (a painter's-order centroid sort) over a dead void (no sky). Two commits closed the gap and killed the fork: 7aaee45 (software z-buffer + ACES tonemap) and bdc85ef (unify OSM 2D/3D native+web + the CPU sky gradient). Since then the web OSM 3D is the correct, unified CPU render — verified by facett-map3d/tests/cpu_zbuffer.rs + backend_conformance.rs and by facett-demo's osm_tabs/osm3d tests passing under --no-default-features (the exact wasm feature set). Do not re-introduce the WebGPU wasm path: it is the reverted fork.

Refreshing / redeploying the live web demo

cargo xtask demo-wasm rebuilds dist/ from the current gallery HEAD (the built blobs are .gitignored — they live only on the pages branch, never in source history). cargo xtask deploy-pages then force-publishes dist/ to the pages branch, which Codeberg serves at https://nordisk.codeberg.page/facett. The deploy step pushes over SSH (git@codeberg.org, per the nordisk SSH-only law), so it must be run by someone whose key can push to origin (or set $FACETT_PAGES_REMOTE). Whenever the gallery gains components, re-run deploy-pages so the live bundle tracks HEAD — the deployed bundle does not auto-follow main.