## Context See proposal.md for motivation. The machinery this builds on, verified against the code: - `Property` (`src/prop/mod.rs`) resolves reads in layers: `vals[i]` → if it is an expression, `cache[i]` (else `defaults[i]`) → if unset, `defaults[i]` → type default. `defaults` is `Vec` and `PropertyValue::SExpr` exists, but defaults are only settable pre-`Arc` via builder `set_defaults_*` (no expression variant), so schema code can only write `vals`. `allow_exprs()` is also builder-only: a live property can never gain expr support, so factories must opt in. - Exactly one evaluator exists: `PropertyRect::eval_with` (`src/prop/wrap.rs:377`) — gathers globals from `add_depend` edges plus extras (parent w/h), runs the s-expr machine per expr index, writes the cache. Widgets run it in their per-pass paths (`MultiLine::eval_rect`, etc.). Colors and other styled f32 props are always plain values today. - Batching (`src/prop/guard.rs`): a `PropertyAtomicGuard` collects `(prop, role, action)` and on **Drop** notifies all subscribers with a shared `BatchGuard`. `RedrawTrigger::make_guard` (`src/ui/mod.rs:136`) binds the batch's `end_batch` to exactly one redraw token, fired when the last `BatchGuard` reference drops; `trigger()` enqueues into a bounded(1) channel, so multiple triggers coalesce into one pass. - Pubsub (`src/pubsub.rs`): `Publisher::notify` is a non-blocking `try_send` into **unbounded** per-subscriber queues — events published before a listener task starts polling are buffered and drained later. Therefore applying a theme during `App::setup` (before widget listener tasks exist in `App::start`) is safe: nothing is lost. - Widgets (`src/ui/text.rs:80-90, 231-264`) wrap their properties with `Role::Internal` and use `when_change_external` handlers that only clear the widget's draw cache and request a pass; the handler skips `Role::Internal` echoes so draw-pass evaluations don't retrigger passes forever. Evaluation belongs in the draw path; invalidation belongs in handlers. - Node/task teardown precedent: `menu/mod.rs:486-509` channel deletion does `clear_tasks()` + `unlink()` + `redraw.trigger()`. - Settings: `/setting` props persist via the `Setting` pimpl (`src/setting.rs`); `Setting::new` loads persisted rows with `get_property(&name).unwrap()` — an unknown persisted key panics. Enums render as "unknown" in the settings screen. Builder enum defaults are written as `PropertyValue::Str` (`set_defaults_str` on an Enum property), a latent type mismatch. - Styling is inline across `src/app/schema/**` (23 files); the scifi palette leaks into `src/app/node.rs` factories; `COLOR_SCHEME`/`PaperLight` is a compile-time second look whose arms are dead (`const DarkMode`). ## Goals / Non-Goals **Goals:** - Layered theming that reuses property fallback semantics: baseline = defaults, theme = `vals`, unload = unset. - Theme switching that is O(tokens), atomic, and covers nodes created at runtime (no re-theming pass, no `on_link` signal needed). - A defined style/structure split: schema owns layout tree and functional wiring; themes own styling, decorations, and reactions. **Non-Goals:** - Making `VectorShape` internal colors expression-driven (shape colors are baked consts; scifi rebuilds themed shapes structurally — revisit later if a theme needs it). - Dynamic/runtime-loaded themes; the registry is compile-time (`minimal`, `scifi`). - Theming plugin-owned UI beyond what plugins inherit from shared tokens. - A themes-from-disk or remote theme format. ## Decisions ### D1: Layer on defaults/vals instead of rebuilding or walking Switching rebuilds nothing. Minimal installs defaults; themes set `vals`; unload unsets. Alternatives considered: (a) destroy and re-run `schema::make` per switch — loses runtime state (scroll, focus, joined channels wiring) and is O(everything); (b) walk the tree setting props — no `on_link` signal exists, so nodes created after a switch (new channel screens) would be missed, and unload needs a journal of everything. Defaults/vals gives restore semantics for free and pushes the dynamic-node problem into creation-time wiring (D5). ### D2: Post-creation default installation, no modify events New methods on `PropertyPtr` (`src/prop/mod.rs`), mutating `defaults[i]` with the same type/length checks as the builder variants: ```rust impl Property { pub fn set_default_bool(&self, i: usize, val: bool) -> Result<()>; pub fn set_default_u32(&self, i: usize, val: u32) -> Result<()>; pub fn set_default_f32(&self, i: usize, val: f32) -> Result<()>; pub fn set_default_f32_multi(&self, vals: &[f32]) -> Result<()>; pub fn set_default_str>(&self, i: usize, val: S) -> Result<()>; /// Writes PropertyValue::Enum (unlike builder set_defaults_str, which /// writes Str onto Enum properties — a latent type mismatch). pub fn set_default_enum>(&self, i: usize, val: S) -> Result<()>; pub fn set_default_expr(&self, i: usize, code: SExprCode) -> Result<()>; /// Raw variant used by the journal and token node construction. pub fn set_default_value(&self, i: usize, val: PropertyValue) -> Result<()>; } ``` Installing a default emits **no** modification event: it is a construction-time operation (before first frame) or happens inside a switch batch where the accompanying unsets already notify. Rule: defaults are never mutated as a live styling mechanism. Alternative — a new `ModifyAction` for default changes — adds pubsub surface for no current consumer; revisit if a future feature needs live default editing. ### D3: SExpr defaults with effective-source semantics; single shared cache `is_expr`/`get_expr` resolve the **effective expression**: the `vals` expression if present, else the `defaults` expression. `get_value` resolution per the `prop-defaults` spec — never returns an unresolved expression: ```rust pub fn get_value(&self, i: usize) -> Result { match self.get_raw_value(i)? { PropertyValue::SExpr(_) => { let cached = self.get_cached(i)?; if !cached.is_null() { return Ok(cached) } // fall through to the default layer self.default_or_type_default(i) } PropertyValue::Unset => Ok(self.default_or_type_default(i)), v => Ok(v), } } /// default layer: plain default → default-expr cache → type default fn default_or_type_default(&self, i: usize) -> PropertyValue { match &self.defaults[i] { PropertyValue::SExpr(_) => match self.get_cached(i) { Ok(c) if !c.is_null() => c, _ => self.typ.default_value(), }, d => d.clone(), } } ``` One cache per index is shared between the two expression sources — after a source switch the cache is stale until the next evaluation. With draw-side evaluation (D7) **every draw pass recomputes expr indices from current dependency values**, so no pass can observe a stale cache: the window closes at the next pass, which is exactly the pass the switch triggers. Two separate caches would double the state for zero observable gain. Alternative rejected. ### D4: Themes may override any property, including rect; ownership rule Theme overrides are plain `vals` (value or expression) over baseline default-exprs; unset restores. Exception — properties the owning widget itself writes at runtime (e.g. multiline edit writes `rect[3]` height via `Role::Internal` in `eval_rect`, `src/ui/edit/behave.rs:140`) are last-writer-wins: themes MUST NOT override them, or the widget clobbers the theme. This rule is **enforced** by D13: widget-written properties carry write masks without `Theme`, so a theme attempt fails with `PropertyPermissionDenied` instead of silently losing a write race. The 5.1 scrub assigns the masks; the D4 audit becomes that assignment. ### D5: Live dependency rewiring with automatic listener resync `when_change` subscriptions snapshot `get_depends()` at widget construction and the poll loop rebuilds its poll set from that list every iteration (`ui/mod.rs:246-312`) — the list is only fixed because it is captured by value. Instead of forbidding later wiring, make the depends list a live thing: 1. `Property` (`src/prop/mod.rs`) gains a depends-changed publisher and a remover; `add_depend` notifies: ```rust depends_pub: Publisher<()>, // new field, beside on_modify pub fn add_depend>(&self, prop: &PropertyPtr, i: usize, local_name: S) { self.depends.lock().unwrap().push(PropertyDepend { /* … */ }); self.depends_pub.notify(()); } /// Remove edges matching (dep prop, index, local name) — theme unload /// restores original wiring with it. pub fn remove_depend(&self, prop: &PropertyPtr, i: usize, local_name: &str); ``` 2. `when_change_impl` (`src/ui/mod.rs`) shares the subscription list in an `Arc>>`, subscribes to `prop.depends_pub`, and on receipt rebuilds the dependency entries from a fresh `get_depends()` snapshot (entry 0 — the property itself — never changes), then runs the handler once more: dropping a receiver discards its queued messages, so the extra run closes any event missed during the swap. It is the existing invalidate+trigger handler, coalesced by the bounded(1) redraw channel. Theme rules under this mechanism: - Theme expressions may reference **any** property (not just creation-wired names) by adding depends at apply time; the resync makes the widget hear them. D4 still applies: never on properties the owning widget writes at runtime. - Theme-added edges are journaled (D8) and removed on unload, so repeated switches don't accumulate stale edges. - Theme-local names must be fresh (convention: prefixed, e.g. `th_*`) — a duplicate local name would shadow another in the eval globals. - Startup ordering converges either way: if the theme applies before widget tasks start, the construction snapshot already includes the edges; if after, the buffered depends-changed event triggers resync. The explicit `stop()`/`start()` walk (`win/mod.rs:276-291`) remains as a coarse re-init fallback (rebuilds every handler of a widget); the theme engine does not need it. Alternatives considered: (a) creation-only wiring — rejected: too restrictive for theme expressions; (b) explicit stop()/start() as the primary mechanism — rejected: coarse (whole widget), async churn, and the engine would have to locate affected widgets; kept as fallback. ### D6: `/theme` token node + wiring helpers ("classes") A root-level `/theme` node (sibling of `/setting`) created **before** `schema::make`, holding the themeable vocabulary as token properties: colors as 4×f32 (`PropertySubType::Color`), sizes/spacing as f32. Token defaults = minimal palette, installed at node construction (D2, no events). Helpers replace inline styling blocks in schema code: ```rust // src/theme/mod.rs (sketch) /// Wire `prop_name` on `node` so each component follows the token /// `token_name` on the /theme node. Installs default-exprs + depends. /// Replaces the 4× set_f32 styling blocks used across the schema. pub fn wire_color( node: &SceneNodePtr, prop_name: &str, theme: &SceneNodePtr, token_name: &str, ) -> Result<()> { let prop = node.get_property(prop_name).ok_or(Error::PropertyNotFound)?; let token = theme.get_property(token_name).ok_or(Error::PropertyNotFound)?; for i in 0..4 { let local = format!("{token_name}_{i}"); prop.set_default_expr(i, expr::load_var(&local))?; prop.add_depend(&token, i, local); } Ok(()) } /// Single-f32 variant for font_size, padding, spacing, etc. pub fn wire_f32( node: &SceneNodePtr, prop_name: &str, theme: &SceneNodePtr, token_name: &str, ) -> Result<()> { /* same, index 0 */ } ``` The token is the class; the helper call is the class assignment. Theme apply = set `vals` on ~30 tokens. **Prerequisite**: `allow_exprs()` is builder-only, so every factory in `src/app/node.rs` must call it for every themeable prop (`text_color`, `*_color`, `font_size`, `padding`, `lineheight`, spacing…) — otherwise `set_default_expr`/`set_expr` fail with `PropertySExprNotAllowed`. This factory pass happens with the Tier-0 scrub (tasks 5.1). **Theme-defined tokens.** The `/theme` root carries only the *shared* vocabulary — the tokens the schema wires defaults to, present in every theme, forming the minimal palette. Themes are not boxed in by it: `add_property` is builder-only (`&mut self` on an owned node), so live properties cannot be appended to the linked `/theme` node — but child nodes link to live parents freely (that is how the whole schema is built). A theme therefore mints its private vocabulary as a tracked child node: ```rust // src/theme/mod.rs (sketch) — ThemeCtx helper /// Create `/theme/` carrying the theme's own token properties /// (built pre-Arc with builder add_property + set_defaults_*), null /// pimpl, linked under /theme and tracked for unload. pub fn create_token_child(&self, props: Vec) -> Result; ``` Rules that fall out: - `/theme` root = shared tokens, always present. Schema default-wiring (D6 helpers) may reference **only** these — a default must never dangle when its theme is inactive. - `/theme/` child = theme-private tokens (e.g. scifi's `glow_color`), present only while that theme is applied. They appear in theme-installed **vals** expressions and wired overrides (journaled, D5/D8) — never in schema defaults. - Unload ordering matters: unset widget vals → remove journaled dep edges → unlink the token child. A dead dep edge makes `dep.prop.upgrade()` fail and eval error on every pass, so edges are removed before the node that holds their targets dies. (Defense in depth: eval may skip dead edges rather than error — implementer's choice, the ordering rule is the contract.) - Token children are data-only nodes: null pimpl, nothing persisted (only the `theme` enum is), lookup-friendly for debugging (`/theme/scifi`). ### D7: Generalize evaluation from rect to bounded f32 arrays — in the draw path Extract the `eval_with` pattern into a shared free function; add wrappers: ```rust // src/prop/wrap.rs (sketch) pub fn eval_f32_multi( prop: &PropertyPtr, atom: &mut PropertyAtomicGuard, role: Role, range: &[usize], extras: Vec<(String, f32)>, ) -> Result<()> { let mut globals = vec![]; for dep in prop.get_depends() { let Some(dep_prop) = dep.prop.upgrade() else { return Err(Error::PropertyNotFound) }; globals.push((dep.local_name, SExprVal::Float32(dep_prop.get_f32(dep.i)?))); } globals.extend(extras); let mut changes = vec![]; for &i in range { if !prop.is_expr(i)? { continue } let expr = prop.get_expr(i)?; let mut machine = SExprMachine { globals: globals.clone(), stmts: &expr }; changes.push((i, machine.call()?.as_f32()?)); } prop.set_cache_f32_multi(atom, role, changes).unwrap(); Ok(()) } impl PropertyColor { /// Globals only (no w/h extras). Called at the top of draw(). pub fn eval(&self, atom: &mut PropertyAtomicGuard) -> Result<()> { eval_f32_multi(self.prop(), atom, self.role, &[0, 1, 2, 3], vec![]) } } ``` `PropertyRect::eval_with` becomes a thin wrapper (extras = parent w/h). `PropertyFloat32::eval` serves `font_size`/spacing (index 0). **Call sites are in the draw path, not in when_change handlers** — this is the same pattern rects already use, and it is what makes evaluation timing irrelevant: the pass triggered by a switch recomputes every expr index from current token values before reading them. Handlers stay invalidation-only (`draw_cache.clear(); redraw.trigger()` — the existing `text.rs:257-260` shape). The cache writes use the widget wraps' `Role::Internal`, so `when_change_external` skips them as eval echoes, exactly like rect evals today. ### D8: Theme engine with tracked nodes, tasks, and a journal `src/theme/`: trait, compile-time registry, and a per-application ctx: ```rust pub trait Theme: Send + Sync { fn name(&self) -> &'static str; fn apply<'a>(&'a self, ctx: &'a ThemeCtx) -> BoxFuture<'a, Result<()>>; } /// Unload bookkeeping. Nodes are the storage: theme-created properties /// live on tracked nodes, theme tasks are pushed onto tracked nodes /// (SceneNode::push_task) and cancel with clear_tasks(); the ctx only /// records what cannot be reconstructed. /// /// The touched-set is required, not an optimization: `vals` carries no /// authorship, so a theme override is indistinguishable from schema /// setup or runtime state (scroll, typed text, is_visible) — unloading /// by deep-walking and resetting every property would destroy runtime /// state. Unload therefore resets exactly the (prop, i) pairs the /// theme recorded, which also means the ctx is the ONLY sanctioned way /// for a theme to modify properties it does not own. pub enum JournalEntry { /// Bounded `vals` override — unload unsets it (falls to default). /// No prior value needed: defaults ARE the baseline. Touched { prop: PropertyPtr, i: usize }, /// Unbounded/direct-list override (e.g. nick_colors) — unbounded /// props have no defaults tier, so the prior entries are recorded. List { prop: PropertyPtr, prior: Vec }, /// Theme-added dependency edge (D5), removed on unload. Depend { prop: PropertyPtr, dep_prop: PropertyPtr, i: usize, local_name: String }, } /// What a theme (and the engine) may do; everything done through the /// ctx is undone by unload. pub struct ThemeCtx { app: AppPtr, /// Roots the theme linked into pre-existing (schema) trees — /// including its `/theme/` token child. Descendants ride /// along: unlink drops the subtree. nodes: SyncMutex>, journal: SyncMutex>, } impl ThemeCtx { /// Link a theme-owned node under an existing parent; unlinked on /// unload (clear_tasks() cancels its tasks, unlink() drops the /// subtree). pub fn link_tracked(&self, parent: &SceneNodePtr, child: SceneNodePtr); /// Build `/theme/` carrying theme-defined token properties /// (D6); linked, tracked, data-only — also the home for theme /// tasks (created lazily for themes with no other nodes). pub fn create_token_child(&self, name: &str, props: Vec) -> Result; /// Push a task onto a theme-owned node so teardown cancels it. pub fn push_task(&self, node: &SceneNodePtr, task: smol::Task<()>); /// Set a bounded property value (stamped Role::Theme) — journal /// records only (prop, i); dedups repeat entries (e.g. per-step /// animation writes). pub fn set_touched(&self, atom: &mut PropertyAtomicGuard, prop: &PropertyPtr, i: usize, val: PropertyValue) -> Result<()>; /// Overwrite an unbounded list (stamped Role::Theme) — journal /// records the prior entries. pub fn set_list(&self, atom: &mut PropertyAtomicGuard, prop: &PropertyPtr, vals: Vec) -> Result<()>; /// Add a dependency edge, recording it for removal (D5). pub fn depend_journaled(&self, prop: &PropertyPtr, dep: &PropertyPtr, i: usize, local_name: String); } pub fn registry_lookup(name: &str) -> Option<&'static dyn Theme>; ``` `minimal` is the registry's identity element: it has no `apply` implementation (or an empty one) — the minimal look *is* the unloaded state (token defaults + schema defaults), so switching to minimal is just unload. Theme **behavior** is property watchers — scifi's fade: ```rust // src/theme/scifi.rs (sketch) async fn apply(ctx: &ThemeCtx) -> Result<()> { let tokens = ctx.create_token_child("scifi", private_token_props())?; set_tokens(ctx, &[("accent_color", [0., 0.94, 1., 1.]), /* … */]).await?; king_video_node(ctx).await?; // tracked structural node if ctx.app.is_first_time.load(Ordering::Relaxed) { scramble_splash(ctx).await?; // tracked node + hide task (D12) } // Watch the overlay toggle (existing property, existing pubsub) and // animate alpha. Replaces the fade inside the reconnect click // handler in src/app/schema/mod.rs. The watcher is a task pushed // onto the theme's token child — cancelled with clear_tasks() when // that node is unlinked (tasks live on nodes, D8). let overlay = ctx.app.sg_root.lookup_node("/window/content/chat/netstatus_overlay").unwrap(); let is_visible = overlay.get_property("is_visible").unwrap(); let alpha = overlay.get_property("alpha").unwrap(); let sub = is_visible.subscribe_modify(); let ex = ctx.app.ex.clone(); let fade_task = ex.spawn(async move { loop { let Ok((_, _, guard)) = sub.receive().await else { break }; if !is_visible.get_bool(0).unwrap() { continue } for step in 1..=50 { msleep(20).await; let atom = &mut guard.spawn(); // Role::Theme via ctx; touched-set entry dedups across steps ctx.set_touched(atom, &alpha, 0, PropertyValue::Float32(step as f32 / 50.))?; } } }); ctx.push_task(&tokens, fade_task); Ok(()) } ``` The engine (not the trait) performs unload from the ctx state, so themes cannot leak by forgetting cleanup. Unload order is part of the contract: `clear_tasks()` on tracked nodes **first** (cancel the actors — an in-flight watcher writing `alpha` after its unset would leave a stale value nothing resets) → unset `Touched` entries → restore `List` entries → remove `Depend` edges → `clear_values` on shared tokens → `unlink()` per tracked node (drops subtrees and their properties; token children included). Edges come off before the nodes holding their targets die, so no eval ever sees a dangling dep (D6). **`Role::Theme`.** `vals` carries no authorship, so attribution lives on the event stream: a new `Role::Theme` variant, stamped by every ctx setter (`set_touched`, `set_list`, shared/private token sets). Consumer audit: `when_change_impl` filters by equality (`ui/mod.rs:279-281`, skipping only `Internal`/`Ignored`), so `Theme` events reach widgets exactly like `App` ones — no filter change, but verified during implementation; `net.rs`/`setting.rs` produce roles rather than match them, and any external Role mapping needs a `Theme` arm. Role is bin/app-local (not serialized), so no wire impact. Bonus this buys: a dev-mode leak audit — a `Role::Theme` event arriving on a (prop, i) not in the touched-set means something bypassed the ctx; warn-log it. The ctx setters take no role parameter; `Role::Theme` is not caller-choice. ### D9: Atomic switch flow — real guard mechanics One batch for the whole switch; structural changes get an explicit trigger because unlinking notifies no properties (same as `menu/mod.rs`): ```rust // src/theme/mod.rs (sketch) pub async fn switch(app: &App, current: &mut InstalledTheme, next_name: &str) -> Result<()> { let ctx = ThemeCtx::new(app); { let atom = &mut app.redraw_trigger.make_guard(gfxtag!("theme switch")); // Unload: clear_tasks (cancel actors) → unset Touched → // restore Lists → remove Depend edges → unset shared token // vals → unlink tracked nodes unload_current(current, &ctx, atom); // Load: next theme's tokens/nodes/watchers let next = registry_lookup(next_name) .or_else(|| registry_lookup(DEFAULT_THEME)) .ok_or(Error::ThemeNotFound)?; if let Some(apply) = next.apply_fn() { apply(&ctx).await?; } *current = InstalledTheme::from(next, ctx); } // guard Drop: notify all queued actions under one BatchGuard id // Unlinks changed the tree but no properties; request a pass // explicitly (coalesced by the bounded(1) channel — free if the // batch already triggered one). app.redraw_trigger.trigger(); Ok(()) } ``` What "atomic" concretely means here, per `guard.rs`/`ui/mod.rs`: 1. All unload+apply property actions are queued in one guard and notified together at Drop under a single `BatchGuardId` — no listener ever sees a half-switched batch. 2. Widget handlers that react clear draw caches and enqueue triggers; the bounded(1) redraw channel coalesces every trigger into one pass. 3. That pass re-evaluates all expr indices draw-side (D7) from the already-settled token values, so the frame is internally consistent by construction — mixed state cannot be drawn, not merely unlikely. Startup application and live switching share this function; the `/setting/theme` watcher is an **engine-owned** task (not theme-tracked, never unloaded with a theme). ### D10: Setting integration and robustness `theme` enum property in `create_setting` (items `minimal`, `scifi`; default `scifi` installed via D2's `set_default_enum` — Enum variant, not Str). Persistence is free via the `Setting` pimpl. Read-side guard: unknown persisted theme → default (spec scenario). Fix `Setting::new` to **skip** persisted keys that no longer exist as node properties instead of `unwrap()`-panicking — a pre-existing latent bug this change makes reachable (persisted `theme` vs. older binary, or a renamed setting later). Settings screen gains a minimal enum control (cycle-on-tap row); theme names are shown raw (no i18n) in v1, matching existing settings rows. ### D11: Sequencing — PaperLight dies first All 14 PaperLight-bearing files are reworked again by the split; deleting the dead scheme first halves the branch noise and makes the minimal/scifi diff reviewable. Mechanical collapse: `match COLOR_SCHEME` arms inline to their DarkMode values; `ColorScheme`/`COLOR_SCHEME` and the window-linked `bg` vector-art block are deleted. ### D12: The splash is scifi-owned The first-run scramble splash is theme content: the scramble effect and its colors are scifi flavor, not baseline structure. It moves out of `schema::make` into scifi's `apply` as a tracked node plus a tracked hide task, gated on `is_first_time` (readable from the app context at apply time, before it is consumed in `App::start`). Consequence: the minimal theme has no splash — first run under minimal goes straight to the baseline UI, which the completeness requirement already covers. ### D13: Property permissions — Role bitflags + read/write masks `Role` becomes a bitflag set and properties carry a permission pair; setters/getters check the acting role against the mask and fail with a new `Error::PropertyPermissionDenied`: ```rust // src/prop/mod.rs (sketch) — hand-rolled u8 bit ops, no new dependency pub struct Role: u8 { const User = 1 << 0; const App = 1 << 1; const Internal = 1 << 2; const Ignored = 1 << 3; // marker ("don't notify"), rarely in masks const Theme = 1 << 4; } pub struct PropertyPermission { /// Roles allowed to read. pub read: Role, /// Roles allowed to write (set/unset/push/insert/remove/clear/expr). pub write: Role, } impl Property { pub fn new>(name: S, typ: PropertyType, subtype: PropertySubType, permission: PropertyPermission) -> Self; pub fn can_read(&self, role: Role) -> bool; pub fn can_write(&self, role: Role) -> bool; } ``` Hand-rolled bit ops rather than the `bitflags` crate: `bitflags` is not a dependency of `bin/app` today, and adding one is a supply-chain decision requiring human review (repo rules) — for a two-flag type the newtype is trivial. **Write enforcement is centralized and immediate**: every mutating API already carries a role (`set_*`, `set_expr`, `unset`, `clear_values`, `push_*`, `insert_*`, `remove_*`) — a single `can_write` check before any mutation or journal entry; denial returns `PropertyPermissionDenied` with the property untouched. This makes two existing conventions *enforced invariants*: D4 (themes cannot override widget-written properties — the write mask simply lacks `Theme`) and the ctx-only rule (a theme writing outside `set_touched` still stamps `Role::Theme` and is denied at the property layer if the mask forbids it). **Read enforcement is staged by where an actor is attributable** — raw `get_*` calls carry no role (145 sites) and stay trusted in-crate for now: 1. Wrap layer: every widget reads through `Property{Float32,Color,…}` wraps, which already hold a role from `wrap()` — `get()` checks `can_read`, and `wrap()` itself validates the role upfront (failing construction instead of at first read). 2. Expr evaluation: `eval_f32_multi` reads dependencies on behalf of a wrap role — dep reads go through the check. 3. External boundary: `net.rs` RPC property reads check against the remote actor's role. Full role parameters on raw getters is future hardening, deliberately not in this change (each of the 145 sites would need a reasoned role, not a mechanical one). **Exemptions** (by design): `set_default_*` (construction metadata, D2), `set_cache_*` (derived eval artifacts, written `Internal`), and `add_depend` (wiring metadata). Modifying a default still cannot be gated by write masks — defaults are installed before the tree is live. **Factory masks make the style/structure/function split concrete** (assigned during the 5.1 scrub; transitional default `PropertyPermission { read: all, write: all }` keeps current behavior until then): ``` prop class read write ───────────────────────────── ────────────────── ──────────────────── themeable style (colors, all internal roles App | Theme font_size, padding, spacing) widget-computed/runtime all internal roles Internal (| App for (scroll, is_focused, schema bootstrap) height, select_text, alpha on multiline rect[3]) behavior toggles all internal roles App (never Theme — (is_active, is_visible) themes don't change what the UI does) content/data (text, items, all internal roles App (User where nick_colors) user input persists) settings (/setting/*) all User | App tokens (/theme/*) all Theme (set at apply, unset at unload — nobody else writes them) ``` `Role::Theme` stamping (D8) and this table are two views of the same contract: the mask says who may, the role on the event says who did. ## Structural changes ``` bin/app/src/ ├── prop/mod.rs + set_default_{bool,u32,f32,str,enum,expr,value} │ (post-Arc, D2); effective is_expr/get_expr/get_value (D3); │ builder set_defaults_expr; Role bitflags + │ PropertyPermission on Property::new + can_read/ │ can_write + write-path enforcement (D13); unit tests ├── error.rs + Error::PropertyPermissionDenied (D13) ├── prop/wrap.rs + eval_f32_multi free fn; PropertyColor::eval; │ PropertyFloat32::eval; PropertyRect::eval_with delegates (D7) ├── app/node.rs factories: allow_exprs() on themeable props + Tier-0 scrub (D6) ├── theme/mod.rs NEW Theme trait, ThemeCtx (nodes-as-storage: touched-set, │ list/dep journal, token children, tasks on nodes), │ registry, switch(), apply_startup(), │ create_token_node(), create_token_child(), │ wire_color/wire_f32 (D6-D9) ├── theme/scifi.rs NEW scifi impl: tokens, king video, splash, fade watcher (D8, D12) ├── app/mod.rs App::setup: link /theme before schema::make; apply_startup after ├── setting.rs theme enum prop; skip unknown persisted keys (D10) ├── app/schema/** PaperLight removal; Tier-1 defaults + wiring helpers (D1, D11) └── app/schema/settings.rs enum rendering (cycle-on-tap) (D10) ``` `minimal` has no file: it is the unloaded state. ## Flows ### Setup and startup application ``` main.rs └─ App::setup(kv_db, app_db) ├─ link /setting Setting pimpl loads persisted props synchronously │ (incl. `theme`; unknown keys skipped — D10) ├─ link /window ├─ link /theme NEW: token props constructed with minimal │ palette as defaults (D2, no events) ├─ schema::make(...) structure + Tier-1 defaults + wire_color/wire_f32 │ (helpers read /theme; all depends wired here — D5) └─ theme::apply_startup() ├─ read /setting/theme → "scifi" (unknown → default — D10) ├─ switch(app, ∅ → scifi) (D9) │ └─ scifi tokens set → notifications queue (unbounded — safe, │ widget listener tasks don't exist yet; they drain later) └─ spawn engine-owned /setting/theme watcher (live switches) App::start(event_pub, epoch) ├─ window.init(); redraw_trigger.trigger() (first pass queued) └─ start_procs → Window::start draw loop + widget OnModify tasks start; queued token notifications drain into handlers (cache clears) └─ first draw pass: every widget re-evals its expr-bound props (rect + colors + fonts — D7) from settled scifi token values → first frame is fully scifi (spec: applied before first frame) ``` ### Live switch (scifi → minimal) ``` user taps theme row in settings screen └─ setting prop `theme` set → Setting pimpl persists; engine watcher fires └─ switch(app, scifi → minimal) ┌──────────────────────────────────────────────────────────┐ │ atom = redraw.make_guard("theme switch") │ │ │ │ UNLOAD scifi (journal replay in reverse + teardown): │ │ UNLOAD scifi (cancel actors, then unwind): │ │ clear_tasks() on tracked nodes FIRST (in-flight fade │ │ stops before it can write stale vals) │ │ unset Touched entries → vals fall to schema defaults │ │ restore List entries (nick_colors priors) │ │ remove Depend edges (incl. private-token refs) │ │ shared token.clear_values() × ~30 (Unset, queued) │ │ unlink() tracked nodes — /theme/scifi dies with its │ │ props and tasks, king video, splash │ │ │ │ LOAD minimal: registry identity element — nothing to do │ └──────────────────────────────────────────────────────────┘ drop(atom) ─▶ one notification wave (single BatchGuardId) │ ├─ widget handlers: draw_cache.clear() + trigger() │ (bounded(1) channel coalesces all triggers) └─ last BatchGuard ref drops ─▶ one redraw token (plus the explicit trigger for unlinks) │ ▼ draw pass: draw-side eval reads token *defaults* (= minimal palette) → one consistent minimal frame; scifi nodes absent from the tree; no residue (spec: atomic switching, teardown) ``` ### Redraw guarantee (why no mixed frame is possible) - Property mutations are only observed through the draw pass; a pass only starts by draining a token from the bounded(1) channel, and tokens are enqueued after state is settled (`make_guard` defers to end-of- batch; handlers mutate then trigger). - Passes re-evaluate every expr index from current dependency values before reading them (D7) — a pass cannot draw a stale cache even if it races a handler. - Structural-only changes (unlink) notify nothing, hence the explicit `trigger()` in `switch` — mirroring the `menu/mod.rs` precedent. ## Risks / Trade-offs - [Theme overrides on widget-written properties get clobbered] → D4 ownership rule, audited per widget during the schema split; enforcement metadata deferred. - [Live dependency rewiring touches the widget hot path (`when_change_impl`)] → D5 keeps the existing poll loop; resync is rare (theme switches), adds one subscription per watched prop, and the post-resync handler run is the existing coalesced invalidate+trigger. - [Draw-side eval adds per-pass cost] → same order as today's rect evals; exprs are tiny (var loads + arithmetic); only wired (themeable) props carry exprs. - [`Setting::new` panics on unknown persisted keys] → fixed as part of D10 (skip-and-log), before the new `theme` key ships. - [Theme node z-order collisions with schema layers] → theme-owned background/decoration nodes take a reserved low band (`z_index` 0 under `/window/content`); convention documented with the engine. - [Volume: 23 schema files to convert] → helpers land first, then per-area conversion (menu, chat, wallet, settings, root); PaperLight removal (D11) precedes; visual check per area under both themes. - [VectorArt shape colors not tokenizable] → scifi rebuilds its themed shapes structurally; neutral shapes keep baked colors (non-goal). - [First-frame ordering: theme applied before widget tasks exist] → safe by construction: pubsub queues are unbounded (events buffer), and the first pass evaluates draw-side from settled token values (Flows). ## Migration Plan Single change, no flag: default `scifi` means the shipped look is unchanged on upgrade — the new `/setting/theme` key is the only persisted addition, and the D10 skip-guard makes it harmless to older binaries. Rollback is revert; any persisted `theme` value is ignored (skipped) by pre-change code once D10's guard is in. Implementation order is the tasks order: PaperLight removal → property APIs → evaluation → tokens/wiring → schema split → engine → scifi → setting/UI. Each step keeps `make compile-dev` green; behavior verification per area under both themes. ## Open Questions - Exact shared token vocabulary (`accent_color` vs split accent/dim/ accent2, how many `edit.*`/`menu.*`/`chatview.*` tokens): grown by trial and error during the split; does not affect the mechanism. The pressure is lower than it first looks — only *schema-referenced* tokens must exist up front; themes mint anything else privately (D6).