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<PropertyValue> 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.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.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.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.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.menu/mod.rs:486-509 channel deletion
does clear_tasks() + unlink() + redraw.trigger()./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.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:
vals, unload = unset.on_link signal needed).Non-Goals:
VectorShape internal colors expression-driven (shape colors
are baked consts; scifi rebuilds themed shapes structurally — revisit
later if a theme needs it).minimal, scifi).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).
New methods on PropertyPtr (src/prop/mod.rs), mutating defaults[i]
with the same type/length checks as the builder variants:
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<S: Into<String>>(&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<S: Into<String>>(&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.
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:
pub fn get_value(&self, i: usize) -> Result<PropertyValue> {
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.
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.
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:
Property (src/prop/mod.rs) gains a depends-changed publisher and
a remover; add_depend notifies:
depends_pub: Publisher<()>, // new field, beside on_modify
pub fn add_depend<S: Into<String>>(&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);
when_change_impl (src/ui/mod.rs) shares the subscription list in
an Arc<Mutex<Vec<_>>>, 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:
th_*) — a duplicate local name would shadow another in the eval
globals.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.
/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:
// 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:
// src/theme/mod.rs (sketch) — ThemeCtx helper
/// Create `/theme/<name>` 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<Property>) -> Result<SceneNodePtr>;
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/<name> 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.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.)theme enum is), lookup-friendly for debugging
(/theme/scifi).Extract the eval_with pattern into a shared free function; add wrappers:
// 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.
src/theme/: trait, compile-time registry, and a per-application ctx:
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<PropertyValue> },
/// 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/<name>` token child. Descendants ride
/// along: unlink drops the subtree.
nodes: SyncMutex<Vec<SceneNodePtr>>,
journal: SyncMutex<Vec<JournalEntry>>,
}
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/<name>` 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<Property>) -> Result<SceneNodePtr>;
/// 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<PropertyValue>) -> 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:
// 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.
One batch for the whole switch; structural changes get an explicit
trigger because unlinking notifies no properties (same as menu/mod.rs):
// 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:
BatchGuardId — no listener
ever sees a half-switched batch.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).
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.
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.
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.
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:
// 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<S: Into<String>>(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:
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).eval_f32_multi reads dependencies on behalf of a
wrap role — dep reads go through the check.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.
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.
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)
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)
make_guard defers to end-of-
batch; handlers mutate then trigger).trigger() in switch — mirroring the menu/mod.rs precedent.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.Setting::new panics on unknown persisted keys] → fixed as part of
D10 (skip-and-log), before the new theme key ships.z_index 0 under
/window/content); convention documented with the engine.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.
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).