josie / alder-tools

final audit: rails removal, config hardening, CLI polish, Makefile

- config.rs: delete dead rail-map feature (Rail/cfg.rails/rail.* keys);
  add /etc/aldermon/aldermon.conf search path; range-validate file
  numerics (new rejects_out_of_range test)
- sensors.rs: sio_input reads {stem}_input directly (label-shadowing
  hazard gone, ~30 sysfs reads/tick -> 1)
- plot.rs: rewrite stale decimation-era docs, drop unused window_ticks
  param, dedupe marker comment, levels() under cfg(test)
- ui.rs: PlotSpec tuple -> named struct; resize_graphs() on poll_ms
  edits too; save_conf failures surface in pane; panic-proof peak_x;
  is_multiple_of (clippy zero)
- main.rs: --help + unknown-flag exit 2; --tui tty guard; fresh docs
- aldermon.conf: rail lines out (probe notes kept as comments)
- README: install section, config docs current; Makefile:
  build/check/install(DESTDIR+PREFIX)/uninstall/clean
- whole crate rustfmt'd; 13 tests, clippy -D warnings clean,
  pty-verified 30x100/40x120/24x60 + settings F10 round-trip

af309d6064a0680500a1462eabca0251e8711099
josie <josie@example.com> · 2026-09-19T23:52 · browse files at this commit

parents: f24503a

diff --git a/aldermon/Cargo.lock b/aldermon/Cargo.lock
index da3716b..16194ad 100644
--- a/aldermon/Cargo.lock
+++ b/aldermon/Cargo.lock
@@ -2,14 +2,6 @@
 # It is not intended for manual editing.
 version = 4
 
-[[package]]
-name = "aldermon"
-version = "0.1.0"
-dependencies = [
- "crossterm",
- "ratatui",
-]
-
 [[package]]
 name = "aho-corasick"
 version = "1.1.5"
@@ -19,6 +11,14 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "aldermon"
+version = "0.1.0"
+dependencies = [
+ "crossterm",
+ "ratatui",
+]
+
 [[package]]
 name = "allocator-api2"
 version = "0.2.21"
diff --git a/aldermon/Makefile b/aldermon/Makefile
new file mode 100644
index 0000000..958e459
--- /dev/null
+++ b/aldermon/Makefile
@@ -0,0 +1,34 @@
+# aldermon — btop-style Alder Lake monitor. Gentoo-friendly: respects
+# PREFIX, DESTDIR. `make check` runs fmt+clippy+test.
+PREFIX  ?= /usr/local
+SYSCONF ?= /etc
+DATADIR ?= /usr/share
+
+BIN      := $(DESTDIR)$(PREFIX)/bin/aldermon
+CONF     := $(DESTDIR)$(SYSCONF)/aldermon/aldermon.conf
+HELPER   := $(DESTDIR)$(DATADIR)/aldermon/powercap-pl-rules.sh
+
+.PHONY: all build check install uninstall clean
+
+all: build
+
+build:
+	cargo build --release
+
+check:
+	cargo fmt --check
+	cargo clippy --all-targets -- -D warnings
+	cargo test
+
+install: build
+	install -Dm755 target/release/aldermon $(BIN)
+	install -Dm644 aldermon.conf $(CONF)
+	install -Dm755 powercap-pl-rules.sh $(HELPER)
+
+uninstall:
+	rm -f $(BIN) $(CONF) $(HELPER)
+	rmdir -p --ignore-fail-on-non-empty $(CONF) 2>/dev/null || true
+	rmdir -p --ignore-fail-on-non-empty $(HELPER) 2>/dev/null || true
+
+clean:
+	cargo clean
diff --git a/aldermon/README.md b/aldermon/README.md
index 49699d0..efa956c 100644
--- a/aldermon/README.md
+++ b/aldermon/README.md
@@ -6,23 +6,27 @@ voltage for overclock-safety work.
 ## Usage
 
 ```sh
-cargo run --release -- --tui    # TUI (default workhorse)
-cargo run -- --vid              # VID spike tool (debug)
-cargo run                       # one-shot sensor dump
+aldermon --tui          # TUI (default workhorse)
+aldermon --vid          # VID spike tool (debug, needs root + msr module)
+aldermon                # one-shot sensor dump
+aldermon --help         # options
 ```
 
-The TUI needs a real terminal (pty); it will not run on redirected output.
+The TUI needs a real terminal; piped/redirected stdout exits with a clear
+error instead of a panic.
 
-Features: vCore hero panel (limit marker, 5-min peak, auto-scale), board
-rails, package power/temp, per-core frequency bars + temps, E/P-core
-sections, footer max frequency.
+Features: vCore hero panel (limit marker, 5-min peak, auto-scale),
+package power/temp, per-core frequency bars + temps, E/P-core sections,
+braille sub-pixel scrolling graphs, footer max frequency.
 
 ## Config
 
 `aldermon.conf` is read from the working directory, then
-`~/.config/aldermon/aldermon.conf`. Simple `KEY = VALUE` lines; `#`
-comments; unknown keys are ignored. All values optional — built-in defaults
-target an ASRock Z690M ITX/ax (nct6798).
+`~/.config/aldermon/aldermon.conf`, then `/etc/aldermon/aldermon.conf`
+(first file found wins; no merging). Simple `KEY = VALUE` lines; `#`
+comments; unknown keys are ignored. All values optional — built-in
+defaults target an ASRock Z690M ITX/ax (nct6798). Numeric keys are
+range-checked; an out-of-range value falls back to the default.
 
 ```ini
 vcore_limit   = 1.403   # safety limit shown as marker on the vCore bar
@@ -39,26 +43,37 @@ power_bar_max = 200.0
 temp_min      = 0
 temp_max      = 100
 
-rail.in0.label = CPU Vcore   # hwmon input label
-rail.in4.label = VCCIO?
-rail.in5.hide  = true        # hide unwired/floating inputs
+poll_ms         = 250      # sample/tick period (50..10000)
+graph_secs      = 300      # history kept, sizes peaks + auto-scale
+graph_scale_fixed = false  # true = pin to the scales above
+graphs          = true
+layout          = auto     # auto | single | dual
 ```
 
-Rail labels for this board were confirmed against the BIOS and differential
-stress-ng probing (2026-08-29); see comments in `aldermon.conf`. The only
-remaining guess is in4 = VCCIO.
+## Install
+
+```sh
+make            # release build
+make check      # fmt + clippy + tests
+doas make install   # PREFIX=/usr/local by default
+```
+
+Installs `aldermon` to `$(PREFIX)/bin`, this repo's `aldermon.conf` to
+`/etc/aldermon/aldermon.conf` (system default — override per-user via
+`~/.config/aldermon/`), and the udev helper to `/usr/share/aldermon/`.
+`make uninstall` reverses it (leaves the system conf dir if non-empty).
 
 ## Package power (RAPL) — one-time setup
 
 `energy_uj` is root-only on this kernel. To read watts as a normal user,
-install the udev rule once as root:
+install the udev rule once as root (comes from `make install`):
 
 ```sh
-doas ./powercap-pl-rules.sh
+doas /usr/share/aldermon/powercap-pl-rules.sh
 doas usermod -aG adm $USER   # then re-login
 ```
 
 ## Status
 
-Working: TUI, config, RAPL power, rail map for Z690M ITX/ax. Remaining
+Working: TUI, config (+ system /etc default), RAPL power. Remaining
 notes in `to-do.md`.
\ No newline at end of file
diff --git a/aldermon/aldermon.conf b/aldermon/aldermon.conf
index 3d7d112..fadd682 100644
--- a/aldermon/aldermon.conf
+++ b/aldermon/aldermon.conf
@@ -12,23 +12,10 @@
 #   in3 ~3.33 = +3.3V (BIOS-confirmed 2026-08-29); in8 ~3.18 (3.3 V aux)
 #   BIOS DRAM 1.37 / 1.2 IMC / 1.8 V / 0.82 PCH: no matching inputs at all
 #   → those rails are not wired to the nct6798
-#   in5/6/10/11/13 floating garbage (unwired) — hidden
+#   in5/6/10/11/13 floating garbage (unwired)
+# (Historic rail map — the TUI shows CPU metrics only; in0 stays the
+#  vCore source of truth. Lines kept as documentation.)
 
-rail.in0.label  = CPU Vcore
-rail.in1.label  = Fixed 1.01
-rail.in2.label  = Fixed 3.42
-rail.in3.label  = +3.3V
-rail.in4.label  = VCCIO?
-rail.in5.hide   = true
-rail.in6.hide   = true
-rail.in7.label  = Fixed 3.42
-rail.in8.label  = 3.3V aux
-rail.in9.label  = 1.05V PROC
-rail.in10.hide  = true
-rail.in11.hide  = true
-rail.in12.label = Fixed 1.14
-rail.in13.hide  = true
-rail.in14.label = Fixed 1.5V
 poll_ms = 250
 graph_secs = 300
 graph_scale_fixed = true
diff --git a/aldermon/src/config.rs b/aldermon/src/config.rs
index 30b47b9..d2e78c9 100644
--- a/aldermon/src/config.rs
+++ b/aldermon/src/config.rs
@@ -1,19 +1,8 @@
-//! Static board config: rail labels + display limits. Zero deps: simple
-//! `KEY = VALUE` lines, `#` comments, blank lines ignored. Unknown keys are
-//! ignored (forward compatibility). Loaded once at startup.
-
-/// Which hwmon input of the SIO chip maps to what. `(key, label)` pairs;
-/// the key is the sysfs stem, e.g. "in0" → `in0_input`.
-pub struct Rail {
-    pub input: String,
-    pub label: String,
-    /// Inputs not connected on this board report garbage (floating) values;
-    /// hide them unless the user maps them.
-    pub hide: bool,
-}
+//! Display limits + graph scales. Zero deps: simple `KEY = VALUE` lines,
+//! `#` comments, blank lines ignored. Unknown keys are ignored (forward
+//! compatibility). Loaded once at startup.
 
 pub struct Config {
-    pub rails: Vec<Rail>,
     pub vcore_limit: f64,
     pub temp_warn: f64,
     pub temp_crit: f64,
@@ -55,7 +44,6 @@ pub enum Layout {
 impl Default for Config {
     fn default() -> Self {
         Config {
-            rails: default_rails(),
             vcore_limit: 1.403,
             temp_warn: 80.0,
             temp_crit: 95.0,
@@ -77,44 +65,19 @@ impl Default for Config {
     }
 }
 
-/// Default rail map: ASRock Z690M ITX/ax (nct6798). in0 CONFIRMED Vcore
-/// (shown as the hero meter, not a rail row). v2 view: only CPU-relevant
-/// rails visible by default (in4 VCCIO?, in9 1.05V PROC); the others stay
-/// mapped but hidden — re-enable via `rail.inN.hide = false` in the conf.
-/// Differential stress-ng probing 2026-08-29 + BIOS cross-check; "?" =
-/// evidence-based guess.
-pub fn default_rails() -> Vec<Rail> {
-    vec![
-        rail("in0", "CPU Vcore", false),
-        rail("in1", "Fixed 1.01", true),
-        rail("in2", "Fixed 3.42", true),
-        rail("in3", "+3.3V", true),
-        rail("in4", "VCCIO?", false),
-        rail("in5", "", true),
-        rail("in6", "", true),
-        rail("in7", "Fixed 3.42", true),
-        rail("in8", "3.3V aux", true),
-        rail("in9", "1.05V PROC", false),
-        rail("in10", "", true),
-        rail("in11", "", true),
-        rail("in12", "Fixed 1.14", true),
-        rail("in13", "", true),
-        rail("in14", "Fixed 1.5V", true),
-    ]
-}
-
-fn rail(input: &str, label: &str, hide: bool) -> Rail {
-    Rail {
-        input: input.to_string(),
-        label: label.to_string(),
-        hide,
+/// Parse a numeric value; out-of-range or junk keeps the current value.
+/// Ranges mirror the settings-pane validation so a hand-edited conf can't
+/// produce degenerate scales (empty ranges, zero-width bars).
+fn parse_in(val: &str, cur: f64, range: std::ops::RangeInclusive<f64>) -> f64 {
+    match val.parse::<f64>() {
+        Ok(v) if range.contains(&v) => v,
+        _ => cur,
     }
 }
 
-/// Parse config text over a default Config. Lines:
-///   rail.in12.label = VCCSA
-///   rail.in5.hide = true
+/// Parse one `KEY = VALUE` line over a default Config. Lines:
 ///   vcore_limit = 1.35
+///   poll_ms = 250
 fn apply_line(cfg: &mut Config, line: &str) {
     let line = line.split('#').next().unwrap_or("").trim();
     if line.is_empty() {
@@ -126,17 +89,19 @@ fn apply_line(cfg: &mut Config, line: &str) {
     let key = key.trim();
     let val = val.trim();
     match key {
-        "vcore_limit" => cfg.vcore_limit = val.parse().unwrap_or(cfg.vcore_limit),
-        "temp_warn" => cfg.temp_warn = val.parse().unwrap_or(cfg.temp_warn),
-        "temp_crit" => cfg.temp_crit = val.parse().unwrap_or(cfg.temp_crit),
-        "vcore_bar_max" => cfg.vcore_bar_max = val.parse().unwrap_or(cfg.vcore_bar_max),
-        "power_bar_max" => cfg.power_bar_max = val.parse().unwrap_or(cfg.power_bar_max),
-        "clock_bar_max" => cfg.clock_bar_max = val.parse().unwrap_or(cfg.clock_bar_max),
-        "temp_max" => cfg.temp_max = val.parse().unwrap_or(cfg.temp_max),
-        "vcore_min" => cfg.vcore_min = val.parse().unwrap_or(cfg.vcore_min),
-        "clock_min" => cfg.clock_min = val.parse().unwrap_or(cfg.clock_min),
-        "power_min" => cfg.power_min = val.parse().unwrap_or(cfg.power_min),
-        "temp_min" => cfg.temp_min = val.parse().unwrap_or(cfg.temp_min),
+        "vcore_limit" => cfg.vcore_limit = parse_in(val, cfg.vcore_limit, 0.1..=5.0),
+        "temp_warn" => cfg.temp_warn = parse_in(val, cfg.temp_warn, 0.0..=150.0),
+        "temp_crit" => cfg.temp_crit = parse_in(val, cfg.temp_crit, 0.0..=150.0),
+        "vcore_bar_max" => cfg.vcore_bar_max = parse_in(val, cfg.vcore_bar_max, 0.1..=5.0),
+        "power_bar_max" => cfg.power_bar_max = parse_in(val, cfg.power_bar_max, 1.0..=500.0),
+        "clock_bar_max" => {
+            cfg.clock_bar_max = parse_in(val, cfg.clock_bar_max, 500_000.0..=10_000_000.0)
+        }
+        "temp_max" => cfg.temp_max = parse_in(val, cfg.temp_max, 10.0..=150.0),
+        "vcore_min" => cfg.vcore_min = parse_in(val, cfg.vcore_min, 0.0..=5.0),
+        "clock_min" => cfg.clock_min = parse_in(val, cfg.clock_min, 0.0..=10_000_000.0),
+        "power_min" => cfg.power_min = parse_in(val, cfg.power_min, 0.0..=500.0),
+        "temp_min" => cfg.temp_min = parse_in(val, cfg.temp_min, 0.0..=150.0),
         "graphs" => cfg.graphs = val == "true",
         "poll_ms" => cfg.poll_ms = val.parse().unwrap_or(cfg.poll_ms).max(50),
         "graph_secs" => cfg.graph_secs = val.parse().unwrap_or(cfg.graph_secs).max(5),
@@ -148,41 +113,19 @@ fn apply_line(cfg: &mut Config, line: &str) {
                 _ => Layout::Auto,
             }
         }
-        "rail.hide" => return, // needs input suffix; handled below
         _ => {}
     }
-    if let Some(rest) = key.strip_prefix("rail.") {
-        match rest.split_once('.') {
-            Some((input, "label")) => set_rail(cfg, input, val, None),
-            Some((input, "hide")) => set_rail(cfg, input, "", Some(val == "true")),
-            _ => {}
-        }
-    }
-}
-
-fn set_rail(cfg: &mut Config, input: &str, label: &str, hide: Option<bool>) {
-    if let Some(r) = cfg.rails.iter_mut().find(|r| r.input == input) {
-        if !label.is_empty() {
-            r.label = label.to_string();
-        }
-        if let Some(h) = hide {
-            r.hide = h;
-        }
-    } else {
-        cfg.rails.push(Rail {
-            input: input.to_string(),
-            label: label.to_string(),
-            hide: hide.unwrap_or(false),
-        });
-    }
 }
 
+/// Search order: ./aldermon.conf (dev tree) → ~/.config/aldermon/
+/// (user override) → /etc/aldermon/ (system default, installed).
+/// First readable file wins; no merging.
 pub fn load() -> Config {
     let mut cfg = Config::default();
-    // Search order: ./aldermon.conf then ~/.config/aldermon/aldermon.conf
     let candidates = [
         Some(std::path::PathBuf::from("aldermon.conf")),
         dirs_home().map(|h| h.join(".config/aldermon/aldermon.conf")),
+        Some(std::path::PathBuf::from("/etc/aldermon/aldermon.conf")),
     ];
     for path in candidates.into_iter().flatten() {
         if let Ok(text) = std::fs::read_to_string(&path) {
@@ -234,10 +177,11 @@ pub fn update_conf_text(text: &str, cfg: &Config) -> String {
         let key = line.split('#').next().unwrap_or("").trim();
         let key = key.split_once('=').map(|(k, _)| k.trim().to_string());
         if let Some(k) = key {
-            if wanted
-                .lines()
-                .any(|w| w.split_once('=').map(|(wk, _)| wk.trim() == k).unwrap_or(false))
-                && !seen.contains(&k)
+            if wanted.lines().any(|w| {
+                w.split_once('=')
+                    .map(|(wk, _)| wk.trim() == k)
+                    .unwrap_or(false)
+            }) && !seen.contains(&k)
             {
                 // First occurrence of a managed key: emit the fresh block
                 // and mark every block key seen (the block covers them all).
@@ -276,30 +220,25 @@ mod tests {
     use super::*;
 
     #[test]
-    fn parses_rail_label() {
+    fn tolerates_junk() {
         let mut cfg = Config::default();
-        apply_line(&mut cfg, "rail.in12.label = CPU Vccio  # note");
-        assert_eq!(cfg.rails.iter().find(|r| r.input == "in12").unwrap().label, "CPU Vccio");
+        apply_line(&mut cfg, "not a config line");
+        apply_line(&mut cfg, "unknown_key = 42");
+        apply_line(&mut cfg, "vcore_limit = notanumber");
+        assert_eq!(cfg.vcore_limit, 1.403);
     }
 
     #[test]
-    fn parses_hide_and_limits() {
+    fn rejects_out_of_range() {
         let mut cfg = Config::default();
-        apply_line(&mut cfg, "rail.in5.hide = true");
+        apply_line(&mut cfg, "vcore_limit = 99"); // above the 5 V sanity cap
+        assert_eq!(cfg.vcore_limit, 1.403);
+        apply_line(&mut cfg, "clock_bar_max = 0"); // degenerate scale
+        assert_eq!(cfg.clock_bar_max, 4_500_000.0);
         apply_line(&mut cfg, "vcore_limit = 1.35");
-        assert!(cfg.rails.iter().find(|r| r.input == "in5").unwrap().hide);
         assert_eq!(cfg.vcore_limit, 1.35);
     }
 
-    #[test]
-    fn tolerates_junk() {
-        let mut cfg = Config::default();
-        apply_line(&mut cfg, "not a config line");
-        apply_line(&mut cfg, "unknown_key = 42");
-        apply_line(&mut cfg, "vcore_limit = notanumber");
-        assert_eq!(cfg.vcore_limit, 1.403);
-    }
-
     #[test]
     fn parses_layout_and_graphs() {
         let mut cfg = Config::default();
@@ -311,15 +250,6 @@ mod tests {
         assert_eq!(cfg.layout, Layout::Auto);
     }
 
-    #[test]
-    fn non_cpu_rails_hidden_by_default() {
-        let cfg = Config::default();
-        let hidden: Vec<&str> = cfg.rails.iter().filter(|r| r.hide).map(|r| r.input.as_str()).collect();
-        assert_eq!(hidden, ["in1", "in2", "in3", "in5", "in6", "in7", "in8", "in10", "in11", "in12", "in13", "in14"]);
-        let shown: Vec<&str> = cfg.rails.iter().filter(|r| !r.hide && r.input != "in0").map(|r| r.input.as_str()).collect();
-        assert_eq!(shown, ["in4", "in9"]);
-    }
-
     #[test]
     fn parses_poll_and_graph_window() {
         let mut cfg = Config::default();
@@ -334,17 +264,19 @@ mod tests {
     #[test]
     fn updates_conf_in_place() {
         let text = "# comment\npoll_ms = 250\ngraph_secs = 120\nrail.in0.label = CPU Vcore\ngraphs = true\n";
-        let mut cfg = Config::default();
-        cfg.poll_ms = 500;
-        cfg.graph_secs = 300;
-        cfg.graphs = false;
+        let cfg = Config {
+            poll_ms: 500,
+            graph_secs: 300,
+            graphs: false,
+            ..Default::default()
+        };
         let out = update_conf_text(text, &cfg);
         assert!(out.contains("poll_ms = 500"));
         assert!(out.contains("graph_secs = 300"));
         assert!(out.contains("graphs = false"));
         assert!(out.contains("rail.in0.label = CPU Vcore")); // untouched
         assert!(out.starts_with("# comment")); // untouched
-        // Each managed key appears exactly once
+                                               // Each managed key appears exactly once
         assert_eq!(out.matches("poll_ms").count(), 1);
         assert_eq!(out.matches("graphs").count(), 1);
     }
diff --git a/aldermon/src/main.rs b/aldermon/src/main.rs
index 7a8b439..d8da846 100644
--- a/aldermon/src/main.rs
+++ b/aldermon/src/main.rs
@@ -1,25 +1,49 @@
-//! aldermon — Alder Lake monitor.
+//! aldermon — Alder Lake hardware monitor (i5-12600KF / Z690M ITX/ax).
 //!
-//! Currently a dump tool: prints a one-shot sensor snapshot to stdout. The
-//! TUI consumes the same `sensors` layer once the vCore source spike is done.
+//! Modes: no args = one-shot sensor dump; `--tui` = live TUI (vCore hero,
+//! per-core bars, braille graphs, F2 settings); `--vid` = root-only VID vs
+//! in0 cross-check (debug spike, kept as a tool).
 
 mod config;
 mod plot;
 mod sensors;
 mod ui;
 
+use std::io::IsTerminal;
 use std::thread::sleep;
 use std::time::{Duration, Instant};
 
 use sensors::{cpu_frequencies, discover_chips, rapl_domains, rapl_energy_uj};
 
+const USAGE: &str = "\
+usage: aldermon [OPTION]
+
+  (no option)  one-shot sensor dump to stdout
+  --tui        live monitoring TUI (q/Esc quit, F2 settings)
+  --vid        VID vs SIO in0 cross-check (needs root + msr module)
+  --help       show this help";
+
 fn main() {
     let args: Vec<String> = std::env::args().collect();
+    for a in &args[1..] {
+        if a.starts_with('-') && !matches!(a.as_str(), "--tui" | "--vid" | "--help") {
+            eprintln!("aldermon: unknown option '{a}'\n{USAGE}");
+            std::process::exit(2);
+        }
+    }
+    if args.iter().any(|a| a == "--help") {
+        println!("{USAGE}");
+        return;
+    }
     if args.iter().any(|a| a == "--vid") {
         vid_spike();
         return;
     }
     if args.iter().any(|a| a == "--tui") {
+        if !std::io::stdout().is_terminal() {
+            eprintln!("aldermon: --tui needs a real terminal (stdout is not a tty)");
+            std::process::exit(1);
+        }
         ui::run_tui();
         return;
     }
@@ -77,6 +101,6 @@ fn vid_spike() {
                 None => println!("  cpu{:>2} VID: unreadable", cpu),
             }
         }
-        std::thread::sleep(std::time::Duration::from_millis(400));
+        sleep(Duration::from_millis(400));
     }
 }
diff --git a/aldermon/src/plot.rs b/aldermon/src/plot.rs
index 8933266..4ebe745 100644
--- a/aldermon/src/plot.rs
+++ b/aldermon/src/plot.rs
@@ -5,19 +5,20 @@
 //! resolution a TUI offers. No hysteresis, no EMA: raw samples map
 //! directly to sub-pixel positions so small jitters read.
 //!
-//! Window mapping: `window_ticks` (= graph_secs / poll_ms) samples span
-//! the full sub-col width, right-pinned (newest at the right edge). When
-//! the window holds more samples than sub-cols, each sub-col shows the
-//! MAX of its bucket — spikes survive decimation. When the window is
-//! shorter than the width, each sample stretches across multiple sub-cols
-//! and the left side stays blank until history accrues. Consecutive
+//! Window mapping (btop rule): 1:1 — one sample per sub-col, newest at
+//! the right edge, NO decimation and NO hysteresis, so middle-of-trace
+//! dots never change as the window scrolls (only the right edge wiggles
+//! as new samples arrive). The visible span is therefore `sub_w ×
+//! poll_ms` (sub_w = 2 × inner width); `graph_secs` sizes the ring for
+//! peaks/scale only, it does NOT stretch the view. History shorter than
+//! the width leaves the left side blank until it accrues. Consecutive
 //! sub-cols are gap-filled vertically so steps read as a connected line.
 
 use ratatui::{
-    Frame,
     layout::Rect,
     style::{Color, Style},
     text::{Line, Span},
+    Frame,
 };
 
 /// Ring of samples, oldest first. Capacity is sized for the configured
@@ -74,9 +75,9 @@ impl Ring {
     }
 }
 
-/// Plain rounding map to cell rows (used by tests; the braille renderer
-/// does its own sub-pixel mapping).
-#[allow(dead_code)]
+/// Plain rounding map to cell rows (test helper for the value→row math;
+/// the braille renderer does its own sub-pixel mapping).
+#[cfg(test)]
 fn levels(samples: &[f64], min: f64, max: f64, rows: usize) -> Vec<usize> {
     // Row 0 = top of the plot = max value.
     let span = max - min;
@@ -147,7 +148,6 @@ pub fn render(
     f: &mut Frame,
     area: Rect,
     ring: &Ring,
-    _window_ticks: usize,
     min: f64,
     max: f64,
     color: Color,
@@ -221,7 +221,6 @@ pub fn render(
         prev_row = Some(sr);
     }
 
-    // Marker row: red '┄' on blank cells (first-blank-wins, as before).
     // Marker row: red '┄' on blank cells, in the caller's [0, rows-1]
     // cell-row (no padding — the caller's scale headroom keeps it clear
     // of the borders in auto mode; in fixed mode a limit at the bound
@@ -235,7 +234,10 @@ pub fn render(
             let mut spans: Vec<Span> = Vec::with_capacity(cols);
             for &b in bits[r].iter().take(cols) {
                 if b != 0 {
-                    spans.push(Span::styled(char::from_u32(0x2800 + b as u32).unwrap().to_string(), st));
+                    spans.push(Span::styled(
+                        char::from_u32(0x2800 + b as u32).unwrap().to_string(),
+                        st,
+                    ));
                 } else if marker_cell_row == Some(r) {
                     spans.push(Span::styled("┄".to_string(), marker_st));
                 } else {
@@ -301,8 +303,16 @@ mod tests {
         let out = map_to_subcols(&s, 8);
         assert_eq!(
             out,
-            vec![Some(1.0), Some(2.0), Some(3.0), Some(4.0),
-                 Some(5.0), Some(6.0), Some(7.0), Some(8.0)]
+            vec![
+                Some(1.0),
+                Some(2.0),
+                Some(3.0),
+                Some(4.0),
+                Some(5.0),
+                Some(6.0),
+                Some(7.0),
+                Some(8.0)
+            ]
         );
     }
 
@@ -328,7 +338,14 @@ mod tests {
         let out = map_to_subcols(&s, 6);
         assert_eq!(
             out,
-            vec![Some(5.0), Some(6.0), Some(7.0), Some(8.0), Some(9.0), Some(10.0)]
+            vec![
+                Some(5.0),
+                Some(6.0),
+                Some(7.0),
+                Some(8.0),
+                Some(9.0),
+                Some(10.0)
+            ]
         );
     }
 
@@ -350,8 +367,9 @@ mod tests {
         assert_eq!(braille_dot(4, 0), 0x01);
         // Cross-check: a full column (col 0, all 4 rows) = 0x01|0x02|0x04|
         // 0x40 = 0x47 = "⡇"; btop's braille_up table row 4 col 0 is "⡇".
-        let full_col0 = braille_dot(0, 0) | braille_dot(1, 0) | braille_dot(2, 0) | braille_dot(3, 0);
+        let full_col0 =
+            braille_dot(0, 0) | braille_dot(1, 0) | braille_dot(2, 0) | braille_dot(3, 0);
         assert_eq!(full_col0, 0x47);
         assert_eq!(char::from_u32(0x2800 + full_col0 as u32), Some('⡇'));
     }
-}
\ No newline at end of file
+}
diff --git a/aldermon/src/sensors.rs b/aldermon/src/sensors.rs
index 29ad604..c4f962d 100644
--- a/aldermon/src/sensors.rs
+++ b/aldermon/src/sensors.rs
@@ -211,11 +211,13 @@ pub fn sio_vcore() -> Option<f64> {
     sio_input("in0")
 }
 
-/// Any SIO voltage input by sysfs stem ("in0", "in12", …).
+/// Any SIO voltage input by sysfs stem ("in0", "in12", …), in volts.
+/// Reads exactly `{stem}_input` after resolving the chip — not via
+/// `voltages()`, whose label preference could shadow the stem name if a
+/// future kernel ships `in0_label` (silently breaking the vCore read).
 pub fn sio_input(input: &str) -> Option<f64> {
-    let chip = discover_chips().into_iter().find(|c| c.name.starts_with("nct"))?;
-    voltages(&chip)
+    let chip = discover_chips()
         .into_iter()
-        .find(|(label, _)| label == input)
-        .map(|(_, v)| v)
+        .find(|c| c.name.starts_with("nct"))?;
+    read_int(&chip.path.join(format!("{input}_input"))).map(|mv| mv as f64 / 1000.0)
 }
diff --git a/aldermon/src/ui.rs b/aldermon/src/ui.rs
index 9c4a6eb..54e2603 100644
--- a/aldermon/src/ui.rs
+++ b/aldermon/src/ui.rs
@@ -6,10 +6,10 @@ use std::time::{Duration, Instant};
 
 use crossterm::event::{self, Event, KeyCode};
 use ratatui::{
-    Frame,
     layout::Rect,
     style::{Color, Modifier, Style},
     text::{Line, Span},
+    Frame,
 };
 
 use crate::config;
@@ -91,7 +91,7 @@ fn setting_value(app: &App, row: usize) -> String {
         1 => {
             // Minutes when the window is a whole minute or more.
             let s = app.cfg.graph_secs;
-            if s >= 60 && s % 60 == 0 {
+            if s >= 60 && s.is_multiple_of(60) {
                 format!("{} m", s / 60)
             } else {
                 format!("{} s", s)
@@ -136,11 +136,18 @@ fn parse_window_secs(raw: &str) -> Result<u64, String> {
     }
     if let Some((m, s)) = raw.split_once('m') {
         let s = s.strip_suffix('s').unwrap_or(s);
-        let m: u64 = m.trim().parse().map_err(|_| format!("'{raw}' is not a number"))?;
-        let s: u64 = s.trim().parse().map_err(|_| format!("'{raw}' is not a number"))?;
+        let m: u64 = m
+            .trim()
+            .parse()
+            .map_err(|_| format!("'{raw}' is not a number"))?;
+        let s: u64 = s
+            .trim()
+            .parse()
+            .map_err(|_| format!("'{raw}' is not a number"))?;
         return Ok(m * 60 + s);
     }
-    raw.parse::<u64>().map_err(|_| format!("'{raw}' is not a number"))
+    raw.parse::<u64>()
+        .map_err(|_| format!("'{raw}' is not a number"))
 }
 
 fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
@@ -153,6 +160,8 @@ fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
                 return Err("poll_ms must be 50..10000".into());
             }
             app.cfg.poll_ms = v;
+            // Ring capacity is derived from graph_secs / poll_ms.
+            app.resize_graphs();
         }
         1 => {
             let v = parse_window_secs(raw)?;
@@ -160,10 +169,7 @@ fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
                 return Err("window must be 5s..60m".into());
             }
             app.cfg.graph_secs = v;
-            app.vcore_graph = Ring::new(ring_capacity(&app.cfg));
-            app.watts_graph = Ring::new(ring_capacity(&app.cfg));
-            app.temp_graph = Ring::new(ring_capacity(&app.cfg));
-            app.clock_graph = Ring::new(ring_capacity(&app.cfg));
+            app.resize_graphs();
         }
         2 => {
             // Scale: toggle auto/fixed. Bare Enter toggles; also accept
@@ -175,20 +181,18 @@ fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
                 _ => return Err("use auto|fixed (or Enter to toggle)".into()),
             }
         }
-        3 => parse_scale(raw, 0.0..=app.cfg.vcore_bar_max, "V")
-            .map(|v| app.cfg.vcore_min = v)?,
+        3 => parse_scale(raw, 0.0..=app.cfg.vcore_bar_max, "V").map(|v| app.cfg.vcore_min = v)?,
         4 => parse_scale(raw, app.cfg.vcore_min.max(0.1)..=5.0, "V")
             .map(|v| app.cfg.vcore_bar_max = v)?,
         5 => parse_scale(raw, 0.0..=app.cfg.clock_bar_max / 1_000_000.0, "GHz")
             .map(|v| app.cfg.clock_min = v * 1_000_000.0)?,
-        6 => parse_scale(raw, 0.5..=10.0, "GHz")
-            .map(|v| app.cfg.clock_bar_max = v * 1_000_000.0)?,
-        7 => parse_scale(raw, 0.0..=app.cfg.power_bar_max, "W")
-            .map(|v| app.cfg.power_min = v)?,
+        6 => {
+            parse_scale(raw, 0.5..=10.0, "GHz").map(|v| app.cfg.clock_bar_max = v * 1_000_000.0)?
+        }
+        7 => parse_scale(raw, 0.0..=app.cfg.power_bar_max, "W").map(|v| app.cfg.power_min = v)?,
         8 => parse_scale(raw, app.cfg.power_min.max(1.0)..=500.0, "W")
             .map(|v| app.cfg.power_bar_max = v)?,
-        9 => parse_scale(raw, 0.0..=app.cfg.temp_max, "°C")
-            .map(|v| app.cfg.temp_min = v)?,
+        9 => parse_scale(raw, 0.0..=app.cfg.temp_max, "°C").map(|v| app.cfg.temp_min = v)?,
         _ => parse_scale(raw, app.cfg.temp_min.max(10.0)..=150.0, "°C")
             .map(|v| app.cfg.temp_max = v)?,
     }
@@ -197,7 +201,10 @@ fn setting_absorb(app: &mut App, row: usize, raw: &str) -> Result<(), String> {
 
 /// Parse a scale bound typed in the settings pane; must land in `range`.
 fn parse_scale(raw: &str, range: std::ops::RangeInclusive<f64>, unit: &str) -> Result<f64, String> {
-    let v: f64 = raw.trim().parse().map_err(|_| format!("'{raw}' is not a number"))?;
+    let v: f64 = raw
+        .trim()
+        .parse()
+        .map_err(|_| format!("'{raw}' is not a number"))?;
     if !range.contains(&v) {
         return Err(format!("out of range ({unit})"));
     }
@@ -260,6 +267,17 @@ impl App {
         }
     }
 
+    /// Rebuild the graph rings at the capacity implied by the current cfg.
+    /// Called whenever poll_ms or graph_secs change — the old sampling grid
+    /// is stale at the new period, so history is dropped.
+    fn resize_graphs(&mut self) {
+        let cap = ring_capacity(&self.cfg);
+        self.vcore_graph = Ring::new(cap);
+        self.watts_graph = Ring::new(cap);
+        self.temp_graph = Ring::new(cap);
+        self.clock_graph = Ring::new(cap);
+    }
+
     fn poll(&mut self) {
         self.vcore = sensors::sio_vcore();
         if let Some(v) = self.vcore {
@@ -309,9 +327,7 @@ impl App {
                     let e_core = cpus.first().is_some_and(|c| *c >= 12);
                     let freq_khz = cpus
                         .iter()
-                        .filter_map(|c| {
-                            freqs.iter().find(|(cpu, _)| cpu == c).map(|(_, k)| *k)
-                        })
+                        .filter_map(|c| freqs.iter().find(|(cpu, _)| cpu == c).map(|(_, k)| *k))
                         .max();
                     let freq_max_khz = cpus
                         .iter()
@@ -359,8 +375,7 @@ impl App {
                             self.package_watts = Some(w);
                             self.watts_graph.push(w);
                             if w.is_finite() {
-                                self.watts_all_time_peak =
-                                    self.watts_all_time_peak.max(w);
+                                self.watts_all_time_peak = self.watts_all_time_peak.max(w);
                             }
                         }
                         Some((e, Instant::now()))
@@ -435,8 +450,14 @@ fn meter_line(
     // Value text right-aligned inside the bar (htop pattern); bold default fg.
     let text_len = value_text.chars().count();
     let text: Vec<char> = if text_len >= inner {
-        value_text.chars().rev().take(inner).collect::<Vec<_>>()
-            .into_iter().rev().collect()
+        value_text
+            .chars()
+            .rev()
+            .take(inner)
+            .collect::<Vec<_>>()
+            .into_iter()
+            .rev()
+            .collect()
     } else {
         let pad = inner - text_len;
         std::iter::repeat_n(' ', pad)
@@ -499,8 +520,7 @@ fn core_section_lines(title: &str, cores: &[CoreRow], app: &App, width: u16) ->
         // 800 MHz idle floor sits at 0% and turbo pegs 100%.
         let ratio = match (core.freq_khz, core.freq_max_khz) {
             (Some(f), max) if max > core.freq_min_khz => Some(
-                ((f as f64 - core.freq_min_khz as f64)
-                    / (max - core.freq_min_khz) as f64)
+                ((f as f64 - core.freq_min_khz as f64) / (max - core.freq_min_khz) as f64)
                     .clamp(0.0, 1.0),
             ),
             _ => None,
@@ -523,16 +543,33 @@ fn render_lines(f: &mut Frame, area: Rect, lines: &[Line<'static>]) {
         }
         f.render_widget(
             ratatui::text::Text::from(line.clone()),
-            Rect { x: area.x, y: area.y + i as u16, width: area.width, height: 1 },
+            Rect {
+                x: area.x,
+                y: area.y + i as u16,
+                width: area.width,
+                height: 1,
+            },
         );
     }
 }
 
-/// One stacked plot: (label, live text, peak text, ring, axis min/max
-/// [RAW, for labels], trace min/max [PADDED, for render], line color,
-/// optional marker row, axis display divisor — clock shows GHz on the
-/// axis but stores kHz).
-type PlotSpec<'a> = (&'a str, String, String, &'a Ring, f64, f64, f64, f64, Color, Option<u16>, f64);
+/// One stacked plot. `axis_*` bounds are RAW (shown in the axis labels —
+/// the user's conf/window values); `trace_*` bounds are PADDED (what the
+/// renderer maps the trace over). `axis_div` is the display divisor —
+/// clock shows GHz on the axis but stores kHz.
+struct PlotSpec<'a> {
+    label: &'a str,
+    live: String,
+    peak: String,
+    ring: &'a Ring,
+    axis_min: f64,
+    axis_max: f64,
+    trace_min: f64,
+    trace_max: f64,
+    color: Color,
+    marker: Option<u16>,
+    axis_div: f64,
+}
 
 pub fn draw(f: &mut Frame, app: &App) {
     let area = f.area();
@@ -557,7 +594,10 @@ pub fn draw(f: &mut Frame, app: &App) {
         (area.height.saturating_sub(3 + footer_h), 0)
     } else if area.height >= cores_full + plots_min + footer_h {
         let leftover = area.height - cores_full - footer_h - 1;
-        (cores_full, (leftover / 4).saturating_sub(3).max(GRAPH_MIN_ROWS))
+        (
+            cores_full,
+            (leftover / 4).saturating_sub(3).max(GRAPH_MIN_ROWS),
+        )
     } else if area.height >= compact_h + plots_min + footer_h {
         (compact_h, GRAPH_MIN_ROWS)
     } else {
@@ -574,7 +614,10 @@ pub fn draw(f: &mut Frame, app: &App) {
         2 // pkg power + pkg temp meter rows
     };
     let bottom_h = bottom_h + 1; // blank separator above footer
-    let cores_area = Rect { height: cores_h, ..area };
+    let cores_area = Rect {
+        height: cores_h,
+        ..area
+    };
     let bottom_y = area.y + cores_h;
     let footer_y = area.y + area.height.saturating_sub(footer_h);
 
@@ -583,11 +626,7 @@ pub fn draw(f: &mut Frame, app: &App) {
     // that class's cores) instead of clipped per-core rows.
     let p: Vec<CoreRow> = app.cores.iter().filter(|c| !c.e_core).cloned().collect();
     let e: Vec<CoreRow> = app.cores.iter().filter(|c| c.e_core).cloned().collect();
-    let (col_w, used) = if dual {
-        ((w / 2).max(1), 2)
-    } else {
-        (w, 1)
-    };
+    let (col_w, n_cols) = if dual { ((w / 2).max(1), 2) } else { (w, 1) };
     if graphs_on && cores_h <= compact_h {
         // Compact: "P-cores"/"E-cores" meters show the busiest core of each
         // class (freq max; temp max for the safety color).
@@ -606,28 +645,62 @@ pub fn draw(f: &mut Frame, app: &App) {
             let fmax = cores.iter().map(|c| c.freq_max_khz).max().unwrap_or(0);
             let fmin = cores.iter().map(|c| c.freq_min_khz).max().unwrap_or(0);
             let ratio = match freq {
-                Some(fq) if fmax > fmin => Some(
-                    ((fq as f64 - fmin as f64) / (fmax - fmin) as f64).clamp(0.0, 1.0),
-                ),
+                Some(fq) if fmax > fmin => {
+                    Some(((fq as f64 - fmin as f64) / (fmax - fmin) as f64).clamp(0.0, 1.0))
+                }
                 _ => None,
             };
             let color = match temp {
                 Some(t) => temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit),
                 None => Color::DarkGray,
             };
-            render_lines(f, rect, &[meter_line(title, &text, ratio, color, rect.width, None)]);
+            render_lines(
+                f,
+                rect,
+                &[meter_line(title, &text, ratio, color, rect.width, None)],
+            );
         };
-        compact("P-cores", &p, Rect { width: col_w, ..cores_area });
-        if used == 2 {
-            compact("E-cores", &e, Rect { x: area.x + col_w, width: w - col_w, ..cores_area });
+        compact(
+            "P-cores",
+            &p,
+            Rect {
+                width: col_w,
+                ..cores_area
+            },
+        );
+        if n_cols == 2 {
+            compact(
+                "E-cores",
+                &e,
+                Rect {
+                    x: area.x + col_w,
+                    width: w - col_w,
+                    ..cores_area
+                },
+            );
         }
     } else {
         let sections = [
-            ("P-cores", p, Rect { width: col_w, ..cores_area }),
-            ("E-cores", e, Rect { x: area.x + col_w, width: w - col_w, ..cores_area }),
+            (
+                "P-cores",
+                p,
+                Rect {
+                    width: col_w,
+                    ..cores_area
+                },
+            ),
+            (
+                "E-cores",
+                e,
+                Rect {
+                    x: area.x + col_w,
+                    width: w - col_w,
+                    ..cores_area
+                },
+            ),
         ];
         for (i, (title, cores, rect)) in sections.into_iter().enumerate() {
-            if i >= used || rect.width == 0 {
+            if i >= n_cols || rect.width == 0 {
                 continue;
             }
             let lines = core_section_lines(title, &cores, app, rect.width);
@@ -636,7 +709,11 @@ pub fn draw(f: &mut Frame, app: &App) {
     }
 
     // ---- package zone (bottom) ----
-    let bottom_area = Rect { y: bottom_y, height: bottom_h, ..area };
+    let bottom_area = Rect {
+        y: bottom_y,
+        height: bottom_h,
+        ..area
+    };
     let vc_max = dynamic_max(app.vcore_all_time_peak, app.cfg.vcore_bar_max, SCALE_MARGIN);
     let watt_max = dynamic_max(app.watts_all_time_peak, app.cfg.power_bar_max, SCALE_MARGIN);
     let vc_color = match app.vcore {
@@ -698,21 +775,25 @@ pub fn draw(f: &mut Frame, app: &App) {
         // Raw bounds: fixed = conf min/max, auto = window min/max +
         // SCALE_MARGIN. Used for the axis labels so they show the user's
         // conf values (not the padded trace bounds).
-        let (vc_lo, vc_hi, ck_lo, ck_hi, pw_lo, pw_hi, tp_lo, tp_hi) =
-            if app.cfg.graph_scale_fixed {
-                (
-                    app.cfg.vcore_min, app.cfg.vcore_bar_max,
-                    app.cfg.clock_min, app.cfg.clock_bar_max,
-                    app.cfg.power_min, app.cfg.power_bar_max,
-                    app.cfg.temp_min, app.cfg.temp_max,
-                )
-            } else {
-                let vc = (ring_min(&app.vcore_graph), ring_max(&app.vcore_graph, 0.0));
-                let ck = (ring_min(&app.clock_graph), ring_max(&app.clock_graph, 0.0));
-                let pw = (ring_min(&app.watts_graph), ring_max(&app.watts_graph, 0.0));
-                let tp = (ring_min(&app.temp_graph), ring_max(&app.temp_graph, 0.0));
-                (vc.0, vc.1, ck.0, ck.1, pw.0, pw.1, tp.0, tp.1)
-            };
+        let (vc_lo, vc_hi, ck_lo, ck_hi, pw_lo, pw_hi, tp_lo, tp_hi) = if app.cfg.graph_scale_fixed
+        {
+            (
+                app.cfg.vcore_min,
+                app.cfg.vcore_bar_max,
+                app.cfg.clock_min,
+                app.cfg.clock_bar_max,
+                app.cfg.power_min,
+                app.cfg.power_bar_max,
+                app.cfg.temp_min,
+                app.cfg.temp_max,
+            )
+        } else {
+            let vc = (ring_min(&app.vcore_graph), ring_max(&app.vcore_graph, 0.0));
+            let ck = (ring_min(&app.clock_graph), ring_max(&app.clock_graph, 0.0));
+            let pw = (ring_min(&app.watts_graph), ring_max(&app.watts_graph, 0.0));
+            let tp = (ring_min(&app.temp_graph), ring_max(&app.temp_graph, 0.0));
+            (vc.0, vc.1, ck.0, ck.1, pw.0, pw.1, tp.0, tp.1)
+        };
         // Padded bounds for the trace mapping: ~1/4 interior row top and
         // bottom (plus a small per-metric floor) so the trace stays just
         // clear of the box borders in both fixed and auto mode. Applied
@@ -727,17 +808,84 @@ pub fn draw(f: &mut Frame, app: &App) {
         let (p_tp_lo, p_tp_hi) = pad(tp_lo, tp_hi, 1.0);
         // 4 boxed plots stacked top→bottom, sharing one time axis.
         // Cascade: vCore → Peak clock → Pkg power → Pkg temp (user order).
+        // vCore limit marker cell row (over the PADDED bounds, clamped so
+        // an out-of-scale limit pins to the box edge).
+        let vc_marker = ((1.0
+            - ((app.cfg.vcore_limit - p_vc_lo) / (p_vc_hi - p_vc_lo)).clamp(0.0, 1.0))
+            * (graph_rows - 1) as f64)
+            .round() as u16;
+        let temp_now = app.package_temp.map_or(Color::DarkGray, |t| {
+            temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)
+        });
         let plots: [PlotSpec; 4] = [
-            ("vCore", hero_text.clone(), hero_peak, &app.vcore_graph, vc_lo, vc_hi, p_vc_lo, p_vc_hi, vc_color,
-                Some(((1.0 - ((app.cfg.vcore_limit - p_vc_lo) / (p_vc_hi - p_vc_lo)).clamp(0.0, 1.0))
-                    * (graph_rows - 1) as f64).round() as u16), 1.0),
-            ("Clock", clock_text, clock_peak, &app.clock_graph, ck_lo, ck_hi, p_ck_lo, p_ck_hi, Color::Green, None, 1_000_000.0),
-            ("Power", fmt_opt_watts(app.package_watts), watts_peak, &app.watts_graph, pw_lo, pw_hi, p_pw_lo, p_pw_hi, Color::Blue, None, 1.0),
-            ("Temp", fmt_opt_temp(app.package_temp), temp_peak, &app.temp_graph, tp_lo, tp_hi, p_tp_lo, p_tp_hi,
-                app.package_temp.map_or(Color::DarkGray, |t| temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)), None, 1.0),
+            PlotSpec {
+                label: "vCore",
+                live: hero_text.clone(),
+                peak: hero_peak,
+                ring: &app.vcore_graph,
+                axis_min: vc_lo,
+                axis_max: vc_hi,
+                trace_min: p_vc_lo,
+                trace_max: p_vc_hi,
+                color: vc_color,
+                marker: Some(vc_marker),
+                axis_div: 1.0,
+            },
+            PlotSpec {
+                label: "Clock",
+                live: clock_text,
+                peak: clock_peak,
+                ring: &app.clock_graph,
+                axis_min: ck_lo,
+                axis_max: ck_hi,
+                trace_min: p_ck_lo,
+                trace_max: p_ck_hi,
+                color: Color::Green,
+                marker: None,
+                axis_div: 1_000_000.0,
+            },
+            PlotSpec {
+                label: "Power",
+                live: fmt_opt_watts(app.package_watts),
+                peak: watts_peak,
+                ring: &app.watts_graph,
+                axis_min: pw_lo,
+                axis_max: pw_hi,
+                trace_min: p_pw_lo,
+                trace_max: p_pw_hi,
+                color: Color::Blue,
+                marker: None,
+                axis_div: 1.0,
+            },
+            PlotSpec {
+                label: "Temp",
+                live: fmt_opt_temp(app.package_temp),
+                peak: temp_peak,
+                ring: &app.temp_graph,
+                axis_min: tp_lo,
+                axis_max: tp_hi,
+                trace_min: p_tp_lo,
+                trace_max: p_tp_hi,
+                color: temp_now,
+                marker: None,
+                axis_div: 1.0,
+            },
         ];
         let mut y = bottom_y;
-        for (label, text, peak, ring, axis_min, axis_max, min, max, color, marker, axis_div) in plots {
+        for PlotSpec {
+            label,
+            live,
+            peak,
+            ring,
+            axis_min,
+            axis_max,
+            trace_min,
+            trace_max,
+            color,
+            marker,
+            axis_div,
+        } in plots
+        {
             let axis_max = plot::fmt_axis(axis_max / axis_div);
             let axis_min = if axis_div > 1.0 {
                 // GHz axis: always show decimals so a near-zero min isn't "0".
@@ -761,14 +909,24 @@ pub fn draw(f: &mut Frame, app: &App) {
             // ratatui's line diff drops separator spans in narrow rects.
             let key = Line::from(vec![
                 Span::styled(format!("{label}  "), Style::default().fg(Color::Cyan)),
-                Span::styled(text.clone(), Style::default().add_modifier(Modifier::BOLD)),
+                Span::styled(live, Style::default().add_modifier(Modifier::BOLD)),
             ]);
             f.render_widget(
                 ratatui::text::Text::from(key),
-                Rect { x: area.x, y, width: w, height: 1 },
+                Rect {
+                    x: area.x,
+                    y,
+                    width: w,
+                    height: 1,
+                },
             );
 
-            let box_rect = Rect { x: box_x, y: y + 1, width: box_w, height: box_h };
+            let box_rect = Rect {
+                x: box_x,
+                y: y + 1,
+                width: box_w,
+                height: box_h,
+            };
             f.render_widget(
                 ratatui::widgets::Block::default()
                     .borders(ratatui::widgets::Borders::ALL)
@@ -785,47 +943,90 @@ pub fn draw(f: &mut Frame, app: &App) {
             if max_w + 2 <= box_w {
                 f.render_widget(
                     ratatui::text::Text::from(Line::from(Span::styled(axis_max.clone(), axis_st))),
-                    Rect { x: box_x + 2, y: box_rect.y, width: max_w, height: 1 },
+                    Rect {
+                        x: box_x + 2,
+                        y: box_rect.y,
+                        width: max_w,
+                        height: 1,
+                    },
                 );
             }
             if min_w + 2 <= box_w {
                 f.render_widget(
                     ratatui::text::Text::from(Line::from(Span::styled(axis_min.clone(), axis_st))),
-                    Rect { x: box_x + 2, y: box_rect.y + box_h - 1, width: min_w, height: 1 },
+                    Rect {
+                        x: box_x + 2,
+                        y: box_rect.y + box_h - 1,
+                        width: min_w,
+                        height: 1,
+                    },
                 );
             }
-            let used = plot::render(
+            let trace_cols = plot::render(
                 f,
-                Rect { x: box_rect.x + 1, y: box_rect.y + 1, width: inner_w, height: graph_rows },
-                ring, 0, min, max, color, marker,
+                Rect {
+                    x: box_rect.x + 1,
+                    y: box_rect.y + 1,
+                    width: inner_w,
+                    height: graph_rows,
+                },
+                ring,
+                trace_min,
+                trace_max,
+                color,
+                marker,
             );
             if !peak.is_empty() {
                 let peak_w = peak.chars().count() as u16;
-                let trace_right = box_rect.x + 1 + used.max(1);
-                let peak_x = trace_right
-                    .saturating_sub(peak_w)
-                    .clamp(area.x + KEY_RESERVE, box_rect.x + box_w - peak_w.min(box_w));
+                let trace_right = box_rect.x + 1 + trace_cols.max(1);
+                // Never call clamp with min > max (it panics): order the
+                // bounds with saturating math first.
+                let hi = box_rect.x + box_w.saturating_sub(peak_w);
+                let lo = (area.x + KEY_RESERVE).min(hi);
+                let peak_x = trace_right.saturating_sub(peak_w).max(lo).min(hi);
                 f.render_widget(
                     ratatui::text::Text::from(Line::from(Span::styled(
                         peak,
                         Style::default().fg(Color::DarkGray),
                     ))),
-                    Rect { x: peak_x, y, width: peak_w, height: 1 },
+                    Rect {
+                        x: peak_x,
+                        y,
+                        width: peak_w,
+                        height: 1,
+                    },
                 );
             }
             y += plot_h;
         }
     } else if !app.settings_open {
-        let mut lines = vec![
-            meter_line("vCore", &hero_text, app.vcore.map(|v| v / vc_max), vc_color, w,
-                Some(app.cfg.vcore_limit / vc_max)),
-        ];
+        let mut lines = vec![meter_line(
+            "vCore",
+            &hero_text,
+            app.vcore.map(|v| v / vc_max),
+            vc_color,
+            w,
+            Some(app.cfg.vcore_limit / vc_max),
+        )];
         if !app.cfg.graphs || w < GRAPHS_MIN_WIDTH {
-            lines.push(meter_line("Pkg power", &fmt_opt_watts(app.package_watts),
-                app.package_watts.map(|v| v / watt_max), Color::Blue, w, None));
-            lines.push(meter_line("Pkg temp", &fmt_opt_temp(app.package_temp),
+            lines.push(meter_line(
+                "Pkg power",
+                &fmt_opt_watts(app.package_watts),
+                app.package_watts.map(|v| v / watt_max),
+                Color::Blue,
+                w,
+                None,
+            ));
+            lines.push(meter_line(
+                "Pkg temp",
+                &fmt_opt_temp(app.package_temp),
                 app.package_temp.map(|t| t / app.cfg.temp_max),
-                app.package_temp.map_or(Color::DarkGray, |t| temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)), w, None));
+                app.package_temp.map_or(Color::DarkGray, |t| {
+                    temp_color(t, app.cfg.temp_warn, app.cfg.temp_crit)
+                }),
+                w,
+                None,
+            ));
             lines.push(Line::from(""));
         }
         render_lines(f, bottom_area, &lines);
@@ -839,11 +1040,26 @@ pub fn draw(f: &mut Frame, app: &App) {
             Span::styled("Max freq: ", Style::default().fg(Color::Cyan)),
             Span::raw(format!(
                 "{:.2} GHz",
-                app.cores.iter().filter_map(|c| c.freq_khz).max().unwrap_or(0) as f64 / 1_000_000.0
+                app.cores
+                    .iter()
+                    .filter_map(|c| c.freq_khz)
+                    .max()
+                    .unwrap_or(0) as f64
+                    / 1_000_000.0
             )),
-            Span::styled("    q: quit  F2: settings", Style::default().fg(Color::DarkGray)),
+            Span::styled(
+                "    q: quit  F2: settings",
+                Style::default().fg(Color::DarkGray),
+            ),
         ]);
-        f.render_widget(ratatui::text::Text::from(footer), Rect { y: footer_y, height: 1, ..area });
+        f.render_widget(
+            ratatui::text::Text::from(footer),
+            Rect {
+                y: footer_y,
+                height: 1,
+                ..area
+            },
+        );
     }
 }
 
@@ -851,12 +1067,19 @@ fn draw_settings(f: &mut Frame, area: Rect, app: &App) {
     let h = (SETTINGS_ROWS.len() as u16 + 4).min(area.height); // title + rows + hint + margin
     let w = 52u16.min(area.width);
     let y = area.height.saturating_sub(h + 1) / 2;
-    let rect = Rect { x: (area.width - w) / 2, y, width: w, height: h };
+    let rect = Rect {
+        x: (area.width - w) / 2,
+        y,
+        width: w,
+        height: h,
+    };
 
     f.render_widget(ratatui::widgets::Clear, rect); // blank the graphs behind
     let mut lines = vec![Line::from(Span::styled(
         "Settings",
-        Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
+        Style::default()
+            .fg(Color::Cyan)
+            .add_modifier(Modifier::BOLD),
     ))];
     for (i, label) in SETTINGS_ROWS.iter().enumerate() {
         let cursor = if i == app.settings_row { ">" } else { " " };
@@ -872,12 +1095,18 @@ fn draw_settings(f: &mut Frame, area: Rect, app: &App) {
             Style::default()
         };
         lines.push(Line::from(vec![
-            Span::styled(format!("{} {:<14}", cursor, label), Style::default().fg(Color::Cyan)),
+            Span::styled(
+                format!("{} {:<14}", cursor, label),
+                Style::default().fg(Color::Cyan),
+            ),
             Span::styled(value, style),
         ]));
     }
     if let Some(err) = &app.settings_error {
-        lines.push(Line::from(Span::styled(err.clone(), Style::default().fg(Color::Red))));
+        lines.push(Line::from(Span::styled(
+            err.clone(),
+            Style::default().fg(Color::Red),
+        )));
     } else {
         lines.push(Line::from(""));
     }
@@ -937,8 +1166,12 @@ pub fn run_tui() {
                         app.edit_buf.clear();
                     }
                     KeyCode::F(10) if app.settings_open => {
-                        save_conf(&app);
-                        app.settings_open = false;
+                        if let Err(e) = save_conf(&app) {
+                            // Failed save keeps the pane open; error inline.
+                            app.settings_error = Some(format!("save failed: {e}"));
+                        } else {
+                            app.settings_open = false;
+                        }
                     }
                     KeyCode::Char('q') | KeyCode::Esc
                         if !app.settings_open || key.code == KeyCode::Esc =>
@@ -967,10 +1200,11 @@ pub fn run_tui() {
 
 /// Write the settings block back to the conf the app loaded (F10). No-op
 /// when no file was loaded (defaults only).
-fn save_conf(app: &App) {
+fn save_conf(app: &App) -> Result<(), String> {
     if let Some(path) = &app.cfg.loaded_path {
         let text = std::fs::read_to_string(path).unwrap_or_default();
         let updated = config::update_conf_text(&text, &app.cfg);
-        let _ = std::fs::write(path, updated);
+        std::fs::write(path, updated).map_err(|e| format!("{}: {e}", path.display()))?;
     }
-}
\ No newline at end of file
+    Ok(())
+}