Skip to content

Commit a212915

Browse files
Merge pull request #19 from Spacecraft-Software/ui-reduced-motion
feat(vault-config): ui.reduced_motion key (reserved groundwork)
2 parents af15447 + 5888b27 commit a212915

5 files changed

Lines changed: 83 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ range may break in any release.
1010

1111
### Added
1212

13+
- **`ui.reduced_motion` config key (reserved).** Completes the PRD §7.1 config
14+
registry with the accessibility preference to suppress animated TUI elements.
15+
The TUI has no animations yet (it's fully event-driven — no spinner, no
16+
lock-countdown, no blink), so the key is **inert groundwork**: `vault-config`
17+
records/validates it and `vault-tui` populates an `App.reduced_motion` flag
18+
from it, ready for a future spinner / lock-countdown to honor without
19+
re-plumbing. It's a TUI-rendering preference, read by the TUI directly (not
20+
relayed to the agent). Tested in `vault-config` (round-trip, rejects
21+
non-booleans, never emitted as an agent flag).
22+
1323
- **TUI About overlay.** `vault-tui` gains a read-only About screen (open with
1424
`?` or `:about`, dismiss with `Esc`/`q`) showing the version, maintainer,
1525
copyright/license, and canonical URL — rendered from the same

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -148,11 +148,14 @@ vault config unset clipboard.clear_secs
148148
Recognised keys: `clipboard.clear_secs` (auto-clear window, `0` disables),
149149
`clipboard.backend` (`auto`/`arboard`/`osc52`; see below),
150150
`agent.idle_lock_secs` (idle-lock timeout, `0` disables),
151-
`agent.session_keyring` (resume across restarts; see above), and
152-
`sync.interval_secs` (background `/sync` interval while unlocked, `0` disables).
153-
When the CLI auto-starts the agent, these populate its launch flags (changes
154-
apply on the next agent spawn). Wipe the on-disk item cache (and drop a running
155-
agent's keys) with `vault purge`.
151+
`agent.session_keyring` (resume across restarts; see above),
152+
`sync.interval_secs` (background `/sync` interval while unlocked, `0` disables),
153+
and `ui.reduced_motion` (suppress animated TUI elements — **reserved**: the TUI
154+
has no animations yet, so this records the preference for when a spinner /
155+
lock-countdown lands). When the CLI auto-starts the agent, the agent-side keys
156+
populate its launch flags (changes apply on the next agent spawn);
157+
`ui.reduced_motion` is read by `vault-tui` directly. Wipe the on-disk item cache
158+
(and drop a running agent's keys) with `vault purge`.
156159

157160
With `sync.interval_secs` set, the agent re-pulls `/sync` on that cadence while
158161
unlocked — keeping the in-memory vault and offline cache fresh without a manual

crates/vault-config/src/lib.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ pub const KNOWN_KEYS: &[&str] = &[
2828
"agent.idle_lock_secs",
2929
"agent.session_keyring",
3030
"sync.interval_secs",
31+
"ui.reduced_motion",
3132
];
3233

3334
/// Accepted values for `clipboard.backend`.
@@ -45,6 +46,8 @@ pub struct Config {
4546
pub agent: AgentCfg,
4647
/// Background-sync settings.
4748
pub sync: SyncCfg,
49+
/// TUI rendering preferences.
50+
pub ui: UiCfg,
4851
/// Registered account profile (written by `vault register`). Skipped from
4952
/// the file until something is set, so an unregistered config carries no
5053
/// empty `[account]` table.
@@ -86,6 +89,17 @@ pub struct SyncCfg {
8689
pub interval_secs: Option<u64>,
8790
}
8891

92+
/// `[ui]` table — TUI-only rendering preferences (the agent never renders, so
93+
/// these are read by `vault-tui` directly, not relayed via `agent_args`).
94+
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
95+
#[serde(default, deny_unknown_fields)]
96+
pub struct UiCfg {
97+
/// Suppress animated TUI elements (spinner / lock-countdown) for
98+
/// accessibility. Reserved: the TUI has no animations yet, so this records
99+
/// the preference for when they land.
100+
pub reduced_motion: Option<bool>,
101+
}
102+
89103
/// `[account]` table — the registered account, written by `vault register`
90104
/// and read by `login`/`unlock` to default `server`/`email`/`device_id`.
91105
#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)]
@@ -142,6 +156,12 @@ impl Config {
142156
self.sync.interval_secs
143157
}
144158

159+
/// Effective `ui.reduced_motion`, if set.
160+
#[must_use]
161+
pub const fn reduced_motion(&self) -> Option<bool> {
162+
self.ui.reduced_motion
163+
}
164+
145165
/// The registered account profile.
146166
#[must_use]
147167
pub const fn account(&self) -> &AccountCfg {
@@ -171,6 +191,7 @@ impl Config {
171191
"agent.idle_lock_secs" => Ok(self.agent.idle_lock_secs.map(|v| v.to_string())),
172192
"agent.session_keyring" => Ok(self.agent.session_keyring.map(|v| v.to_string())),
173193
"sync.interval_secs" => Ok(self.sync.interval_secs.map(|v| v.to_string())),
194+
"ui.reduced_motion" => Ok(self.ui.reduced_motion.map(|v| v.to_string())),
174195
other => Err(other.to_owned()),
175196
}
176197
}
@@ -203,6 +224,10 @@ impl Config {
203224
self.sync.interval_secs = Some(parse_u64(key, raw)?);
204225
Ok(())
205226
}
227+
"ui.reduced_motion" => {
228+
self.ui.reduced_motion = Some(parse_bool(key, raw)?);
229+
Ok(())
230+
}
206231
other => Err(unknown_key(other)),
207232
}
208233
}
@@ -234,6 +259,10 @@ impl Config {
234259
self.sync.interval_secs = None;
235260
Ok(())
236261
}
262+
"ui.reduced_motion" => {
263+
self.ui.reduced_motion = None;
264+
Ok(())
265+
}
237266
other => Err(unknown_key(other)),
238267
}
239268
}
@@ -524,6 +553,27 @@ mod tests {
524553
assert_eq!(c.clipboard_backend(), None);
525554
}
526555

556+
#[test]
557+
fn reduced_motion_round_trips_and_is_tui_only() {
558+
let mut c = Config::default();
559+
assert_eq!(c.reduced_motion(), None);
560+
c.set("ui.reduced_motion", "true").expect("set true");
561+
assert_eq!(c.reduced_motion(), Some(true));
562+
// It's a TUI-rendering preference — never relayed to the agent.
563+
assert!(
564+
!agent_args(&c).contains(&OsString::from("--reduced-motion")),
565+
"ui.reduced_motion must not become an agent flag"
566+
);
567+
// Survives a toml round-trip.
568+
let text = toml::to_string_pretty(&c).expect("serialise");
569+
let back: Config = toml::from_str(&text).expect("parse");
570+
assert_eq!(back.reduced_motion(), Some(true));
571+
// Non-boolean rejected; unset clears.
572+
assert!(c.set("ui.reduced_motion", "sometimes").is_err());
573+
c.unset("ui.reduced_motion").expect("unset");
574+
assert_eq!(c.reduced_motion(), None);
575+
}
576+
527577
#[test]
528578
fn sync_interval_round_trips() {
529579
let mut c = Config::default();

crates/vault-tui/src/app.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -747,6 +747,10 @@ pub struct App {
747747
pub toast: Option<String>,
748748
/// Interactive unlock state, `Some` while [`Screen::Unlock`] is shown.
749749
pub unlock: Option<UnlockState>,
750+
/// Suppress animated UI (`ui.reduced_motion`). Reserved: the TUI has no
751+
/// animations yet, so nothing reads this — it's populated from config so a
752+
/// future spinner / lock-countdown can honor it without re-plumbing.
753+
pub reduced_motion: bool,
750754
/// Set when the user asks to quit.
751755
pub should_quit: bool,
752756
}
@@ -775,6 +779,7 @@ impl App {
775779
osc52_clear_at: None,
776780
toast: None,
777781
unlock: None,
782+
reduced_motion: false,
778783
should_quit: false,
779784
}
780785
}
@@ -808,6 +813,7 @@ impl App {
808813
osc52_clear_at: None,
809814
toast: None,
810815
unlock: None,
816+
reduced_motion: false,
811817
should_quit: false,
812818
}
813819
}

crates/vault-tui/src/main.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ async fn run(socket: &Path) -> anyhow::Result<()> {
178178

179179
/// Query the agent and build the initial (or refreshed) [`App`].
180180
async fn load_app(socket: &Path) -> App {
181-
match client::request(socket, &Request::Status).await {
181+
let mut app = match client::request(socket, &Request::Status).await {
182182
Err(e) => App::message("No agent", e.to_string(), None),
183183
Ok(Response::Status(s)) if s.unlocked => {
184184
match client::request(socket, &Request::List).await {
@@ -193,7 +193,14 @@ async fn load_app(socket: &Path) -> App {
193193
Ok(Response::Status(s)) => locked_screen(socket, s).await,
194194
Ok(Response::Error(err)) => App::message("Error", err.to_string(), None),
195195
Ok(other) => App::message("Error", format!("unexpected response: {other:?}"), None),
196-
}
196+
};
197+
// Reserved accessibility preference (`ui.reduced_motion`): record it on the
198+
// App so a future animated element can honor it. No visible effect yet.
199+
app.reduced_motion = vault_config::load()
200+
.ok()
201+
.and_then(|c| c.reduced_motion())
202+
.unwrap_or(false);
203+
app
197204
}
198205

199206
/// Build the locked screen: an interactive unlock prompt when an account is

0 commit comments

Comments
 (0)