navgraph — one navigation graph over the whole application, down to the fields
Status: PHASE 1 IMPLEMENTED (2026-09-28), on the v1 look tokens; PHASE 2 built in the scene renderer (2026-09-29, §9 "Phase 2 as built" — proven on a korp-shaped scene, not yet on korp). Owner, 2026-09-28: "show graph of navigation with unique numbering at dev mode" and "yes :) do navgraph". Phases 2–4 (korp, korp's fields, the editor) are not started. Look and feel v2.0 is still not designed; the overlay's colours are v1 palette roles under token names chosen to become v2.0 keys (§9). The original design text below is kept as written; §9 records what phase 1 built, where it differs, and what is left.
Owner, 2026-09-24: "in dev mode draw 1 number on each facett component … try to enumerate also IF possible in the navigation order if that has been declared." Owner, 2026-09-25: "we need a navigation graph over the whole application down to the smallest atom = fields, and numbering 1, 1.1.2.3 for sub trees. Prepare to draw the labels AND the graph/splines between them. … a future UI editor, where I can place out components and get the 'code' for it."
1. What the graph is
A tree of nodes, one per addressable thing on screen. The tree has a spine and a leaf level:
| level | kind | example id | who names it |
| 1 | App |
korp |
host |
| 2 | Surface (tab, page, dialog) |
korp.explore |
host |
| 3.. | Pane (a facet mount) |
korp.explore.graph.left |
host (MountId, phase-3 design) |
| .. | Group (form section, toolbar, split) |
korp.explore.graph.left.filter |
component or host |
| leaf | Field (text edit, button, checkbox, a painted value) |
….filter.name |
component |
Each node carries:
- id: the dotted path, stable across frames and builds. It is FC-5's stable id and is never a loop index.
- kind
- label: the human text.
- role: the AccessKit role, from
a11y::Semantics. - rect: this frame's rectangle.
- order: the declared rank, or none.
The number is the path of 1-based ranks from the root: 1, 1.2, 1.2.3, 1.2.3.4.
- Declared order wins. The host (or the layout document, see §5) gives each parent an ordered list of child ids.
- No declaration: the rank is the draw order in this frame, shown as
~1.2.~3. The~sits on each guessed step, so you can see a guess and it can never pass for a declaration. - Declared parent, child missing from the list: the child gets
?, e.g.1.2.?. The?doubles as a to-do list.
Edges, three kinds, drawn with separate styles:
- Contains: parent → child. This is the tree itself.
- Next: sibling n → sibling n+1 in the declared order. This is the navigation order (Tab / chord).
- Spatial (optional, off by default): the arrow-key neighbour from
nav::nearest_in_direction, for checking that arrow navigation agrees with the declared order.
2. How it is collected (zero allocation, one pass)
facett_core::navgraph::NavGraph is a per-frame arena kept in egui's Context memory. It is allocated once and cleared every frame; nodes are indices into a Vec whose capacity is reused.
-
Scope stack.
navgraph::scope(ui, id, kind, |ui| …)pushes a parent and pops it when the closure ends, so a child's parent is known without the component threading it. -
Registration points that already exist:
a11y::node(...): every painted, FC-4-compliant element registers as aFieldwith the current scope as parent. Fields come for free wherever FC-4 is done.devid::badge(...): every deck pane registers as aPane.focus::FormField/FocusGroupregister asField/Group.
-
New, thin:
navgraph::field(ui, id, response)for plain egui widgets (aTextEdit, aButton), which emit no a11y node of our own. -
Declared order:
NavOrder, a map from parent id to its ordered child ids, handed in by the host once. It is not rebuilt every frame. -
Release builds: everything above compiles to nothing unless dev mode is on, under the same gate as
devid(debug_assertionsorFACETT_DEVID=1).
3. How it is drawn
A top layer is painted after everything else, so dev mode never moves layout. This is the same rule the devid badge follows.
-
Label: a chip with the number, plus the short id on hover, e.g.
1.2.3 · filter.name. Colour comes fromlook::(no literals). The chip anchors at the node rect's top-left corner. Labels that collide are pushed down and right, one pass, deterministically. -
Depth limit: show levels ≤ N (default 3). Levels below N expand under the hovered or focused node. A whole app down to fields is thousands of chips, and all at once would be noise.
-
Splines:
edges::spline_points/draw_edgefrom label anchor to label anchor. They follow the existing device rule of crisp hairlines with no glow.- Contains edges are faint and tinted by depth.
- Next edges are brighter and carry an arrowhead at
point_at_frac(0.9). - Spatial edges are dashed.
- The paths are pure functions of the rects, so snapshot tests are stable.
-
Toggles: a dev-mode key cycles off → labels → labels+next → labels+all edges.
4. How it is used (not only looked at)
- Chord + number focuses the node, e.g.
Ctrl+G 1 2 3 Enterfocuses1.2.3. This reusesfocus::hintsfor the key handling. - The AccessKit tree says it:
nav: 1.2.3 filter.name [focused], so robot tests address1.2.3headless. NavGraph::to_json()dumps the whole graph (ids, numbers, kinds, rects, edges). It serves as a test artefact, and it is what §5's editor reads.- A test law: in a host with a declared
NavOrder, no node may show~or?. That is the "everything is declared" gate.
5. The future UI editor — what else it needs
The editor's goal: place components and get the "code". The navgraph is its skeleton, because the editor selects, drags and numbers the same nodes. To get from here to there, these pieces are also needed:
-
A layout document, the source of truth. Today the tree exists only as Rust code (korp's
impl Korp). The editor needs it as data: aLayout(ron/toml) made of nodes, each{ id, component, props, slot, children }.- The navgraph's declared order is the document's child order, so
NavOrdercomes for free. - "Get the code" then means the document plus a generated Rust builder for it (below).
- The navgraph's declared order is the document's child order, so
-
Component registry with a property schema.
facet-wrapped's registry already has oneEntryper component, with a fixture (demo()) for preview and aDataFactory. The missing piece is a typed props schema per entry (name, type, default, range, doc). That schema is what the editor's inspector renders. -
Containers as components. Split, tabs, grid, stack and form each need named slots, so there is somewhere to drop a component. Today these are layout code, not registry entries.
-
Bindings. Which state path each field reads and writes, and which message an event sends (the Elm loop,
elm-design.md). Without this, a placed component is a picture, not an app. -
Codegen that round-trips. Layout doc → Rust (the builder calls a hand-written view would make).
- Deterministic output.
- The test: doc → code → compile → render → the same navgraph JSON as rendering the doc directly.
- Edits come back to the doc, never into the generated code.
-
Editor mode on the same overlay. Hit-testing, selection, drop targets and reordering use the navgraph's rects and scopes. Undo and redo are operations on the document.
Order: 1 → 2 → 3 make placement possible. 4 makes the result work. 5 gives the code. 6 is the UI on top. Phase 1 below builds none of this. It only keeps the node ids and the declared order compatible with §5.1, so the editor never needs a second id scheme.
6. Phases (not scheduled — no code before look and feel v2.0 is designed)
| phase | where | delivers | proof |
| 1 | facett-core navgraph.rs |
arena, scope stack, registration hooks (a11y, devid, focus), numbering incl. ~/?, overlay (labels + 3 edge kinds), to_json, chord focus |
unit tests for numbering and spline paths; a facett-demo kittest: declared order ⇒ no ~/?, chord 1.2 focuses the right node, JSON snapshot |
| 2 | korp | NavOrder for korp's tabs and panes; panes wired into the deck |
the korp robot test addresses panes by number |
| 3 | korp + facett | fields in korp's forms registered | the "no ~/?" law green on korp |
| 4+ | facett | §5 editor pieces, in the §5 order | round-trip test in §5.5 |
7. Decided (owner, 2026-09-25)
- The chord is
Ctrl+G(then the number, then Enter). - Depth N = 3 (app, surface, pane). Groups and fields show under the hovered or focused node only.
- The layout document (§5.1) is ron.
8. Part of look and feel v2.0
The navgraph's chips, splines, depth tints and edge styles are look-and-feel tokens, not literals of their own. They belong in the v2.0 token set beside colour, spacing and motion, so the overlay, the devid badge and a future editor share one visual language. Open for the v2.0 design:
- the token names for chip, the three edge kinds and depth tints;
- light and dark variants of each;
- the device rule (crisp, no glow) carried over from v1
EffectsPolicy.
9. Phase 1 as built (2026-09-28)
Where it lives
| piece | file |
| arena, numbering, edges, JSON, plugin, keys, overlay | facett-core/src/navgraph.rs |
| overlay colours (v1 roles → v2.0 token names) | facett-core/src/look/navtokens.rs |
| buffered spline sampling (no allocation per edge) | facett-core/src/edges.rs spline_points_into |
| registration: FC-4 atoms | facett-core/src/a11y.rs node → navgraph::atom |
| registration: deck panes | facett-core/src/devid.rs badge → navgraph::pane (+ install) |
| registration: tab strip + active pane | facett-core/src/lib.rs FacetDeck::ui / pane_ui |
| registration: form groups | facett-core/src/focus.rs FocusGroup::nav_register |
browser half (Ctrl+G default, ?nav=) |
navgraph::web (wasm only) |
| demo wiring + declared order | facett-demo/src/lib.rs draw_app, demo_nav_order |
| Streamlit wiring | facet-wrapped/src/devmode.rs, facet_dev / facet_navgraph in wasm.rs, loader args.dev, sidebar toggle in py/facett_demo_python/app.py |
How it runs
- An egui
Plugin(navgraph::install, idempotent;devid::badgecalls it, so every deck host has it) clears the arena inon_begin_passand, inon_end_pass, numbers the nodes, places the chips, paints onOrder::Debugand publishes the AccessKit nodes. The plugin keeps no reference to the context. - The arena is taken out of context memory for the end-of-pass work (
mem::take, which allocates nothing) so painting never runs inside the memory lock, and is put back after. - Chip AccessKit nodes are written with
Context::accesskit_node_builder, notinteract. A hover-sensing widget on a top layer would take clicks from whatever is under it (egui's hit test stops at the first layer that covers the pointer), which is the same defect the dev-id chip had with the Git pane'sLogtoggle. - Nothing is collected while the overlay is off and no chord is being typed. A process-wide atomic counts the collecting contexts, so an idle dev build pays one atomic load per registration point.
- Zero allocation in the arena at steady state: node slots, their
Strings, the index map, the edge vectors and the spline buffer are all reused. Text layout for the chips goes through egui's painter and allocates there.
Differences from the design text
?carries the draw rank (1.2.?3, not1.2.?), so two undeclared siblings are still tellable apart and a chord can reach each.- A wildcard slot (
*in aNavOrderlist) declares the place of the one child that is not named: the active pane of a tabbed deck, whose key changes with the tab. - Two more ways to declare, both per frame, by the container:
declare_draw_order(a tab strip, whose draw order is the deck's facet order) anddeclare_children(aFocusGroup's field list). - Mode default is off. Existing golden snapshots are recorded in debug builds with the dev-id chip on; a navgraph that drew by default would change every one of them.
FACETT_NAVGRAPH=labels|next|all, aNavModeset by the host,?nav=on a web page, orCtrl+Shift+Gturns it on. - The browser eats Ctrl+G (Chrome: find next, which opens the find bar and takes the digits). eframe's web runner does not prevent that default, so
navgraph::web::prevent_browser_ctrl_gadds a capture-phase listener onwindowthat does. - Spatial edges are computed for shown nodes only. A pane with thousands of hidden atoms would make them quadratic; the JSON's
edges.spatialis therefore the shown set, and only in modeall.
Keys
Ctrl+Shift+G cycles off → labels → labels+next → labels+all edges. Ctrl+G, digits and ., Enter focuses the node (a node that is not a widget focuses its first focusable descendant); Escape cancels, Backspace edits. The chord being typed is shown bottom-left.
Proof
facett-coreunit tests (navgraph::tests,look::navtokens::tests): numbering with nothing declared (all~), declared order winning over draw order with the unlisted child as?, the wildcard slot, merge of a second registration, key sanitising, arena capacity reuse, depth limit with hover and focus, next edges in rank order, deterministic chip collision, spline paths equal tospline_pointsand buffer reuse, JSON shape, mode cycle; token plate opaque and the three confidences distinct.facett-demo/tests/navgraph_demo.rs(kittest, the realdraw_app): the declared spine shows no~/?;Ctrl+G 1.2.1.3 Enterfocuses the third tab and the tree saysnav: 1.2.1.3 tabs.<title> [focused];Ctrl+G 1.2 Enterfocuses the deck's first focusable node; the spine JSON matchestests/snapshots/navgraph_demo.json; off means no atoms and no nodes, andCtrl+Shift+Gturns it on.facet-wrapped/src/devmode.rstest: a wrapped component is1→1.1, declared.
Phase 2 as built (2026-09-29, lane A, branch lane-a/navgraph-streamlit @ b7859b9)
korp's tabs and panes are not korp code: korp (and dwarves, and every scene-based app) draws them through facett_app::scene::render, and the scene is a DATA tree. So phase 2 is in the renderer, not in korp, and no host writes a NavOrder for its tabs and panes:
- every scene pane is a node (
scene.rsdraw_pane→navgraph::begin/end; kinds vianav_kind: rootApp, body spliceSurface, slot/areaPane, containersGroup); - a children pane declares its kids in authored order (
declare_children); - a tab set declares
[tabs, *]— the strip, then the active tab in the wildcard slot, so the active tab is….2whichever tab it is (theFacetDeckrule);TabViewmakes its strip a draw-order-declaredtabsgroup and each header aField(tabview.rs); - the scene root is the declared root when the host set no order of its own.
- facett-core: the per-frame declaration now honours
*likeNavOrderdoes, and a harvested widget (a window-chrome button) never takes the wildcard (navgraph.rsfinish, unit testa_per_frame_declaration_has_a_wildcard_slot_that_skips_harvested_widgets).
Proof: facett-app/tests/navgraph_scene.rs (a korp-shaped scene: rail + three tabs) — shell 1, rail 1.1, tab set 1.2, strip 1.2.1, headers 1.2.1.1..3, active tab 1.2.2 before and after a tab switch, no ~/? on the spine, Ctrl+G 1.2.1.3 Enter focuses the third header: 3/3. On korp's real scene (korp/src/scene_shell.rs: korp-shell = split[facets, korp-tabs]) this gives korp-shell 1, facets 1.1, korp-tabs 1.2, the tab strip 1.2.1, the active tab 1.2.2 and its panes 1.2.2.n.
Left
- Phase 2 in korp itself: korp needs nothing but a facett that carries
b7859b9(korp path-deps../facett). Not proven on korp yet: the korp workspace does not resolve on the build box (it needs../tunnr, not reachable there), so the korp robot test that addresses panes by number is not written. What it is: in korp's robot-remote mount,navgraph::set_mode(Labels), then assertkorp-shell.korp-tabs.<active>is1.2.2for everyTab::ALLtab andCtrl+G 1.2.1.<n> Enterfocuses tab n. - Phase 3 (korp's fields): slot content is host-owned and numbers
~until the host declares it; window chrome buttons are harvested and read?under a declared pane (the to-do list). - Component-owned atoms are
~— mostly fixed 2026-09-29 (42c2a65):navgraph::declare_own_draw_order(ui)lets a component declare its pane's children in draw order without a token; called in 22 views, the demo sweep (navgraph_demoevery_tab_reports_its_undeclared_atoms_and_the_total_only_shrinks) went 198 → 8 undeclared of 210 atoms (left: scene 4, GFX v2 2, widgets 1, OSM 3D 1 — taken to 0 ine6e5e3b, gate CEILING = 0). Originally: Inside a pane, FC-4 atoms number in draw order until the component declares them (adeclare_childrenordeclare_draw_orderin the component). The demo test reports their count rather than hiding it. - Look and feel v2.0: the token names in
navtokens.rs(nav.chip.plate,nav.chip.text.declared|guessed| undeclared,nav.chip.outline.focused,nav.edge.contains|next|spatial) are the proposed keys; light/dark variants come from the v1 palette today. - Phase 4 (editor): nothing built; ids and
NavOrder(serde, ron-ready) stay compatible with §5.1.
10. Ordinary egui (owner requirement, 2026-09-28)
Owner: "navgraph must work on ordinary egui also, of course." The overlay numbers and draws any egui app — an eframe app, korp-ui, dwarves-gui — with no facett component and no hand-written navgraph::field call.
How
-
One call per app:
navgraph::overlay(ctx)(the same asinstall; idempotent, dev mode only). It adds the egui plugin; the keys are the same (Ctrl+Shift+Gcycles,Ctrl+G <number> Enterfocuses). -
Harvest (
facett-core/src/navgraph/harvest.rs): at the end of every pass the plugin reads egui's own per-pass widget record (ViewportState::this_pass.widgets— id, rect, layer, sense, and the id of theUithe widget was added to) and adds every navigable widget (it senses clicks or can take focus) that facett did not register itself:- a widget on the background layer goes under the deepest facett scope whose rect contains it (a plain
TextEditinside a deck pane lands in that pane), else under the app root (eguiwhen nothing registered one); - a widget on another layer (a
Window, anArea, a popup) goes under oneSurfacenode for that layer (layer-<hex>); - two or more widgets added to the same
Ui(a toolbar row) become oneGroup(g<hex>); - the label comes from the AccessKit node egui wrote for the widget; dev mode with the overlay on turns AccessKit on so the labels exist.
- a widget on the background layer goes under the deepest facett scope whose rect contains it (a plain
-
Refinement: facett's own registrations (a11y atoms, dev-id panes, focus groups, scopes) are nodes already and are not harvested twice; a declared
NavOrderkeyed by the harvested ids (egui,layer-<hex>,g<hex>,w<hex>) numbers them like any other node. Where nothing is declared the steps are~. -
Harvested nodes carry
harvested: true(inNavNodeand in the JSON). Plain labels and plainUis are not harvested: the graph numbers what focus can move to.
Proof
facett-core/tests/navgraph_plain_egui.rs (kittest, plain egui only: a side panel with two buttons and a checkbox, a Window with a three-button row and a TextEdit): every widget is a node with a unique number, the window is its own surface, the button row is one group inside it, every step is ~, the chips reach the AccessKit tree, and Ctrl+G <the text edit's number> Enter focuses the TextEdit without typing the chord's digits into it. In facett-demo the control strip's plain egui widgets are harvested the same way.
Left
korp-ui and dwarves-gui do not call— done 2026-09-29 on their own branches: korpnavgraph::overlayyetnavgraph-overlay@76b7e4b(src/main.rsshell_ui, so the robot-remote mount gets it too) and dwarvesnavgraph-overlay@0763014(dwarves-gui/src/main.rs:overlayin the native creation callback,navgraph::web::installin the wasm mount). Not compiled on the build box: the korp workspace needs../tunnrand dwarves' needs../gunnar, neither reachable from there; both calls exist on facett main since549dfbd. Build them where the siblings are before merging.- Harvested ids are egui
Idhashes: stable while the layout's id path is stable, but not readable. ANavOrderfor a plain app is written against them only after reading them offto_json.