# 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`](app-reasoning.md).
## f0 — the app shell as one (the whole)
`facett-app` turns a set of [`Facet`](../facett-core/src/lib.rs)s 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`](../facett-core/src/lib.rs) 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`):
- **native**: `eframe::run_native` (`main.rs:20`) installs `LookTheme::from_os(...)` and,
when a `wgpu_render_state` exists, `facett::map::gpu::install_renderer` /
`facett::map3d::gpu::install_renderer` (feature-gated) before boxing `DemoApp`.
- **wasm**: `#[wasm_bindgen(start)] wasm_start()` (`main.rs:73`) mounts `eframe::WebRunner`
onto `<canvas id="facett_demo">`, reads an initial `#TabName` hash deep-link, and calls
`app.demo.set_active_tab(&tab)`. Every frame, `DemoApp::ui` calls the same
`self.demo.draw_app(ui)` as native (CLI/GUI parity), then on wasm publishes
`state_json()` into a process-global (`wasm_state::publish`, `main.rs:132`) read back by the
exported `window.__facett_state()` (`#[wasm_bindgen(js_name = __facett_state)]`,
`main.rs:171`) — a headless-drivable read hook for the shipped bundle with no filesystem.
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:
- **native** = `WgpuVelloBackend` (`caps().gpu == true`): PBR, shadows, reflective water, sky pass,
bloom/SSAO on a real GPU Z-buffer.
- **web (wasm/glow/WebGL)** = `CpuPainterBackend` (`caps().gpu == false`), its **only** lane, because
the eframe glow path has no wgpu paint-callbacks. `paint_cpu` (`backend.rs:248`) rasterises the
shaded tris through a per-pixel **software z-buffer** (`raster_zbuffer`, perspective-correct 1/z)
with linear-space + Narkowicz-ACES + sRGB tonemap (`tonemap_shaded`), and `Map3D::view`
(`lib.rs:3427`) paints a vertical **sky gradient** behind it when `!caps().gpu`. Honest capability
gap vs native: **no reflective water, shadows, SSAO or bloom** on the web lane (all GPU-only). This
is a deliberate browser-support choice, not a bug or a to-do.
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 `.gitignore`d
— 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`.