canon_flutter
The Flutter binding for the canon runtime — the package a Flutter app installs. It hosts the router (Screen.manager is your routerDelegate), scopes screens, entities, and IDENTITY into the widget tree, and turns the store engine into reactive context reads: products.entityOf(context, id), viewer.of(context), shopper.idOf(context) (the identity alone — field churn never wakes it), products.item(id, child:), the deictic ProductID.navOf(context) faces, Query/Fragment view-state — surgical rebuilds (per key, per unit, structure-only for lists) as the default, not as selector discipline.
The system it binds: an application runtime context specification. You declare your app's runtime contexts as grammar trees — screens, entities, stores — and canon projects that spec into navigation, the URL, and state. Everything else hangs off that essence as a property: compile-safety is how the projection is realized, and identity, when a context has one, is a property of the context — ambient within it, read from the runtime, never threaded through application code.
Four packages carry it: canon (the pure-Dart core: grammar, nav engine, URL model), regent (pure-Dart stores: message bus, folds, optimistic overlays, request status), canon_flutter (the widget layer: router host, screen scoping, and reactive store reads with surgical rebuild granularity — per key, per unit, structure-only for lists — as the default, not as selector discipline), and canon_generator (build-time codegen). Non-Flutter consumers — servers, CLIs, tests — use the same spec without a Flutter dependency.
Compile-safe Flutter navigation generated from one grammar tree. The transitions you're allowed to make are the only methods that exist — an illegal route is a compile error, not a runtime crash.
Built for the AI-authorship era: a machine can only emit legal navigation, and a human audits the entire nav space at a glance in one small spec.
One grammar, both ends: it drives identical navigation on mobile and the web — typed deep links, view-state mirrored to the URL, and real browser back/forward that survives a refresh. The compile-time closed nav space is the part no other router has; the web fidelity is table stakes, done right.
The whole app, on one screen
import 'package:canon/canon.dart';
part 'screen.canon.dart';
@canon
enum _Screens with ScreenNode<_Screens> {
home(HomeScreen()),
search(SearchScreen()),
messages(MessagesScreen()),
profile(ProfileScreen()),
user(UserScreen(), .uuid),
post(PostScreen(), .uuid),
editPost(EditPostScreen(), .uuid),
comment(CommentScreen(), .uuid),
thread(ThreadScreen(), .uuid),
settings(SettingsScreen());
const _Screens(this.widget, [this.id]);
@override final Widget widget;
@override final Codec? id;
// A profile: this user's posts, and links to other users (followers).
static _Screens _user() => user({
post({ comment, user.again }),
user.again, // tap a follower → another profile, fresh frame
});
static final graph = ScreenGraph({
home.keep({ _user() }),
search.keep({ _user(), comment })
.query({ _View.text(.string), _View.sort(.enumValues(SortBy.values)) }), // URL ?text=&sort= — historyless mirror
messages.keep({ thread({ _user() }).fragment({ _View.at(.uuid) }) }), // URL #at=<msgId> — a scroll anchor
profile.keep({
// editPost's id is always this post's; `dirty` is a shared flag (any editor
// can mirror it) → a global close-guard can ask "is anything unsaved?"
post({ editPost.inherit(post).query({ _View.dirty }), comment }),
settings,
}),
user.link({ slot(.username) }), // /user/<username> — a shareable deep link → user
}, root: const SplashScreen()); // boot UI, until the resolver commits the first screen
}
// View-state keys (the URL `?query` / `#fragment`) — a QueryKeyBase enum, `key(codec)`.
enum _View with QueryKeyBase { text, sort, at, dirty }
A row is name(WidgetConst()) or name(WidgetConst(), idCodec). One library-private @canon screens enum, one ScreenGraph, part 'screen.canon.dart'; — that's the whole grammar. Codegen turns this tree into a typed Screen facade whose methods are its edges. Read this section and you've read the app's navigation; everything below maps to a line in it.
The core: typed transitions are legal moves
Without canon — routes are strings, ids are stringly-typed map lookups:
context.go('/messages/thraed/$id'); // typo compiles, crashes at runtime
final id = state.params['treadId']; // wrong key → null → blank screen
The typo, the wrong key, the wrong id type — all invisible until a user hits them.
With canon — the transition is a generated method that only exists where the edge exists:
Screen.goThread(id); // thread is single-placement → kick-start exists
The typo cannot exist: there is no goThraed. Screen.goThread() with no id is a compile error (thread is id-bearing). Screen.goThread(42) is a compile error (its id type is String). There is no Screen.goComment at all — comment has two parents, so it has no unambiguous kick-start. Illegal routes aren't caught; they're unrepresentable — not a string to mistype, not a map to mis-key.
And goX is non-destructive by construction — a deep link or push extends from your live position, it never nukes the back stack. Bug class deleted.
Two ways to move
Kick-start — from anywhere; emitted only when the target is reachable single-placement with the ids you supply.
Screen.goHome();
Screen.goSettings();
Screen.goThread(threadId);
For a dynamic kick-start, Screen.go(Hop.x) takes a ternary and returns the landed placement directly — the least-common …Nav (a sealed AnyPlacement for a cross-screen ternary), switched exhaustively.
Surgical-chain — continue from a live position; ids already on the stack are reused. Screen.on(.path) returns a placement (a typed …Nav) or null, each step offers only satisfiable children, and a whole path commits as one transition:
Screen.on(.search)?.goUser(id); // .user matches ANY live user
Screen.on(.user(id))?.goPost(postId); // .user(id) pins one occurrence
Screen.goHome().goUser(authorId).goPost(postId); // chain disambiguates: post lands under THAT user
Atomic — a chain written in one expression commits as a single transition: one diff against the live stack, one animation. Entries that still match (same screen and id) are reused, not rebuilt, so the chain changes only what actually differs. That's why Screen.at(.home)?.goSettings() pops what's above home and reuses the rest — a minimal jump, never a teardown-and-recreate — and why a surface()-then-go reads as a single declarative "end up here."
Broad reach — a kick-start's reach extends inward: a single-placement id screen gets its goX(id) on every ancestor whose path down to it crosses no unrelated id screen (id-free intermediates auto-fill), so an ancestor jumps straight to a descendant supplying just the one id it needs. (post is multi-placement, so there's no broad goPost — only the direct-child edge above; editPost below is the real broad-reach case.)
Inherit: an id that's provably the parent's
editPost is an ordinary .uuid screen. Placing it as editPost.inherit(post) declares that in this placement its id is always post's — which buys two things at once:
- Sugar — you never re-pass that id; it's taken from the live
post(the edge verb has no id param). - Guarantee — there is no slot to inject a different id, so the compiler proves
editPost's id is 100% thispost's. The classic bug — opening an editor on the wrong entity — isn't caught at runtime, it's unrepresentable.
Inherit is per-placement: put editPost somewhere without post as an ancestor and it's just a normal id screen taking its own id. Transitive — it flattens to the ultimate id source.
Screen.goEditPost(postId); // kick-start: stamps post AND editPost
Screen.on(.post)?.goEditPost(); // already on post → nothing to pass
Screen.on(.profile)?.goPost(postId).goEditPost(); // give post the id; editPost inherits
Screen.on(.profile)?.goEditPost(postId); // broad reach: profile → editPost, one id, both pushed
assert(context.idOf(.editPost) == context.idOf(.post)); // inside editPost: always holds — the guarantee
parentOf: push onto whoever hosts you
comment has two distinct parents (post, search). parentOf pushes onto whichever currently hosts you, with no branching on where you are:
Screen.on(.parentOf.comment)?.goComment(id);
Screen.on(.parentOf); // compile error — target mandatory
Screen.on(.parentOf.home); // compile error — home is a trunk, it has no parent
Recursion: again
A profile links to other profiles, and a post links back to its author. Two distinct recursions, both explicit in the tree:
user.again— drill-in: the screen may follow itself again, each visit a fresh frame with its intermediate frames kept (follower → follower → follower). The one universal fold stays: navigating to the exact current top is a no-op — and a declared.againedge opts out even of that. Anything cleverer (fold-to-ancestor, cycle collapse) is yours to wire with checks on the live stack.
Cyclic screens expose depth (live occurrence count); .depth(n) pins one:
if (Screen.current case HomeUserNav(:final depth)) { ... } // how deep in the chain
Screen.on(.user.depth(2))?.popToUser(); // act on a specific occurrence
Back
Screen.canPop is null iff you're at a trunk. Screen.pop() is sugar for Screen.canPop?.pop() — it pops if it can and returns where you landed (or null at a trunk), never throws. That destination is a sealed PopDestPlacement — one case per non-leaf placement you could land on — so you switch on it directly:
final landed = Screen.pop(); // null at a trunk; else a sealed PopDestPlacement
switch (landed) { /* one case per non-leaf placement — exhaustive */ }
Screen.on(...), Screen.current, and every move return a placement — a typed …Nav like ThreadNav or HomeUserNav. It's a transient cursor: each move returns the next placement and you step from that.
Screen.goThread(threadId).goUser(userId); // chain off each returned placement
A placement is edge-required and single-use — a spent or stale one throws instead of acting from the wrong spot:
final thread = Screen.goThread(threadId);
thread.goUser(userId); // moved off thread
thread.pop(); // throws — thread is already spent
final thread = Screen.goThread(threadId);
await save(); // navigation may move on during the gap
thread.goUser(userId); // throws IF user is no longer a live edge from here
Where a screen has 2+ children or ancestors, go(Hop.x) / popTo(Pop.x) take a ternary for dynamic branching — go(busy ? Hop.user(a) : Hop.comment(c)) — returning the least-common placement type; chain off a named verb when you need a specific one.
Inspect & reach a position — typed, exhaustive
The bug: "is route X active?" by string/regex compare.
Foreclosed: Screen.current is the exact foreground placement (never null), a sealed AnyPlacement you switch exhaustively. Screen.on(.user) is user's placement if it's the front, else null — and it's already the typed placement, so a placement you forget to handle won't compile:
if (Screen.current case HomeUserNav n) ... // exact front placement
switch (Screen.on(.user)) { // no default — add a placement and this won't compile
case HomeUserNav(): ...
case SearchUserNav(): ...
case MessagesThreadUserNav(): ...
case null: ... // user is not the front
}
Screen.on(.x) is front-only; Screen.at(.x) reaches a placement anywhere on the live stack — front or buried. On it, surface() brings it to the front (a no-op if it already is), and goX() is a smart jump — pop back to it, then navigate, as one atomic diff:
Screen.at(.user(id))?.surface(); // bring that user up (no-op if already front)
Screen.at(.home)?.goSettings(); // jump back to home, then settings — one diff
Screen.stack exposes .current, .currentId, .screens, .reachable, and .tab (the active trunk — the bottom of the stack).
Each reach has two forms. Screen.on/Screen.at read a placement once — to inspect or navigate. context.on/context.at are their reactive twins: the same selector, but they return the typed view and rebuild the widget surgically — only when a key the selector names (or the foreground) actually changes, never on unrelated nav. Two axes: Screen. vs context. = read-once vs reactive rebuild; .on vs .at = foreground vs anywhere on the stack. (Screen.<screen>Of(context) reads this widget's own placement.)
Read a screen's own id
The bug: a mirrored currentUserId provider, or id threaded through every constructor — two sources of truth that drift.
Foreclosed:
final userId = context.idOf(.user); // typed, never null for an id-bearing screen
context.idOf(.home); // compile error — home has no id
No InheritedWidget, no mirror, no route-param threading. (When one widget backs 2+ id-bearing screens, codegen emits a sealed Screen.<widget>Id(context) resolver you switch exhaustively — not needed in this tree.)
View-state: typed URL query/fragment, reactive
search.query({...}) / thread.fragment({...}) declare screen-local view-state — nullable, typed, mirrored into the URL's ?query / #fragment as a historyless replace (it never floods back-history). Write it through the placement; read it surgically in a widget:
Screen.on(.search)!.query.sort = .recent; // write — mirrors to ?sort=recent
final text = Query.of<String>(context, _View.text); // read ONE key — rebuilds ONLY when `text` changes
Query.of / Fragment.of are fine-grained: a widget watching a key rebuilds only when that key changes — selection rebuilds with no provider/selector boilerplate. context.on(.x) reads the typed view through the placement reactively, subscribing only to the keys (and foreground) the selector references:
final search = context.on(.search.query({.sort(.recent)})); // SearchView? (null off search / when sort ≠ recent)
// rebuilds when `sort` changes value or search (de)foregrounds — never on unrelated nav or other keys;
// no change to a watched value → no rebuild
// global, across screens: a flag read anywhere on the stack — true while ANY
// editor is dirty. Backs a close-guard; rebuilds only when a `dirty` flips.
final unsaved = context.at(.query({.dirty})) != null;
context.on is the foreground read, context.at the same anywhere on the stack; a placement-less On.query({...}) reads view-state globally across screens (the close-guard above). Screen.ownerOf(context) / isForegroundOf(context) read this widget's own placement, reactively.
State retention
keep / forget decide whether a scope's stack survives leaving it for another trunk (a kick-start to a different family) and coming back — build-time checked to actually flip inherited state, so a no-op annotation won't compile:
home.keep({ _user() }) // jump to another trunk and back → home's stack is intact
child.forget() // this subtree is dropped, rebuilt fresh on return
Retention applies only to that trunk switch — a popTo/go to an ancestor within the scope pops the screens above as normal; they're gone.
The state legs: @entities & the regency
Navigation is one projection of the spec; state is the other. A small OPTIONAL enum binds entities to their id nodes (it earns its place binding nodes or declaring ownership — with neither, omit it and the entity space derives from the store rows), and a const REGENCY declares its REGENTS (pure folds and judges of message families — the record is the only truth, every store is a cached fold, and SET ORDER IS TRAVERSAL ORDER: a guard row protects exactly the rows below it):
@canon
enum _Entities with EntityNode<_Entities> {
cart(CartState), // keyless — a UNIT: the session is its identity
product(Product, .product), // a row binds an entity TYPE to its id node
review(Review, .review);
const _Entities(this.type, [this.key]);
@override final Type type;
@override final Ids? key;
// The graph declares OWNERSHIP only — an unlisted row is a root; an
// all-flat entity space omits it entirely.
static final graph = EntityGraph({
product({review}), // reviews live inside their product
});
}
// Consumer-named rows — the audit list; the generator hangs the reads on
// these names (`products.idsOf(context)`), it never invents its own.
const cart = Cart();
const catalogCovered = CatalogCovered();
const products = Products();
@canon
const app = Regency({
cart, // Unit — folds the keyless cart facts
catalogCovered, // coverage — the gate reads it
CatalogGate(), // a VETO row: judges the flow for rows below
products, // Store<ProductId, Product, ProductMsg> — the disk
// cache folds in as an absence-only arm
}, merges: {
// Merge edges: a store READS-FROM another through a projection that
// carries its own endpoints (the dock/self patterns).
WriteSupportsCart(),
});
/// A guard judges the ledger's OWN state by regent identity — pure,
/// replayable, positioned. Every `read(…)` is checked at build time:
/// the named instance must be a row of the graph.
final class CatalogGate extends Veto<CatalogCacheMsg> {
const CatalogGate();
@override
bool block(CatalogCacheMsg msg, ReadStore read) =>
read(catalogCovered);
}
YOU name the rows — const globals, the app's audit list (const products = Products();); const canonicalization makes the global and any equal
construction one instance, so the name IS the row. The runtime builds the
ledger (Ledger.root(app) splices rows and wires merges); codegen hangs a
read extension on each row CLASS — the generator never invents a name, it
makes YOUR names the whole surface — plus surgical tree ops derived
from the ownership graph (addReview(productId, review) and friends). The
reads are where this package earns its name — reactive, with the engine
deciding granularity once instead of every consumer re-deriving it:
final ids = products.idsOf(context); // key SEQUENCE — rebuilds on add/remove/reorder ONLY
final rows = products.entitiesOf(context); // the ROWS — rebuilds on shape AND on any row's value
final product = products.entityOf(context, id); // ONE entity, nullable — rebuilds when THIS key changes
final cartState = cart.of(context); // the unit's state
products.item(id, child: ProductCard()); // list item: plants the EntityScope, self-keyed
A unit whose state is Identifiable splits WHO from WHAT: the generated
idOf(context) subscribes to the identity alone — profile fields churning
never wake it, only a sign-in/out does — and shopper.id is its now-read
twin:
final who = shopper.idOf(context); // rebuilds ONLY when the id changes
No selectors, no listEquals discipline: the store's fold already computed which keys changed and whether the key sequence changed (the structure feed), so a value edit rebuilds one card and a list shell rebuilds only when membership does.
Request status is a ROW, not a field. An in-flight unit folds the
request fact's key in and the answering facts out — presence is loading,
read with the same reactive surface as any state; a gate reading it drops
duplicate asks at the queue. No loading fields in state, no machinery:
final class ReviewsInFlight extends Unit<Set<ProductId>, ReviewsInFlightMsg> {
const ReviewsInFlight() : super(const {});
@override
Set<ProductId> reduce(Set<ProductId> state, ReviewsInFlightMsg msg) =>
switch (msg) {
GetReviews(:final productId) => {...state, productId},
ReviewsPage(:final id) => {for (final k in state) if (k != id) k},
};
}
final loading = reviewsInFlight.containsIdOf(context); // at the ambient id
And because the WebSocket is itself a subscriber (ledger.at(.exit).msgs<OutMsg>() sends), dispatch(fact) is the app's only verb — top-level, no prefix: the same call states a local fact, sends a request, and marks its key in flight.
Ambient identity: the deictic reads and verbs
Every scope PLANTS the identity it holds — the screen its nav id, an
EntityScope/store.item its item's, a bare IdScope(id) anything else —
each tagged with its grammar NODE. The generated per-node faces resolve to
the nearest plant of their node (extension types erase, so nearness
alone could hand back the wrong identity wearing the right type — node
matching makes that impossible by construction):
ProductID.of(context) // the typed ambient id
ProductID.navOf(context).go(); // DEICTIC nav: from where this widget
// stands — no chain named, no id passed
ProductID.on(context, On.seller)?.goChat(); // a CLAIMED chain: the composite's other
// component read from the stack; a chain
// that proves nothing is a COMPILE error
ProductID.screenOf(context); // one source only: the screen's
ProductID.itemOf(context); // one source only: the item's
chatsStore.idOf(context); // provenance: the scope's id ONLY if
// planted from THIS store (null else)
Item widgets never pass the id they stand on; outbound FACTS still carry
their ids explicitly — the id is read ambiently, the write stays a
visible, journaled value (dispatch(SetQty(ProductID.of(context), 3))).
Codecs (id types)
The id is a value-witness: write a codec and its T becomes the screen's static id type (.uuid ⇒ String). Type safety is that T — the codec itself is for restoration and deep links, a strict string ↔ T round-trip whose decode returns null to reject malformed input (it validates the string form, it doesn't add a finer static type):
.string .raw .uuid .username .email .integer .number .date .enumValues(...) .record2/.record3(...) .csv(...) — or any const class implementing Codec<T>.
Build a shareable link
The inverse of the resolver: every screen is also a deep link, and the typed builder turns a route into a URL with .toUri() — no string-building, every id checked by its codec.
Link.home.user('u1').toUri() // /home/user/u1 — address it the way you'd navigate
Link.editPost('p1').toUri() // /profile/post/p1/edit-post — jump straight to an
// unambiguous screen; the one id back-fills its path
Link.search.query({.text('shoes')}).toUri() // /search?text=shoes — view-state rides the SAME
// dot-shorthand set as Screen.on, minus .not
WidgetLink.<route> is the nav tree (every renderable screen); a .link branch adds resolve-only leaves on WidgetlessLink.<route> (/<username> → username, no screen yet); Link.<route> is both.
Host & lifecycle
MaterialApp.router(routerDelegate: Screen.manager) // THE host — web + mobile, one name
final off = Screen.observe((from, to) { ... }); // post-commit listener, no veto
final snap = Screen.snapshot(); // manual snapshot
Screen.restore(snap); // best-effort; truncates at first illegal edge
Screen.manager is the one host — a RouterDelegate you wire into MaterialApp.router(routerDelegate:). It owns the stack and system back on mobile and the browser back/forward + URL channel on web. (The single name is deliberate: if the wiring ever changes, the name stays — always pass it where a RouterDelegate goes.)
ScreenGraph (this package's construction of the graph) takes a root: boot widget (a splash, shown until the first real screen commits), optional chrome (per-page dressing, wrapped INSIDE the ScreenScope so it reads context.screen), optional pageOf (defaults to MaterialPage), and optional observers — every render value typed in the tier that names the types. The pure NavGraph construction carries the grammar alone (servers, links-only trees, headless tests). Mount another enum's screen family with graft(Other.tree()).
Cold start & deep links. root: is a boot widget shown until the first screen commits — the launch URL and every runtime deep link flow through the one resolver (see One model below); the first commit out of boot auto-replaces the splash, leaving no history. The boot widget itself reads Screen.rootUrl (the launch link, parsed) only to tailor the loading UI — e.g. a profile skeleton when the app opened on a user link — while the resolver does the navigating.
The navigations stream (Screen.navigations / Screen.observe) fires after each commit with the source and destination stacks — diff them for transitions, analytics, or restoration.
One model, web and mobile
The same stack drives both platforms — back means the same thing whether it's the Android button or the browser's. The root: widget and one resolver are the whole contract.
The resolver turns any inbound Url into navigation, the same way for the launch URL, a mobile deep link, or the browser's back/forward buttons:
Screen.resolver = (Url? url) => switch (url) {
Place p => Screen.go(p), // a nav-mirror path (/profile/post/p1) → go straight there
UserLink(:final username) => Screen.goUser(username), // a /.link leaf → resolve to a screen
_ => Screen.goHome(), // bare / or unknown → default landing
};
canon hands the resolver a sealed Url? — one of: a Place (a path that mirrors a nav position — go-able, it implements Hop so Screen.go(place) replays it), a Link (a resolve-only .link leaf carrying data, no screen yet), RootUrl (bare /), or null (unparseable). Each parsed Url also carries url.domain (the inbound scheme://host[:port], e.g. http://localhost:8787) — read it in the resolver to branch on origin; it's null for a locally-built Url.
On web, canon speaks the browser History API directly: goX/push add entries, surgical jumps go(-N) to drop stacks, and the back/forward buttons feed popstate straight back through the resolver — so physical back and Screen.pop() land identically. A refresh reconstructs the live stack from the stored entry; a real cold-start runs the resolver fresh.
Cold-start on web is one entry, by design. A browser won't let a page that the user hasn't interacted with fabricate a back-chain (an anti-trapping rule) — so a deep cold-start Screen.go(place) lands a single returnable base, not a fanned-out stack. That's exactly why root.front exists: it renders the face of that one base entry until the user navigates and real history accrues. On mobile this constraint doesn't exist at all — canon owns the stack outright, so Screen.go(place) builds the full path immediately. The resolver code is identical on both; only the web honors the activation rule, transparently.
The bottom of the history — the root — has three faces the consumer picks via Screen.root:
Screen.root.anchor()— a deep cold-start (someone pasted/profile/post/p1) keeps a returnable base showing the front screen.Screen.root.passthrough()— a bare/is a spent floor: pressing back from the first real screen exits the app rather than trapping the user.- the
root:widget renders/itself: readScreen.root.kind(null when a real screen is committed) andScreen.root.frontto either show the current face or whatever home you like.
Scope: canon owns an in-memory stack and system back, mirrors the active path and view-state into the URL (?query/#fragment, historyless), syncs full browser back/forward history on web, builds shareable links with .toUri(), and parses inbound ones from the grammar's .link branches. canon_link remains the standalone, Flutter-free URL ↔ sealed-Link codec for non-Flutter consumers.
Guarantees
- Compile-time: illegal targets, missing/mistyped ids, and back-at-trunk aren't expressible — the methods don't exist or don't type-check.
- Build-time validation: one owner per screen name; every declaration of a name agrees on id type;
inheritmust target a real ancestor with a matching id type;keep/forgetmust genuinely flip retention; a name is a screen or a.linkbranch at a given position, never both; redundant forms are rejected — a bare leaf (X, notX()), and no emptyslots({})(a screen is already linkable by its id). - Runtime: a placement's verbs are edge-required — they throw on a stale-invalid edge rather than silently teleporting. The engine's raw
go/popare@internal; the typed verbs are the only navigation surface. - Drift check:
assert(Screen.isCodegenFresh)in a test fails if codegen and the live tree diverge.
The payoff: the spec at the top of this file is the complete, auditable nav space. A model can only emit legal navigation, and a human reviews every reachable route at a glance.
The packages
canon— the pure-Dart core: grammar, nav engine, URL model.canon_flutter— this package: the router host, screen scoping, reactive store reads,EntityScope.regent— pure-Dart stores: the message bus, folds, optimistic overlays,Awaits.canon_generator— build-time codegen: the typedScreenfacade (nav, URL mirror,.linkingress +.toUri()builders, view-state) and the typed store surface with its tree ops — all from the one spec.
Libraries
- canon_flutter
- Flutter bindings for canon: the router delegate/manager, screen scope, and reactive view-state widgets. Re-exports canon — consumers import only this.