Skip to content

Harden persistence and improve runtime UI reliability - #323

Merged
devmobasa merged 48 commits into
mainfrom
fix/improvements
Aug 1, 2026
Merged

Harden persistence and improve runtime UI reliability#323
devmobasa merged 48 commits into
mainfrom
fix/improvements

Conversation

@devmobasa

Copy link
Copy Markdown
Owner

Summary

This branch hardens session and configuration persistence, fixes several user-visible correctness issues, removes blocking work from the Wayland event loop, and consolidates duplicated toolbar/modal behavior.

It also adds regression coverage and CI guards for the failure modes addressed here.

Highlights

Session and configuration safety

  • Preserve newer-version sessions in content-addressed side files before downgrade saves can rotate them away.
  • Fail closed if preservation cannot be completed safely.
  • Sync parent directories after session renames and remove the backup-destroying rotation window.
  • Restrict session files, backups, recovery artifacts, markers, and preserved copies to 0600, including artifacts created by older releases.
  • Clean up failed-save temporary files.
  • Notify users when corrupt session data was preserved elsewhere.
  • Salvage valid configuration sections when another section contains an invalid value.
  • Prevent destructive commands and update checks from acting on sections that fell back to defaults.
  • Report partial and total configuration fallback through visible notifications.
  • Refuse configurator saves while a reload is in flight.
  • Diagnose shortcuts that name keys the input layer cannot deliver.

Capture, daemon, and backend correctness

  • Preserve file-save failures when clipboard delivery succeeds, including the existing save-to-file fallback action.
  • Move PNG/PDF rendering and clipboard fingerprint probing off the event-loop dispatch thread.
  • Trim daemon quarantine directories before they reach capacity, preventing permanent crash/restart loops.
  • Wait for overlay termination through pidfds instead of periodic sleep polling.
  • Fix negative-extent rectangle selection.
  • Fix capped toolbar grids dropping buttons from hit testing.
  • Report repeated toolbar rendering failures instead of silently losing them.

Runtime UI and toolbar behavior

  • Centralize modal-surface exclusion rules and tour lifecycle handling.
  • Derive toolbar popover dismissal from one event-ownership table.
  • Persist status-bar interactivity and item visibility through runtime UI state.
  • Ensure managed runtime keys replace stale passthrough copies.
  • Consolidate toolbar hit-to-event mapping and chrome geometry calculations.
  • Cache command-palette rankings and the default keymap.
  • Keep the legacy popup palette internally coherent instead of applying a light outer surface while leaving unreadable light foreground tokens.

Export, packaging, and maintenance

  • Shape PDF page labels with Pango for fallback fonts, bidirectional text, and accurate ellipsizing.
  • Generate the packaged systemd unit from the same renderer used at runtime.
  • Run the process-site and config-writer guards on pull requests.
  • Pin config.example.toml values to compiled defaults.
  • Expand toolbar icon painter coverage.
  • Make environment-mutating tests restore state even when their body panics.
  • Remove dead code and narrow broad dead_code suppressions.
  • Add a load-proof deadline to the capture-sized process-broker test.

devmobasa added 30 commits July 31, 2026 15:07
…ions

A session file written by a newer wayscriber loaded as an empty session,
and with the default backup_retention of 1 the first save rotated it to
the backup slot and the second save replaced that backup - two autosaves
after a downgrade and the newer-version drawings were gone for good.

The runtime load now preserves a verbatim copy of the too-new file under
a versioned side name (session.json.vN-preserved) before returning the
empty session, so no later rotation can reach it. The first preserved
copy wins, and the read-only named-candidate open keeps its no-sidecars
contract by never preserving.

Also close two crash windows in the save path itself:
- every rename that commits a session artifact (primary, recovery,
  markers, Save As) now syncs the parent directory, matching what
  durable_io already does everywhere else, so a reported-successful
  save can no longer roll back after a power loss
- the rotation no longer removes the old backup before renaming over
  it; rename replaces atomically, and the removal only created a
  window with no backup at all
A serde mapping error anywhere in config.toml made the overlay fall back
to full defaults with nothing but a log line - theme = "drak" cost the
session every customization in the file, the total-loss variant of the
failure mode the keybinding work (#293) was built to eliminate.

Loading now salvages per top-level entry: each section of Config is
serde(default), so every entry is re-checked on its own, entries that
map cleanly stay in effect, and only the failing section runs on
defaults for the session. The file is never touched. A syntax error
still fails the load, since there is no parsed document to salvage.

Both outcomes are now reported instead of only logged: the overlay
raises a warning toast (with a Settings chip) plus a desktop
notification naming the unreadable sections, following the existing
keybinding-problem reporting. When the salvaged section is
[keybindings], source presence no longer marks the shipped defaults as
authored.
…succeeds

In ClipboardAndFile mode a save failure was stashed and only surfaced if
the clipboard copy also failed. When the copy succeeded, the error was
dropped after a log line and the user got a "Screenshot Captured"
notification while the file they asked for was never written.

CaptureResult now carries the save failure alongside the partial
success. The event loop reports it as a warning ("Copied to clipboard,
but the file was not saved"), and keeps the overlay open with the same
save-to-file fallback action the clipboard-failure path already offers,
so the bytes in hand can still land on disk.
…s cap

Malformed queue, control, and action entries were renamed into
quarantine/ and never deleted. At 1024 entries the capacity checks
turned fail-stop: ActionJournal::open and quarantine_entry bailed,
Daemon::run failed, and under the shipped Restart=on-failure unit the
daemon sat in a permanent 5-second crash-restart loop until the runtime
directory was wiped by hand - and on systems without XDG_RUNTIME_DIR
the ledger lives on persistent storage, so not even a reboot cleared
it.

Opening a ledger now trims each quarantine directory to a 64-entry
inspection tail (newest by ctime, which the rename into quarantine
stamps), and the mid-run capacity checks collect instead of killing a
running daemon. Removal failures are logged and skipped so a collection
hiccup cannot become another way to wedge; the fail-stop capacity
checks stay in place behind the collection as a backstop.
…nt loop

Three paths violated the backend's own no-blocking invariant by running
heavy work on the thread that dispatches input and paints:

- canvas PNG export re-rendered the full canvas at physical resolution
  and PNG-encoded it inline, freezing the UI for the whole export
- board PDF export rendered every page of every board plus PDF encoding
  inline; multi-board sessions stalled for seconds
- the paste fingerprint probe spawned wl-paste twice with 300 ms
  timeouts each, inline, inside event dispatch

Exports now travel as a deferred render job that the capture worker
executes on a blocking task before feeding the existing delivery
pipeline, so completion, error formatting, and notifications all flow
through the paths that already handle async screenshots. The snapshots
were already ownership-clean; they simply move to the worker.

The fingerprint probe reuses the clipboard paste controller: the worker
carries the probe result back as a completion, and the paste decision
continues on the main thread where the local fallback shapes live, with
a stale-request guard for the window the inline call never had.
AGENTS.md declares tools/lint-and-test.sh the full local CI, but ci.yml
had drifted from it: check-config-writers.py - the guard behind the
"only explicit edits write config.toml" invariant - and
check-process-sites.py never ran on pull requests, so the invariants
they enforce were only checked on machines that remembered to run the
script.
side_row_button_rects passed grid_layout its columns and total_items
arguments swapped. The two call sites cap text columns at the button
count, so the values are currently always equal and the swap was
invisible - but the moment a row grows past the cap (a sixth boards
button; the popover variant already has six), the grid would produce
fewer rects than buttons, and since callers zip rects with buttons the
trailing button - Delete, as it happens - would silently vanish from
paint and hit testing in text mode.
The example claims every value in it is a configured default, but the
guard tests only checked key presence, never values - and three drifts
had already accumulated: the example and docs shipped clear_preset_1..5
as empty while the code binds Ctrl+1..5, head_at_end as false while the
code defaults true, and the docs' arrow "Defaults" list still said 30.0
degrees for an angle the code sharpened to 24.0.

The new test parses the example, serializes it next to
Config::default(), and diffs the trees path by path. Legitimate
differences are allowlisted with their reasons: env-dependent page
navigation, and sections that spell out effective built-in defaults
for fields whose compiled default is "unset" (boards, quick colors,
the sample preset slot, toolbar order).
Review-verified deletions, each confirmed unreferenced by grep and by a
clean warning-free build of both feature sets:

- assets/icons/*.svg: leftovers from the embedded-SVG era, referenced
  nowhere since the switch to procedural painters
- five toolbar_icons wrappers whose "referenced by the toolbar-gtk
  frontend only" comments were false - the GTK frontend uses
  draw_icon_pin/draw_icon_unpin directly
- the capture status channel: an Arc<Mutex<CaptureStatus>> plus
  get_status() with zero production consumers - the UI reads
  CapturePoll exclusively, and every test assertion on it duplicated
  an outcome assertion the same test already made
- is_clipboard_available ("Phase 2" scaffolding with no callers),
  resolve_damage_regions (only its own tests called it),
  invalidate_help_overlay_cache (dead, and redundant - the cache key
  already includes the style), invalidate_text_cache (exported, never
  called)

Stale annotations corrected so the compiler can resume catching real
rot: pointer-constraints fields were annotated "kept for potential
future pointer lock support" while pointer lock uses them today;
manager_available, log_dir, save_snapshot, load_snapshot, and
AUTOSAVE_HISTORY_FALLBACK_DEPTH carried allows despite live callers;
pointer press named a parameter _conn and used it four times.
drag_log and color_log took an already-built string, so the ~10
format! calls per pointer-motion event in the toolbar drag path
allocated on every move with logging disabled. Both now take a closure
and only build the message when their debug env toggle is on.

The command palette rebuilt KeybindingsConfig::default() - nine
sub-configs allocating a Vec<String> per action - on every
rebindability check, including the row-action tooltip lookup the
renderer runs up to twice per frame while the pointer hovers the strip.
The compiled-in default keymap is immutable data; it is now built once
behind a OnceLock (config::keybindings::default_keybindings).
…proof deadline

The 16 MiB publication test carried a 5-second timeout, which the
full parallel suite can exceed when it saturates the machine - the
broker then SIGKILLs the helper and the test fails with status 137.
The test proves byte-count integrity, not latency, so the deadline is
now deliberately generous.
Eight popup surfaces each hand-wrote the list of other surfaces their
opener closes, and the lists disagreed: the radial menu closed all of
them, help closed only the radial menu, and the color picker, palette,
board picker, and context menu each excluded a different subset. The
keyboard router's precedence chain masked most of it, but pointer-driven
openers bypass that chain - help opened from a toolbar or status-chip
click landed on top of an open color picker or board picker and starved
it of keys.

A ModalSurface registry now owns the rule: opening a surface closes
every other open surface through its canonical closer, and the two
deliberate exceptions are data on the enum with their reasons - the
tour survives every opener except the palette (it guides the user into
opening surfaces), and a context menu over the board picker stays,
because the picker's page rows have their own context menus.

The registry also becomes the single source for the other enumerations
of the same set: modal_owns_text_input and
modal_blocks_canvas_key_repeat now derive from it instead of listing
surfaces by hand, and engaged_modal() exposes the precedence question
directly. The board picker's two ~35-line verbatim-duplicate openers
fold into one parameterized body along the way.

New tests pin the previously-buggy help-over-popup paths, both
coexistence exceptions, and the pairwise invariant that no opener
leaves an excluded surface behind.
handle_save_requested guarded only is_saving. A Save pressed during an
in-flight Reload wrote the pre-reload draft and was then judged against
a document it never saw, leaving stale fields marked clean and the next
save rejected by the revision guard. The guarded ConfigDocument means no
data was lost, but the UI reported a state the file did not have.
action_label falls back to the literal word "Action" when an action has
no ActionMeta, and every user-facing message naming a shortcut goes
through it - conflict toasts, invalid-binding notifications,
skipped-default notices, the migration banner. Twelve configurable
selection actions had no entry, so a user who bound one of them into a
collision was told their shortcut conflicted with "Action".

Adds entries for the twelve (z-order front/back, the six nudges, and
the four edge moves) with all surface flags false, so they gain a name
without joining the command palette, help, or toolbar contracts. A new
test pins the invariant: no configurable action may be shown as
"Action".

Investigating the reported "Clear Canvas"/"Clear canvas" drift showed
it is not drift - the overlay writes shortcut names in Title Case and
the configurator in sentence case, deliberately and consistently, and
99 of 148 differ by more than case because the settings list needs
names that stand alone ("Select pen tool") where the palette does not
("Pen Tool"). No cross-crate label test is added, because the two
vocabularies are meant to differ.
render_rect normalizes negative width/height before painting, and
rect_fill_hit normalizes the same way, but rect_outline_hit treated any
non-positive extent as a bare point at the origin. A rectangle carrying
negative extents therefore drew its outline normally and could not be
clicked anywhere along it.

Outline hit testing now normalizes to the painted rectangle. Only a
rectangle with no extent on either axis is still treated as a point; one
collapsed axis paints as a line, which the edge tests already handle.
Every failure inside ToolbarSurface::render - shm pool creation, buffer
creation, wrapping the buffer in a cairo surface, creating the context,
attaching the buffer - returned Ok(()) after discarding the error. The
surface stayed dirty, so it retried on every frame and failed the same
way, and the user saw a blank or stale toolbar with nothing in the log
to explain it.

Each path now returns the real error with context. Because the retry is
per frame, an unconditional warning would repeat at frame rate, so the
caller routes failures through report_render_failure: the first failure
of a streak warns, every 300th warns again so a persistent problem stays
visible, and the rest go to debug. A successful render clears the streak.
An unreadable session was backed up and logged, then reported as
LoadSnapshotOutcome::Empty - indistinguishable from having no session at
all. The overlay restored an empty canvas and said nothing, so the
user's drawings appeared to have vanished and only the log knew their
bytes had been preserved.

backup_corrupt_session now reports where it put them, and the load
returns EmptyAfterCorruption carrying that path. A dedicated variant
rather than a field on Empty so the compiler names every site that has
to decide: the fallback and candidate paths treat it as empty, and the
two places a user learns anything say what happened - the overlay raises
a 20-second error toast naming the backup, and opening a named session
fails with the reason and the path instead of "contains no usable
session data".
Page navigation and the xdg focus-loss behaviour pick their defaults from
XDG_CURRENT_DESKTOP and friends at call time, so two tests that compare
defaults against checked-in values were really testing where they ran.
The keybinding snapshot pins the non-GNOME variant, so it failed on any
GNOME or Ubuntu machine; the example-drift guard papered over the same
problem by allowlisting the two page keys.

Both now build their values inside a shared with_scrubbed_desktop_env
helper. The drift guard scrubs both sides, because parsing the example
runs serde's default functions for every key it omits - which is how the
focus-loss default leaked in even after the Config::default() side was
scrubbed - and the page-key allowlist entries are gone. A new test pins
the GNOME branch that the snapshot cannot record.

Verified by running the suite with XDG_CURRENT_DESKTOP=ubuntu:GNOME.
KeyBinding::parse rejects only an empty string and a modifier-only one,
so a typo like "Ctlr+Z" parsed cleanly - as a key literally named
"Ctlr+Z". Nothing ever produced that name, so the binding conflicted
with nothing, reported nothing, and simply never fired. The user's undo
stopped working with no way to find out why.

Parsing stays permissive, because a future build may deliver names this
one does not, and rejecting would silently change the meaning of a file
the load never writes. Instead the key name is checked against the
vocabulary the input layer actually produces - any single character, or
one of NAMED_KEYS - and an unrecognized one is reported through the
existing InvalidKeybinding channel with its own wording and the binding
left in place. InvalidKeybinding now carries a KeybindingProblem saying
whether the string was dropped or merely doubted.

Near misses get a suggestion: a misspelled modifier (which the parser
folds into the key, since it does not recognize it) and a one-edit typo
on a named key both propose the intended spelling. A test pins
NAMED_KEYS against the input layer's own key-to-name mapping, so a key
the backend learns to deliver fails until the vocabulary records it.
…amily

The non-empty render test enumerated the 34 render_* entry points in
svg.rs, which is the newer icon family. The ~30 older proportional-style
painters - zoom, session, security, history, and the chrome glyphs - had
no coverage at all, so one of them silently painting nothing at 18px
would have shipped.

Adds a test over all 64 public draw_icon_* painters at the five sizes
the chrome asks for, which is the surface callers actually use.
A build that does not recognize a key in runtime-ui.toml preserves it
verbatim so a newer build still finds it. If a later build learns to
manage that key, the preserved copy and the managed value collide while
encoding, and the encoder treated that as fatal: the whole file became
unencodable. Because the conflict lives in the in-memory wire state
rather than on disk, the resulting persistence incident recurred on
every retry, so no toolbar state could be saved again for the life of
the install.

The managed value is authoritative by construction, so it now displaces
the preserved copy and logs that it did. Encoding a file is no longer a
fallible operation on this axis.
The six session write sites hand-rolled the temp-write/fsync/rename
dance and created their temporaries with no mode, so under a default
umask the user's drawings, their backup, the recovery artifact, and every
marker landed at 0644 - world-readable, while the runtime-UI store and
durable_io have always used 0600 for far less sensitive data.

The main save also had no cleanup: it creates its temporary before
rotating the previous session into the backup slot, so every early
return between those points left the file behind, temp_path stepped to
.tmp1, .tmp2, and nothing ever collected the strays.

The three markers and the recovery artifact are pure write-then-rename,
so they now go through durable_io::write_atomic, which owns the whole
sequence including 0600, the parent sync, and removing its temporary on
every failure path. The main save and Save As keep their own ordering -
writing the payload before rotating is what leaves the original intact
when the write fails - and gain the mode plus a Drop guard that removes
the temporary unless a rename defused it.

Tests pin both properties, and both were confirmed to fail against the
previous behaviour.
The status bar settings popover - clickable segments plus the eleven
segment toggles - applied to the current run only. config.toml is an
authored input that the overlay never writes, so flipping a toggle
updated the effective config in memory and the next launch read the
configured value back: every change the user made came back on.

The toolbars solve this already. Pins, minimized state, the side pane,
collapsed sections, item visibility and order, board pins, positions,
and the top display mode all persist through runtime-ui.toml as runtime
overrides layered over their configured seed, which keeps config.toml
authored-only while still surviving a restart. Status-bar content is the
same kind of state - chrome the user arranges from the overlay - so it
now persists the same way.

Adds two seed targets, StatusBarInteractive and StatusBarItem(item),
through the whole path: the wire format (a status_bar_interactive scalar
and a status_bar_items map, both readable as unknown keys by older
builds, so no version bump), the seed registry, the event policy, the
mutation values, and the startup restore. StatusBarItem gains a stable
config_id, which is a compatibility surface: renaming one drops the
user's saved choice for that segment.

Because their configured values are now seeds rather than plain
preferences, the four affected fields move out of the seed-neutral
guard list, and the events move out of the authored-only list into a
test that pins their new persistence. An end-to-end test toggles both
kinds, restarts, and checks they held - and that config.toml is
byte-identical throughout.
…rame

filtered_commands walks the whole command registry and scores each entry,
allocating a joined shortcut string and lowercasing per fuzzy comparison.
It ran on every keystroke and every 55ms repeat tick, and - because the
renderer asks for the row list twice per frame and each ask re-filters -
also two to three times per rendered frame while the palette is open, for
thousands of transient allocations a frame.

The ranking now caches against everything score_command reads: the query,
the recents that bias it, and a new keymap revision standing in for the
shortcut labels it folds in. The revision is bumped by the two places
that replace the keymap, so a rebind re-ranks rather than serving a stale
list. A test exercises all three invalidation paths.
intent_for_hit and drag_intent_for_hit matched the same fifteen HitKinds
and repeated every slider target and spec between them - about ninety
duplicated lines whose only real differences were the two kinds that are
genuinely phase-sensitive: a click has no drag meaning, and a toolbar
item drag opens a gesture on press and reports movement within it after.
Every new hit kind or retuned slider had to be written twice, in step.

One event_for_hit now takes the phase and both entry points call it.
start_drag likewise follows the enum - every kind but Click opens a drag
- instead of a third hand-maintained list that had to be extended
alongside the other two.

Net 48 lines smaller. The existing equivalence test
(slider_press_and_drag_use_same_pointer_mapping) covers the shared path;
a new test pins the two phase-sensitive kinds so the shared mapper
cannot quietly start treating one like the other.
…copies

packaging/wayscriber.service and render_user_service_unit described the
same unit and had already drifted: the packaged copy set
PATH=/usr/local/bin:/usr/bin:/bin while the renderer prepends the
directory the binary was installed to, so a packaged daemon and a
user-installed one resolved helper tools differently.

The packaged file is now the renderer's output for a /usr/bin install,
pinned by a test that prints the expected contents when they diverge, so
the copies cannot drift again. Since the renderer is now the single
source, it also stops emitting the install directory twice when it is
already one of the system directories - which is what a /usr/bin install
produced before, and why the two files could not simply be compared.
devmobasa added 18 commits July 31, 2026 19:26
The width planner and the tree builder each carried their own copy of the
same compact/regular triple for chrome button size, gap, and right
margin, and each recomputed the block width from them. A retuned compact
chrome had to be edited in both or the width the planner reserved and the
width the builder painted would disagree - and the planner is what
decides whether the strip fits at all.

Both now read one ChromeMetrics::for_plan, which also owns the block-width
sum.
[ui] theme = "light" installs the light Theme, but only surfaces that
read theme::current() ever responded - the status pill hairline, the
zoom chip accents, the radial menu. Every modal popup painted the const
dark tokens directly, so with light mode configured a single frame could
hold a light radial menu floating over a dark command palette.

The five panel backgrounds and five borders those popups share now
resolve through the installed theme. Dark is byte-identical to the
tokens it shipped with, because the popups deliberately differ in tint
and this change is about light mode rather than a dark restyle; under a
light theme each takes the theme's popover surface or hairline border
while keeping its own alpha, so their differing translucency survives.

Resolution takes the theme as an argument, with the accessors passing
current(), because that is a process-wide OnceLock and a test cannot
install one variant and then the other. Both variants are pinned:
dark against the exact constants, light against the theme plus
per-token alpha.
PDF page labels selected a font face and drew through Cairo's "toy" text
API, which does no shaping and no font fallback. A board named in CJK,
Arabic, Hebrew, or any script the configured family lacks was measured
as nothing and painted as boxes or reordered glyphs - in the exported
file, where the user cannot correct it.

Labels now go through the same shaped-text layer the rest of the
overlay uses, so they get fallback fonts and correct reordering. The
local ellipsizer - the fourth in the codebase - measures through that
layer too rather than the toy API, so a label is trimmed by the width
that will actually be drawn instead of one the exported glyphs never had.
Five hand-maintained exclusion lists decided when a popover closes, one
per popover, each spelling out the events that must not dismiss it - the
Settings list alone named forty-one of the event enum's variants. Adding
a control meant remembering which list it belonged in, and forgetting
closed the popover out from under the pointer the first time the control
was used.

Each event now declares the popovers it operates inside, once, and every
dismissal rule derives from that. A slice rather than one owner because
some controls genuinely belong to two: the shapes picker is anchored
beside the overflow flyout so their toggles spare each other, the three
overflow-anchored menus spare each other's toggles and the shared
scrollbar, and Open Configurator is reachable from both Session and
Settings. Getting that wrong is what the first attempt at this did, and
the existing dismissal tests caught it.

The neighbouring `unreachable!` is downgraded too: an item drag start
whose policy names no order group is metadata drift between two tables,
not an impossible state, so it now refuses the drag and logs instead of
taking the overlay down. A test pins the pairing it was asserting.
A single #[allow(dead_code)] on the model module covered all 13.7k lines
of it, so nothing inside could ever be reported - the module is adopted
incrementally by both frontends, and the allow that covered the staged
shapes covered the finished ones too.

Scoped to the four files that actually hold staged shapes (2.3k lines),
leaving the other ~11.5k - the specs, snapshot mapping, and settings
panes that are fully live - policed again.

Lifting it found three functions with no callers anywhere, now deleted,
and one method read only by the GTK settings pane, which had been dead
in a no-default-features build without anyone being able to see it; it
now carries a feature-scoped allow saying who reads it.
… checks

Stopping the overlay signalled the child and then alternated try_wait
with a 50ms sleep for up to two seconds. The daemon runs a single
thread, so every one of those sleeps was time SIGTERM, SIGINT, and tray
events sat queued and unserviced - and shutdown_after_run takes the same
path, so it was latency on quit too.

A pidfd becomes readable exactly when its process exits, so the wait is
now a poll on the child's pidfd for the whole remaining budget: an
overlay that exits promptly is observed immediately rather than up to
50ms late, and a slow one no longer costs forty wakeups. The sleep
remains as the fallback for when no pidfd could be opened, which happens
when the child races us to exit.

This is the termination half of the finding. Readiness still spins at
10ms for up to five seconds in wait_until_ready; moving that to inotify
means reworking a two-file identity handshake and is left for its own
change rather than half-applied here.
Twelve overlay toggles applied to the current run only: the toolbar's
icon mode, more-colors, context-aware UI, preset toasts, tool preview,
and delay sliders; the status bar master switch, the board and page
badges, and the always-on floating badge; the history pane's custom-step
section; and the input HUD. Each reset to the configured value on the
next launch, so arranging the overlay had to be redone every time.

They now persist the way the toolbars and status-bar segments already
do: a runtime override in runtime-ui.toml layered over the configured
seed, leaving config.toml an authored input the overlay never writes.
Each is a scalar in the existing V1 format, which older builds carry
through as an unknown key, so no version bump.

Drawing state is deliberately left alone - a thickness change is still
this run's - and the classification test now says so rather than
listing these among the ephemeral ones.
Hiding a section from the toolbar was a this-run-only preference: it
moved the effective config the running overlay reads, never disk, so the
next start brought the section back.

Section visibility now persists as its own runtime-UI target rather than
as an `ItemVisibility` item override, because a section row carries a
whole section's visibility and has a layout-mode baseline behind it that
an individual item does not.

Persisting it means it must stop writing the effective config. Seeds are
derived from that config, so a toggle that moved both would hand
reconciliation a seed already equal to its own override and the override
would be pruned as redundant at the next unrelated seed refresh -- a
session load, an output change. The authored config stays the baseline
the override is measured against.

A restore that already agrees with the configured layout records
nothing, so a section the user never touched keeps following the layout
mode instead of being pinned by its own restore.
Section visibility persisted as the boolean it currently resolves to,
which folds in the layout mode. That is fine only while the layout mode
itself cannot move: a restored `true` means "shown under whichever mode
this run ends up in", so a run that restores a different mode than the
one the value was recorded under would pin every section to the old
mode's baseline.

Store the explicit visibility setting instead -- the same `Default` /
`Shown` / `Hidden` an individual item override already persists. It says
what the user chose rather than what the layout happened to show, so
`Default` restores a section to following the mode instead of freezing
it, and no comparison against the live baseline is needed to avoid
pinning one.
Cycling the layout preset from the strip lasted only for the run: it
moved the effective config, never disk, and the next start came back on
the authored preset.

The preset now persists as a runtime-UI override and restores ahead of
the section settings, so a section the user pinned keeps its own
visibility while every untouched section follows the restored preset.
The restore deliberately does not re-run the preset's section defaults;
the section settings apply immediately afterwards and would be
overwritten by the baseline.

Dropping the authored write takes the legacy mirror re-baseline with it.
It existed so the config-to-items fold would not read the old preset's
flags back as pinned sections on the next seed rebuild, which only
mattered because the switch wrote the config those seeds derive from.
The config no longer moves, so the fold has nothing to re-baseline, and
with the layout preference gone no preference disturbs the seeds at all.
The click highlight and the ring the highlight tool keeps on screen were
this-run-only preferences, so both came back off after a restart.

They persist together as one target because they move together: picking
the highlight tool switches the click highlight on as a side effect, and
that is the same choice the explicit toggles make.

The keyboard and the command palette reach `InputState` before the
backend sees the change, so their rollback cannot be read back off the
live state. They now hand in the pre-change snapshot they already took
to detect the change at all, through the same
`begin_toolbar_mutation_with_rollback` seam the pin toggles use, and land
in the same runtime-UI target as the toolbar path.

Presenter mode forces the click highlight on while it runs, so what
persists is the value presenter will restore -- the user's own, not the
mode's. The ring is never forced, so it persists as it stands.
The status bar, the floating board/page badge, the zoom chip and the
input HUD have no toolbar control -- the keyboard and the command
palette are the only way to reach them -- and none of them survived a
restart.

They now persist through the same runtime-UI targets the toolbar
controls use. Keyboard changes land inside `InputState` before the
backend sees them, so each hands in the pre-change value it already
captured as the mutation's rollback, the way the pin toggles do.

The snapshot that decides whether a change was the user's own now also
watches focus mode. Focus hides the status bar on the way in and puts it
back on the way out, and neither is the user choosing to live without a
status bar; without the guard, entering focus mode would persist its own
housekeeping as a preference.

Drops the seed-neutrality guard along with them. It asserted that these
three fields move no runtime seed, which was true only while they had no
seed to move.
"Applies to this run — edit defaults in the configurator (F11)." was
true when every overlay preference toggle was a current-run change. It
is not true of any of them any more, so it was telling users their
choice would be lost when it now survives the restart.

With the last of these toggles persisted, the whole authored-preference
layer has nothing left to do: no event names an effective-config field,
so `preferences.rs` goes with the notice.

The layer was not merely redundant. Seeds derive from the effective
config, so a toggle that wrote it moved the very baseline its own
durable override is measured against; the next unrelated seed refresh
would find the override equal to its seed and prune it as redundant,
silently undoing the persistence. The test that pinned this down now
asserts the invariant directly: no overlay control writes `config.toml`,
and none writes the effective config either.

The presenter-mode guards move to the values they protect --
`user_click_highlight_enabled` and `user_tool_preview` -- so what
persists is still the value presenter will restore rather than the one
it is holding.

Documents what `runtime-ui.toml` now carries and corrects the seven
"Nothing — this run only" rows in the persistence table.
The `ToolbarPersistence` doc still said an overlay preference toggle is
`Ephemeral` and comes back from `config.toml` on the next start, and the
section-row branch still carried the `Ephemeral` fallback its own
routing above had made unreachable.

Says what the two variants mean now, and why the authored value must
stay put for the override layered over it to survive.
@devmobasa
devmobasa merged commit 07cd731 into main Aug 1, 2026
2 checks passed
@devmobasa
devmobasa deleted the fix/improvements branch August 1, 2026 07:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant