Settings
+Widget appearance and preferences.
+Appearance
+ +Widget
Layout
-
+
Size
-
+
Or drag the widget's bottom-right grip to any size.
Mascot
-
+
Limits
@@ -33,7 +41,7 @@
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index c71ad34..f3139c4 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -4240,7 +4240,7 @@ dependencies = [
[[package]]
name = "tokometer"
-version = "1.4.0"
+version = "1.5.0"
dependencies = [
"ab_glyph",
"reqwest 0.12.28",
diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs
index 40a900e..d5b7d46 100644
--- a/src-tauri/src/commands.rs
+++ b/src-tauri/src/commands.rs
@@ -4,7 +4,7 @@ use tauri_plugin_autostart::ManagerExt;
use crate::history::{HistoryLog, RawSample, Sample};
use crate::poller::RefreshSignal;
-use crate::state::{AppState, BetaFeatures, Layout, Mascot, Size, TrayStyle};
+use crate::state::{AppState, BetaFeatures, Layout, Mascot, Size, Theme, TrayStyle};
#[tauri::command]
pub fn get_state(state: State<'_, AppState>) -> serde_json::Value {
@@ -16,6 +16,7 @@ pub fn get_state(state: State<'_, AppState>) -> serde_json::Value {
"customScale": s.custom_scale,
"mascot": s.mascot,
"trayStyle": s.tray_style,
+ "theme": s.theme,
"workDays": s.work_days,
"probeFallback": s.probe_fallback,
"hiddenLimits": s.hidden_limits,
@@ -62,6 +63,20 @@ pub fn set_tray_style(app: AppHandle, style: String) {
}
}
+#[tauri::command]
+pub fn set_theme(app: AppHandle, theme: String) {
+ let Some(theme) = Theme::from_id(&theme) else {
+ return;
+ };
+ app.state::().0.lock().unwrap().theme = theme;
+ if let Some(win) = app.get_webview_window("settings") {
+ let _ = win.set_theme(Some(theme.window_theme()));
+ let _ = win.set_background_color(Some(theme.background_color()));
+ }
+ crate::state::save(&app);
+ crate::tray::emit_state(&app);
+}
+
#[tauri::command]
pub fn set_work_days(app: AppHandle, days: Vec) {
let Ok(days) = <[bool; 7]>::try_from(days) else {
@@ -192,6 +207,7 @@ pub fn show_settings(app: &AppHandle) {
// the event loop safely on every platform.
let app = app.clone();
tauri::async_runtime::spawn(async move {
+ let theme = app.state::().0.lock().unwrap().theme;
let _ = tauri::WebviewWindowBuilder::new(
&app,
"settings",
@@ -202,12 +218,13 @@ pub fn show_settings(app: &AppHandle) {
.resizable(false)
.maximizable(false)
.minimizable(false)
- // Born hidden, and black underneath: the page shows itself once its
+ // Born hidden, with the theme underneath: the page shows itself once its
// first render has landed (settings.ts), but the webview can still
// surface before its first paint — a native background in the page's
// colour keeps that moment invisible instead of a white flash.
.visible(false)
- .background_color(tauri::window::Color(0, 0, 0, 255))
+ .theme(Some(theme.window_theme()))
+ .background_color(theme.background_color())
.build();
});
}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index b3cde08..f73dafe 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -165,6 +165,7 @@ pub fn run() {
commands::set_layout,
commands::set_size,
commands::set_tray_style,
+ commands::set_theme,
commands::set_work_days,
commands::set_hidden_limits,
commands::set_probe_fallback,
diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs
index 2933df3..3764a1b 100644
--- a/src-tauri/src/state.rs
+++ b/src-tauri/src/state.rs
@@ -178,6 +178,52 @@ impl TrayStyle {
}
}
+/// Shared appearance for the widget and settings window.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum Theme {
+ #[default]
+ Charcoal,
+ Midnight,
+ Paper,
+ Mist,
+}
+
+impl Theme {
+ pub const ALL: [Theme; 4] = [Theme::Charcoal, Theme::Midnight, Theme::Paper, Theme::Mist];
+
+ pub fn id(self) -> &'static str {
+ match self {
+ Theme::Charcoal => "charcoal",
+ Theme::Midnight => "midnight",
+ Theme::Paper => "paper",
+ Theme::Mist => "mist",
+ }
+ }
+
+ pub fn from_id(id: &str) -> Option {
+ Self::ALL.into_iter().find(|theme| theme.id() == id)
+ }
+
+ pub fn window_theme(self) -> tauri::Theme {
+ match self {
+ Theme::Charcoal | Theme::Midnight => tauri::Theme::Dark,
+ Theme::Paper | Theme::Mist => tauri::Theme::Light,
+ }
+ }
+
+ /// Native background before the settings webview paints; matches --bg in theme.css.
+ pub fn background_color(self) -> tauri::window::Color {
+ let (r, g, b) = match self {
+ Theme::Charcoal => (0x14, 0x15, 0x16),
+ Theme::Midnight => (0x05, 0x05, 0x05),
+ Theme::Paper => (0xf0, 0xec, 0xe4),
+ Theme::Mist => (0xe9, 0xef, 0xf4),
+ };
+ tauri::window::Color(r, g, b, 255)
+ }
+}
+
/// Which weekdays count as "work days", indexed Sun..Sat to match the
/// frontend's `Date.getDay()`. Unchecked days hold the 7-day prediction flat
/// (no usage expected), so the dotted line doesn't extrapolate across them.
@@ -207,6 +253,7 @@ pub struct PersistedState {
pub custom_scale: Option,
pub mascot: Mascot,
pub tray_style: TrayStyle,
+ pub theme: Theme,
/// All-true by default; a plain derive would flatten the whole prediction.
#[serde(default = "all_work_days")]
pub work_days: [bool; 7],
@@ -247,6 +294,7 @@ impl Default for PersistedState {
custom_scale: None,
mascot: Mascot::default(),
tray_style: TrayStyle::default(),
+ theme: Theme::default(),
work_days: all_work_days(),
probe_fallback: true,
hidden_limits: Vec::new(),
@@ -465,6 +513,38 @@ mod tests {
assert_eq!(TrayStyle::default(), TrayStyle::Ring);
}
+ #[test]
+ fn every_theme_survives_a_state_save_and_reload() {
+ for theme in Theme::ALL {
+ assert_eq!(Theme::from_id(theme.id()), Some(theme));
+ let state = PersistedState {
+ theme,
+ pin: true,
+ layout: Layout::TilesRow,
+ ..Default::default()
+ };
+ let json = serde_json::to_string(&state).unwrap();
+ let restored: PersistedState = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored.theme, theme);
+ assert!(restored.pin);
+ assert_eq!(restored.layout, Layout::TilesRow);
+ }
+ assert_eq!(Theme::from_id("unknown"), None);
+ }
+
+ #[test]
+ fn upgrading_an_older_state_defaults_theme_without_resetting_preferences() {
+ let state: PersistedState = serde_json::from_str(
+ r#"{"pin":true,"layout":"tiles-column","size":"large","hiddenLimits":["weekly_all"]}"#,
+ )
+ .unwrap();
+ assert_eq!(state.theme, Theme::Charcoal);
+ assert!(state.pin);
+ assert_eq!(state.layout, Layout::TilesColumn);
+ assert_eq!(state.size, Size::Large);
+ assert_eq!(state.hidden_limits, vec!["weekly_all"]);
+ }
+
#[test]
fn persisted_state_fills_missing_fields_with_defaults() {
// The poller writes partial state early on; load() must tolerate it.
@@ -474,6 +554,7 @@ mod tests {
assert_eq!(s.size, Size::Small);
assert_eq!(s.mascot, Mascot::Clawd);
assert_eq!(s.tray_style, TrayStyle::Ring);
+ assert_eq!(s.theme, Theme::Charcoal);
// All-true, not the [false; 7] a plain field default would give —
// otherwise an old state.json (no workDays key) flattens the prediction.
assert_eq!(s.work_days, [true; 7]);
@@ -506,6 +587,7 @@ mod tests {
custom_scale: Some(1.1),
mascot: Mascot::Axolotl,
tray_style: TrayStyle::Text,
+ theme: Theme::Paper,
work_days: [true, false, true, true, true, true, false],
probe_fallback: true,
hidden_limits: vec!["weekly_scoped:fable".into()],
@@ -524,6 +606,7 @@ mod tests {
assert_eq!(back.custom_scale, original.custom_scale);
assert_eq!(back.mascot, original.mascot);
assert_eq!(back.tray_style, original.tray_style);
+ assert_eq!(back.theme, original.theme);
assert_eq!(back.work_days, original.work_days);
assert_eq!(back.probe_fallback, original.probe_fallback);
assert_eq!(back.window.unwrap().x, 12.0);
diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs
index 44b4883..1db22e2 100644
--- a/src-tauri/src/tray.rs
+++ b/src-tauri/src/tray.rs
@@ -177,6 +177,7 @@ pub fn emit_state(app: &AppHandle) {
"customScale": s.custom_scale,
"mascot": s.mascot,
"trayStyle": s.tray_style,
+ "theme": s.theme,
"workDays": s.work_days,
"probeFallback": s.probe_fallback,
"hiddenLimits": s.hidden_limits,
diff --git a/src/api.ts b/src/api.ts
index 74d6741..bd770cd 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -63,6 +63,9 @@ export type Size = "small" | "medium" | "large";
/** Mirrors the Rust `TrayStyle` enum (state.rs). */
export type TrayStyle = "ring" | "text";
+/** Shared widget/settings palette; mirrors the Rust `Theme` enum. */
+export type Theme = "charcoal" | "midnight" | "paper" | "mist";
+
/** Names of the opt-in feature previews being trialled; none right now. */
export type BetaFlag = never;
/** Opt-in feature previews, one boolean per flag; mirrors the Rust
@@ -78,6 +81,7 @@ export interface Preferences {
customScale: number | null;
mascot: Mascot;
trayStyle: TrayStyle;
+ theme: Theme;
/** Which weekdays the 7-day prediction ramps, indexed Sun..Sat. */
workDays: boolean[];
/** Whether a failing usage endpoint may fall back to the 1-token probe. */
@@ -102,6 +106,7 @@ export const setMascot = (mascot: Mascot) => invoke("set_mascot", { mascot
export const setLayout = (layout: Layout) => invoke("set_layout", { layout });
export const setSize = (size: Size) => invoke("set_size", { size });
export const setTrayStyle = (style: TrayStyle) => invoke("set_tray_style", { style });
+export const setTheme = (theme: Theme) => invoke("set_theme", { theme });
export const setWorkDays = (days: boolean[]) => invoke("set_work_days", { days });
export const setHiddenLimits = (ids: string[]) => invoke("set_hidden_limits", { ids });
export const setProbeFallback = (enabled: boolean) =>
diff --git a/src/graph.ts b/src/graph.ts
index 4a33f11..f1bf99e 100644
--- a/src/graph.ts
+++ b/src/graph.ts
@@ -1,6 +1,6 @@
// Usage-over-time graph, shown in place of the mascot: history line coloured
-// by the usage thresholds, a dotted prediction at the current rate, a red bar
-// at the limit, and a faint ghost of the previous window. Right-clicking it
+// by the usage thresholds, a dotted prediction at the current rate, a quiet
+// limit ceiling, and a faint ghost of the previous window. Right-clicking it
// cycles through the limit windows the tiles show (5h, 7d, and any scoped
// model limit); hovering reads off the time and percentage under the cursor.
import {
@@ -51,9 +51,18 @@ const PAD = 8; // px at design size; rendered damped by --chrome
const GHOST_MIN_PEAK_PCT = 5;
/** A :root CSS variable's colour, so the canvas can't drift from styles.css. */
-function cssColor(name: string, fallback: string): string {
- const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
- return value || fallback;
+function graphColors() {
+ const style = getComputedStyle(document.documentElement);
+ const color = (name: string, fallback: string) => style.getPropertyValue(name).trim() || fallback;
+ return {
+ bg: color("--bg", "#141516"),
+ green: color("--green", "#a3bc90"),
+ amber: color("--amber", "#e3b078"),
+ red: color("--red", "#e68c83"),
+ dim: color("--dim", "#a9adaa"),
+ text: color("--text", "#f1f0ed"),
+ track: color("--track", "#2e3232"),
+ };
}
/** The :root --chrome factor (see styles.css), so the graph's furniture —
@@ -79,13 +88,7 @@ export class UsageGraph {
/** The --chrome factor, refreshed at the top of each draw. */
private chrome = 1;
- private readonly bg = cssColor("--bg", "#000000");
- private readonly green = cssColor("--green", "#788c5d");
- private readonly amber = cssColor("--amber", "#d97757");
- private readonly red = cssColor("--red", "#c0392b");
- private readonly dim = cssColor("--dim", "#b0aea5");
- private readonly text = cssColor("--text", "#faf9f5");
- private readonly track = cssColor("--track", "#2a2a28");
+ private colors = graphColors();
constructor(
private canvas: HTMLCanvasElement,
@@ -100,6 +103,8 @@ export class UsageGraph {
const i = windows.findIndex((w) => w.id === this.current().id);
this.windowId = windows[(i + 1) % windows.length].id;
localStorage.setItem(WINDOW_KEY, this.windowId);
+ // Reveal the newly selected window's label until the cursor moves again.
+ this.hoverX = null;
this.draw();
});
// offsetX is in the element's own space, which the real-pixel layout
@@ -176,6 +181,8 @@ export class UsageGraph {
const h = this.canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
+ // Theme changes must repaint the canvas as well as the surrounding CSS.
+ this.colors = graphColors();
this.chrome = chromeScale();
const c = this.chrome;
const pad = PAD * c;
@@ -194,7 +201,7 @@ export class UsageGraph {
// The corner label makes way for the hover readout (drawHover).
if (this.hoverX === null) {
ctx.font = `400 ${14 * c}px Grotesk, sans-serif`;
- ctx.fillStyle = this.dim;
+ ctx.fillStyle = this.colors.dim;
ctx.textBaseline = "top";
ctx.fillText(shown.label, pad, pad + 4 * c);
}
@@ -203,7 +210,7 @@ export class UsageGraph {
ctx.lineJoin = "round";
// Faint quarter gridlines give the empty space some structure.
- ctx.strokeStyle = this.track;
+ ctx.strokeStyle = this.colors.track;
ctx.lineWidth = c;
for (const pct of [25, 50, 75]) {
ctx.beginPath();
@@ -224,7 +231,7 @@ export class UsageGraph {
if (prev && prev.pts.some((p) => p.pct >= GHOST_MIN_PEAK_PCT)) {
const shift = resetMs === null ? 0 : end - prev.resetMs;
const ghost = prev.pts.filter((p) => p.ms + shift >= start);
- ctx.strokeStyle = this.dim;
+ ctx.strokeStyle = this.colors.dim;
ctx.globalAlpha = 0.3;
ctx.lineWidth = 2 * c;
ctx.beginPath();
@@ -238,18 +245,43 @@ export class UsageGraph {
}
}
- // The limit ceiling, and a thin marker at the reset time.
+ const pts = win
+ ? this.history.points(shown.id, start, resetMs).filter((p) => p.ms <= now)
+ : [];
+ if (win) pts.push({ ms: Math.min(now, end), pct: win.utilization });
+
+ let proj: Pt[] | null = null;
+ const slope = trendSlope(pts, span.trendMs, now);
+ if (win?.resetAt && slope !== null && now < end) {
+ const rise = Math.max(0, slope);
+ // The work-day mask only shapes multi-day windows; the 5h projection is
+ // intra-day, so it always ramps.
+ const isWorkDay =
+ span.windowMs > DAY_MS
+ ? (ms: number) => this.workDays[new Date(ms).getDay()] !== false
+ : () => true;
+ proj = projectUsage(now, end, win.utilization, rise, isWorkDay);
+ }
+
+ // A short tick marks the reset without framing the right edge.
if (resetMs !== null) {
- ctx.strokeStyle = this.dim;
+ ctx.save();
+ ctx.strokeStyle = this.colors.dim;
+ ctx.globalAlpha = 0.5;
ctx.lineWidth = c;
ctx.beginPath();
- ctx.moveTo(x(end), y(0));
- ctx.lineTo(x(end), y(100));
+ ctx.moveTo(x(end), y(0) - 4 * c);
+ ctx.lineTo(x(end), y(0));
ctx.stroke();
+ ctx.restore();
}
- // The limit reads as a red gridline, not a frame around the panel.
- ctx.strokeStyle = this.red;
- ctx.globalAlpha = 0.7;
+ // Keep the ceiling neutral until the current reading or forecast reaches
+ // the same warning threshold that turns the usage tile red.
+ const nearLimit =
+ (win?.utilization ?? 0) >= RED_AT_PCT ||
+ (proj?.some((p) => p.pct >= RED_AT_PCT) ?? false);
+ ctx.strokeStyle = nearLimit ? this.colors.red : this.colors.track;
+ ctx.globalAlpha = nearLimit ? 0.45 : 0.7;
ctx.lineWidth = c;
ctx.beginPath();
ctx.moveTo(pad, y(100));
@@ -261,12 +293,9 @@ export class UsageGraph {
// The line's colour follows its height: green low, blending through
// amber at the warning threshold to red at the limit threshold.
const gradient = ctx.createLinearGradient(0, y(0), 0, y(100));
- gradient.addColorStop(0, this.green);
- gradient.addColorStop(AMBER_AT_PCT / 100, this.amber);
- gradient.addColorStop(RED_AT_PCT / 100, this.red);
-
- const pts = this.history.points(shown.id, start, resetMs).filter((p) => p.ms <= now);
- pts.push({ ms: Math.min(now, end), pct: win.utilization });
+ gradient.addColorStop(0, this.colors.green);
+ gradient.addColorStop(AMBER_AT_PCT / 100, this.colors.amber);
+ gradient.addColorStop(RED_AT_PCT / 100, this.colors.red);
// Soft area fill anchors the line to the baseline.
if (pts.length >= 2) {
@@ -287,25 +316,18 @@ export class UsageGraph {
ctx.lineWidth = 3 * c;
this.strokePolyline(ctx, pts, gradient, x, y);
- let proj: Pt[] | null = null;
- const slope = trendSlope(pts, span.trendMs, now);
- if (win.resetAt && slope !== null && now < end) {
- const rise = Math.max(0, slope);
- // The work-day mask only shapes multi-day windows; the 5h projection is
- // intra-day, so it always ramps.
- const isWorkDay =
- span.windowMs > DAY_MS
- ? (ms: number) => this.workDays[new Date(ms).getDay()] !== false
- : () => true;
- proj = projectUsage(now, end, win.utilization, rise, isWorkDay);
+ if (proj) {
+ ctx.save();
+ ctx.lineWidth = 2 * c;
+ ctx.globalAlpha = 0.8;
ctx.setLineDash([c, 6 * c]);
this.strokePolyline(ctx, proj, gradient, x, y);
- ctx.setLineDash([]);
+ ctx.restore();
}
// A bright "now" marker at the end of the live line.
const cur = pts[pts.length - 1];
- ctx.fillStyle = this.text;
+ ctx.fillStyle = this.colors.text;
ctx.beginPath();
ctx.arc(x(cur.ms), y(cur.pct), 2.5 * c, 0, Math.PI * 2);
ctx.fill();
@@ -329,7 +351,7 @@ export class UsageGraph {
const hx = Math.min(Math.max(this.hoverX, pad), w - pad);
const t = start + ((hx - pad) / (w - 2 * pad)) * windowMs;
- ctx.strokeStyle = this.dim;
+ ctx.strokeStyle = this.colors.dim;
ctx.globalAlpha = 0.5;
ctx.lineWidth = c;
ctx.beginPath();
@@ -342,7 +364,7 @@ export class UsageGraph {
const recorded = interpolate(pts, t);
const pct = recorded ?? (proj ? interpolate(proj, t) : null);
if (pct !== null) {
- ctx.fillStyle = this.text;
+ ctx.fillStyle = this.colors.text;
ctx.beginPath();
ctx.arc(hx, y(pct), 2 * c, 0, Math.PI * 2);
ctx.fill();
@@ -361,14 +383,14 @@ export class UsageGraph {
ctx.textBaseline = "top";
const tw = ctx.measureText(label).width;
ctx.globalAlpha = 0.8;
- ctx.fillStyle = this.bg;
+ ctx.fillStyle = this.colors.bg;
ctx.beginPath();
if (typeof ctx.roundRect === "function")
ctx.roundRect(pad - 3 * c, pad, tw + 8 * c, 21 * c, 5 * c);
else ctx.rect(pad - 3 * c, pad, tw + 8 * c, 21 * c);
ctx.fill();
ctx.globalAlpha = 1;
- ctx.fillStyle = this.text;
+ ctx.fillStyle = this.colors.text;
ctx.fillText(label, pad + c, pad + 4 * c);
}
diff --git a/src/main.ts b/src/main.ts
index 9b736a5..805d52b 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -6,6 +6,9 @@ import { Splash } from "./splash";
import { RateTracker } from "./rate";
import { UsageHistory } from "./history";
import { UsageGraph } from "./graph";
+import { applyTheme, restoreTheme } from "./theme";
+
+restoreTheme();
const appWindow = getCurrentWindow();
@@ -364,6 +367,7 @@ if (import.meta.env.DEV) {
});
}
void api.onStateChange((s) => {
+ applyTheme(s.theme);
pinned = s.pin;
renderPin();
applyLayout(s.layout);
@@ -375,6 +379,7 @@ void api.onStateChange((s) => {
});
void api.getState().then((st) => {
+ applyTheme(st.theme);
pinned = st.pin;
renderPin();
applyLayout(st.layout);
diff --git a/src/settings.css b/src/settings.css
index 224abbf..1678f16 100644
--- a/src/settings.css
+++ b/src/settings.css
@@ -1,19 +1,4 @@
-/* Settings window — same palette and typeface as the widget (styles.css). */
-@font-face {
- font-family: "Grotesk";
- src: url("./fonts/SpaceGrotesk-Subset.ttf") format("truetype");
-}
-
-:root {
- --bg: #000000;
- --panel: #1f1f1e;
- --text: #faf9f5;
- --dim: #b0aea5;
- --accent: #d97757;
- --green: #788c5d;
- --red: #c0392b;
- --track: #2a2a28;
-}
+@import "./theme.css";
* {
margin: 0;
@@ -27,59 +12,201 @@ html,
body {
background: var(--bg);
color: var(--text);
- font: 400 13px/1.5 Grotesk, sans-serif;
+ font: 400 13px/1.5 var(--font-ui);
height: 100%;
}
main {
- padding: 16px;
+ padding: 22px 20px;
display: flex;
flex-direction: column;
- gap: 14px;
+ gap: 12px;
+ max-width: 520px;
+ margin: 0 auto;
+}
+
+.page-header {
+ padding: 0 2px 6px;
+}
+
+.page-header h1 {
+ font-size: 22px;
+ font-weight: 600;
+ line-height: 1.3;
+ letter-spacing: -0.6px;
+}
+
+.page-header p {
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--dim);
}
section {
background: var(--panel);
- border-radius: 10px;
- padding: 12px 14px;
+ border: 1px solid var(--track);
+ border-radius: 12px;
+ padding: 16px;
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: 12px;
}
h2 {
font-size: 13px;
- font-weight: 400;
- color: var(--accent);
+ font-weight: 600;
+ color: var(--text);
}
.field {
display: flex;
flex-direction: column;
- gap: 4px;
+ gap: 6px;
}
.label {
- color: var(--dim);
+ color: var(--text);
font-size: 12px;
}
.options {
display: flex;
flex-wrap: wrap;
- gap: 4px;
+ gap: 6px;
+}
+
+#opt-layout {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+#opt-layout button {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 3px;
+ min-width: 0;
+ padding: 4px 2px;
+ font-size: 11px;
+ line-height: 14px;
+}
+
+.layout-preview {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 28px;
+ width: 50px;
+ pointer-events: none;
+}
+
+.layout-mini {
+ display: grid;
+ width: 48px;
+ height: 28px;
+ padding: 3px;
+ gap: 2px;
+ background: var(--bg);
+ box-shadow: inset 0 0 0 1px var(--border);
+ border-radius: 4px;
+ grid-template-columns: 140fr 100fr;
+ grid-template-rows: repeat(2, minmax(0, 1fr));
+}
+
+.layout-display,
+.layout-tile {
+ min-width: 0;
+ min-height: 0;
+ border-radius: 1px;
+ background: var(--panel);
+}
+
+.layout-display {
+ grid-row: 1 / -1;
+}
+
+.layout-display svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+ stroke: var(--green);
+ stroke-width: 1.5;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.layout-tile {
+ display: grid;
+ place-items: center;
+}
+
+.layout-tile::after {
+ content: "";
+ width: 50%;
+ height: 2px;
+ border-radius: 1px;
+ background: var(--green);
+}
+
+.layout-mini[data-layout="mascot-right"] {
+ grid-template-columns: 100fr 140fr;
+}
+
+.layout-mini[data-layout="mascot-right"] .layout-display {
+ grid-column: 2;
+}
+
+.layout-mini[data-layout="mascot-top"],
+.layout-mini[data-layout="mascot-bottom"] {
+ width: 28px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-rows: 140fr 65fr;
+}
+
+.layout-mini[data-layout="mascot-top"] .layout-display {
+ grid-column: 1 / -1;
+ grid-row: 1;
+}
+
+.layout-mini[data-layout="mascot-bottom"] {
+ grid-template-rows: 65fr 140fr;
+}
+
+.layout-mini[data-layout="mascot-bottom"] .layout-display {
+ grid-column: 1 / -1;
+ grid-row: 2;
+}
+
+.layout-mini[data-layout="tiles-row"] {
+ height: 18px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-rows: 1fr;
+}
+
+.layout-mini[data-layout="tiles-column"] {
+ width: 22px;
+ grid-template-columns: 1fr;
+}
+
+#opt-size,
+#opt-tray {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: 1fr;
}
.options button,
.actions button {
- border: 1px solid var(--track);
- border-radius: 6px;
- background: transparent;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ background: var(--bg);
color: var(--dim);
font: inherit;
font-size: 12px;
- padding: 4px 10px;
+ min-height: 30px;
+ padding: 5px 10px;
cursor: pointer;
+ transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.options button:hover,
@@ -96,12 +223,12 @@ h2 {
.actions .primary {
background: var(--accent);
border-color: var(--accent);
- color: var(--bg);
+ color: var(--on-accent);
}
.actions .primary:hover:enabled {
background: var(--accent);
- color: var(--bg);
+ color: var(--on-accent);
filter: brightness(1.12);
}
@@ -112,18 +239,91 @@ h2 {
}
.options button.selected {
- border-color: var(--accent);
+ border-color: var(--accent-border);
+ background: var(--accent-soft);
color: var(--accent);
}
+#opt-theme {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
+#opt-theme button {
+ padding: 6px;
+ text-align: left;
+ min-width: 0;
+}
+
+.theme-preview {
+ display: flex;
+ align-items: stretch;
+ gap: 6px;
+ height: 50px;
+ padding: 6px;
+ border-radius: 4px;
+ background: var(--bg);
+ box-shadow: inset 0 0 0 1px var(--border);
+ pointer-events: none;
+}
+
+.theme-preview svg,
+.preview-value {
+ background: var(--panel);
+ border-radius: 3px;
+ min-width: 0;
+ flex: 1;
+}
+
+.preview-grid {
+ stroke: var(--track);
+}
+
+.preview-line {
+ stroke: var(--green);
+ stroke-width: 2;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+.preview-value {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--green);
+ font: 400 20px/1 Grotesk, sans-serif;
+}
+
+.preview-value span {
+ font-size: 13px;
+}
+
+.theme-name {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 4px;
+ padding: 6px 2px 0;
+}
+
+.theme-default {
+ font-size: 10px;
+ color: var(--dim);
+}
+
.check {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
+ min-height: 24px;
}
.check input {
+ width: 15px;
+ height: 15px;
+ flex-shrink: 0;
accent-color: var(--accent);
cursor: pointer;
}
@@ -134,7 +334,8 @@ h2 {
}
.hint.indent {
- margin-left: 21px;
+ margin-left: 23px;
+ margin-top: -8px;
}
/* ---- update card: a ring says where the updater is — a dash orbits while
@@ -238,10 +439,9 @@ h2 {
transition: stroke-dashoffset 0.25s linear;
}
-/* available: the ring completes, the arrow lights up, the whole thing breathes */
+/* available: the ring completes and the arrow lights up. */
.update[data-phase="available"] .arc {
stroke-dashoffset: 0;
- filter: drop-shadow(0 0 3px color-mix(in srgb, var(--accent) 60%, transparent));
}
.update[data-phase="available"] .arrow {
stroke: var(--accent);
@@ -249,9 +449,6 @@ h2 {
opacity: 1;
transition-delay: 0.25s;
}
-.update[data-phase="available"] .ring {
- animation: breathe 3s ease-in-out infinite;
-}
/* up to date / failed: the outcome draws itself in */
.update[data-phase="up-to-date"] .arc,
@@ -279,12 +476,6 @@ h2 {
}
}
-@keyframes breathe {
- 50% {
- transform: scale(1.06);
- }
-}
-
@keyframes rise {
from {
opacity: 0;
@@ -299,6 +490,7 @@ h2 {
.update .title {
font-size: 13px;
+ font-weight: 500;
}
/* Stacked and stretched, so a second action sits centred under the first. */
@@ -361,26 +553,74 @@ h2 {
.limits {
display: flex;
flex-wrap: wrap;
- gap: 4px;
+ gap: 6px;
+}
+
+.days {
+ display: grid;
+ grid-template-columns: repeat(7, minmax(0, 1fr));
}
.days label,
.limits label {
- border: 1px solid var(--track);
- border-radius: 6px;
+ position: relative;
+ border: 1px solid var(--border);
+ border-radius: 7px;
+ background: var(--bg);
color: var(--dim);
font-size: 12px;
- padding: 4px 8px;
+ padding: 5px 8px;
+ min-height: 30px;
+ text-align: center;
cursor: pointer;
}
+.days label {
+ padding-inline: 0;
+ font-size: 11px;
+}
+
+.days label:hover,
+.limits label:hover {
+ background: var(--track);
+ color: var(--text);
+}
+
.days label.on,
.limits label.on {
- border-color: var(--accent);
+ border-color: var(--accent-border);
+ background: var(--accent-soft);
color: var(--accent);
}
+/* Keep chip inputs keyboard accessible without showing a second control. */
.days input,
.limits input {
- display: none;
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+}
+
+.days label:focus-within,
+.limits label:focus-within {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.update .hint {
+ overflow-wrap: anywhere;
+}
+
+@media (max-width: 360px) {
+ main {
+ padding: 16px 12px;
+ }
+ .update {
+ grid-template-areas: "ring text" "actions actions" "notes notes";
+ grid-template-columns: auto 1fr;
+ }
+ .update .actions {
+ margin-top: 12px;
+ }
}
diff --git a/src/settings.ts b/src/settings.ts
index 84e54ec..26e2d4e 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -7,6 +7,9 @@ import { getVersion } from "@tauri-apps/api/app";
import { getCurrentWindow } from "@tauri-apps/api/window";
import * as api from "./api";
import { parseReleaseNotes } from "./release-notes";
+import { applyTheme, restoreTheme, THEMES } from "./theme";
+
+restoreTheme();
const LAYOUTS: [api.Layout, string][] = [
["mascot-left", "Display left"],
@@ -52,12 +55,16 @@ function optionGroup(
for (const [value, label] of options) {
const btn = document.createElement("button");
btn.textContent = label;
+ btn.setAttribute("aria-pressed", "false");
btn.addEventListener("click", () => pick(value));
buttons.set(value, btn);
container.appendChild(btn);
}
return (selected) => {
- for (const [value, btn] of buttons) btn.classList.toggle("selected", value === selected);
+ for (const [value, btn] of buttons) {
+ btn.classList.toggle("selected", value === selected);
+ btn.setAttribute("aria-pressed", String(value === selected));
+ }
};
}
@@ -65,6 +72,57 @@ const markLayout = optionGroup("opt-layout", LAYOUTS, (l) => void api.setLayout(
const markSize = optionGroup("opt-size", SIZES, (s) => void api.setSize(s));
const markMascot = optionGroup("opt-mascot", MASCOTS, (m) => void api.setMascot(m));
const markTray = optionGroup("opt-tray", TRAY_STYLES, (t) => void api.setTrayStyle(t));
+const markTheme = optionGroup("opt-theme", THEMES, (t) => void api.setTheme(t));
+
+// Compact diagrams show the display and usage tiles in their actual positions.
+for (const [i, [layout, name]] of LAYOUTS.entries()) {
+ const button = document.getElementById("opt-layout")!.children[i];
+ const preview = document.createElement("span");
+ preview.className = "layout-preview";
+ preview.setAttribute("aria-hidden", "true");
+ const miniature = document.createElement("span");
+ miniature.className = "layout-mini";
+ miniature.dataset.layout = layout;
+ if (!layout.startsWith("tiles-")) {
+ miniature.innerHTML = ``;
+ }
+ for (let tile = 0; tile < 2; tile++) {
+ const block = document.createElement("span");
+ block.className = "layout-tile";
+ miniature.appendChild(block);
+ }
+ preview.appendChild(miniature);
+ const label = document.createElement("span");
+ label.textContent = name.replace("Tiles only (wide)", "Tiles wide").replace("Tiles only (tall)", "Tiles tall");
+ button.setAttribute("aria-label", name);
+ button.setAttribute("title", name);
+ button.replaceChildren(preview, label);
+}
+
+// Each preview uses the real palette tokens, independent of the selected theme.
+for (const [i, [theme, name]] of THEMES.entries()) {
+ const button = document.getElementById("opt-theme")!.children[i];
+ const preview = document.createElement("span");
+ preview.className = "theme-preview";
+ preview.dataset.theme = theme;
+ preview.setAttribute("aria-hidden", "true");
+ preview.innerHTML = `42%`;
+ const label = document.createElement("span");
+ label.className = "theme-name";
+ label.textContent = name;
+ if (theme === "charcoal") {
+ const badge = document.createElement("span");
+ badge.className = "theme-default";
+ badge.textContent = "Default";
+ label.appendChild(badge);
+ }
+ button.replaceChildren(preview, label);
+}
const sizeHint = document.getElementById("size-hint")!;
const pinBox = document.getElementById("pin") as HTMLInputElement;
@@ -143,6 +201,8 @@ function renderLimits() {
}
function render(prefs: api.Preferences) {
+ applyTheme(prefs.theme);
+ markTheme(prefs.theme);
markLayout(prefs.layout);
// A free-resized widget matches no preset; say what it is instead.
markSize(prefs.customScale === null ? prefs.size : null);
diff --git a/src/styles.css b/src/styles.css
index fb32c12..0884251 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1,20 +1,6 @@
-/* Font: open-licensed (SIL OFL 1.1). See src/fonts/. */
-@font-face {
- /* Space Grotesk, instanced at wght=400 and subset to Basic Latin. */
- font-family: "Grotesk";
- src: url("./fonts/SpaceGrotesk-Subset.ttf") format("truetype");
-}
+@import "./theme.css";
:root {
- --bg: #000000;
- --panel: #1f1f1e;
- --text: #faf9f5;
- --dim: #b0aea5;
- --accent: #d97757;
- --green: #788c5d;
- --amber: #d97757;
- --red: #c0392b;
- --track: #2a2a28;
/* min(window-width / layout design-width, 1), set from JS. Chrome —
* radii, graph furniture — follows small widgets down but stops growing
* past design size, so a large widget grows its content, not its bezels. */
@@ -52,7 +38,8 @@ body {
right: 0;
bottom: 0;
background: var(--bg);
- border-radius: 16px;
+ border-radius: clamp(10px, calc(16px * var(--chrome)), 16px);
+ box-shadow: inset 0 0 0 1px var(--border);
overflow: hidden;
}
@@ -71,7 +58,8 @@ body {
#mascot-chip {
position: relative;
background: var(--panel);
- border-radius: calc(8px * var(--chrome));
+ border-radius: calc(10px * var(--chrome));
+ box-shadow: inset 0 0 0 1px var(--tile-border);
cursor: pointer;
/* The canvas's transferred intrinsic size must not inflate the chip's
* automatic minimum, or wide chips (mascot-top/bottom) crush the tiles. */
@@ -101,13 +89,17 @@ body {
/* ---- usage tiles: big percentage over a short reset countdown ---- */
.panel {
background: var(--panel);
- border-radius: calc(8px * var(--chrome));
+ border-radius: calc(10px * var(--chrome));
+ box-shadow: inset 0 0 0 1px var(--tile-border);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
/* Size container: the tile drives its type sizes (cqh/cqw in .pct/.reset). */
container-type: size;
+ min-width: 0;
+ min-height: 0;
+ font-variant-numeric: tabular-nums;
}
/* ---- layouts ----
@@ -180,15 +172,15 @@ body {
display: none;
}
-/* Type tracks the tile: the cqh terms are 32px/14px/11px at the 65px design
+/* Type tracks the tile: the cqh terms are 30px/14px/11px at the 65px design
* tile height, and the cqw terms take over on a tile narrower than the design
* aspect — .pct's so "100%" never spills out, .reset's so the countdown keeps
* pace with the label there. The three lines fit at every aspect: the widest
- * tile is the worst case, where all three cqh terms bind — they total 87.6, or
- * 96% of the height once line-height is counted. */
+ * tile is the worst case, where all three cqh terms bind — they total 84.1, or
+ * 93% of the height once line-height is counted. */
.pct {
font-family: Grotesk, sans-serif;
- font-size: min(49.5cqh, 34cqw);
+ font-size: min(46cqh, 34cqw);
line-height: 1.1;
color: var(--dim);
}
@@ -216,7 +208,7 @@ body {
font-size: 16.6cqmin;
line-height: 1.1;
color: var(--dim);
- opacity: 0.75;
+ opacity: 1;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
@@ -318,15 +310,15 @@ body {
justify-content: center;
border: none;
border-radius: 4px;
- background: rgba(31, 31, 30, 0.85);
- color: var(--accent);
+ background: var(--panel);
+ color: var(--dim);
cursor: grab;
opacity: 0;
transition: opacity 0.15s;
z-index: 10;
- outline: none;
}
-body:hover #drag-handle {
+body:hover #drag-handle,
+body:focus-within #drag-handle {
opacity: 1;
}
#drag-handle:active {
@@ -346,7 +338,8 @@ body:hover #drag-handle {
transition: opacity 0.15s;
z-index: 10;
}
-body:hover #controls {
+body:hover #controls,
+body:focus-within #controls {
opacity: 1;
}
#controls button {
@@ -357,9 +350,11 @@ body:hover #controls {
justify-content: center;
border: none;
border-radius: 6px;
- background: rgba(31, 31, 30, 0.85);
+ background: var(--panel);
+ box-shadow: inset 0 0 0 1px var(--border);
color: var(--dim);
cursor: pointer;
+ transition: background 0.15s, color 0.15s;
}
#drag-handle svg {
width: 14px;
@@ -412,9 +407,9 @@ body:hover #controls {
opacity: 0;
transition: opacity 0.15s;
z-index: 10;
- outline: none;
}
-body:hover #resize-handle {
+body:hover #resize-handle,
+body:focus-within #resize-handle {
opacity: 1;
}
#resize-handle svg {
@@ -479,7 +474,7 @@ body:hover #resize-handle {
height: 16px;
padding: 0 6px;
border-radius: 8px;
- background: rgba(31, 31, 30, 0.85);
+ background: var(--panel);
color: var(--dim);
font: 400 9px/16px Grotesk, sans-serif;
white-space: nowrap;
diff --git a/src/theme.css b/src/theme.css
new file mode 100644
index 0000000..0a84887
--- /dev/null
+++ b/src/theme.css
@@ -0,0 +1,93 @@
+/* Shared surfaces and colours keep both windows feeling like one app. */
+@font-face {
+ font-family: "Grotesk";
+ src: url("./fonts/SpaceGrotesk-Subset.ttf") format("truetype");
+ font-display: swap;
+}
+
+:root,
+[data-theme="charcoal"] {
+ color-scheme: dark;
+ --bg: #141516;
+ --panel: #202223;
+ --text: #f1f0ed;
+ --dim: #a9adaa;
+ --accent: #e2a18a;
+ --accent-soft: #342b28;
+ --accent-border: #806359;
+ --green: #a3bc90;
+ --amber: #e3b078;
+ --red: #e68c83;
+ --track: #2e3232;
+ --border: #343838;
+ --tile-border: #ffffff05;
+ --on-accent: #141516;
+ --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+/* The original black-and-warm-grey palette, with clearer status colours. */
+[data-theme="midnight"] {
+ color-scheme: dark;
+ --bg: #050505;
+ --panel: #1c1c1b;
+ --text: #faf9f5;
+ --dim: #aeada5;
+ --accent: #d98c70;
+ --accent-soft: #30231e;
+ --accent-border: #805744;
+ --green: #9aaf7d;
+ --amber: #dca068;
+ --red: #e17d72;
+ --track: #2b2b28;
+ --border: #33332f;
+ --tile-border: #ffffff05;
+ --on-accent: #050505;
+}
+
+[data-theme="paper"] {
+ color-scheme: light;
+ --bg: #f0ece4;
+ --panel: #fffcf6;
+ --text: #35332d;
+ --dim: #736d61;
+ --accent: #985138;
+ --accent-soft: #f5e4da;
+ --accent-border: #c19580;
+ --green: #4c713e;
+ --amber: #946217;
+ --red: #b1433a;
+ --track: #e7e1d6;
+ --border: #d6cebf;
+ --tile-border: #51432812;
+ --on-accent: #ffffff;
+}
+
+[data-theme="mist"] {
+ color-scheme: light;
+ --bg: #e9eff4;
+ --panel: #f9fbfd;
+ --text: #293744;
+ --dim: #637381;
+ --accent: #356487;
+ --accent-soft: #dfebf4;
+ --accent-border: #8faec4;
+ --green: #34725c;
+ --amber: #95601e;
+ --red: #b33f50;
+ --track: #e0e7ed;
+ --border: #cbd6df;
+ --tile-border: #274b6a12;
+ --on-accent: #ffffff;
+}
+
+button:focus-visible,
+input:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ transition: none !important;
+ }
+}
diff --git a/src/theme.test.ts b/src/theme.test.ts
new file mode 100644
index 0000000..7f184cd
--- /dev/null
+++ b/src/theme.test.ts
@@ -0,0 +1,47 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { applyTheme, restoreTheme } from "./theme";
+
+describe("theme first paint", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ delete document.documentElement.dataset.theme;
+ });
+
+ afterEach(() => vi.restoreAllMocks());
+
+ it("uses Charcoal when there is no cached preference or the cached id is retired", () => {
+ restoreTheme();
+ expect(document.documentElement.dataset.theme).toBe("charcoal");
+ localStorage.setItem("appearance-theme", "retired-theme");
+ restoreTheme();
+ expect(document.documentElement.dataset.theme).toBe("charcoal");
+ });
+
+ it("restores a light theme before the backend responds", () => {
+ applyTheme("paper");
+ delete document.documentElement.dataset.theme;
+ restoreTheme();
+ expect(document.documentElement.dataset.theme).toBe("paper");
+ });
+
+ it("lets the backend preference replace a stale first-paint cache", () => {
+ localStorage.setItem("appearance-theme", "midnight");
+ restoreTheme();
+ applyTheme("mist");
+ expect(document.documentElement.dataset.theme).toBe("mist");
+ expect(localStorage.getItem("appearance-theme")).toBe("mist");
+ });
+
+ it("still applies backend preferences when browser storage is unavailable", () => {
+ vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
+ throw new Error("Storage unavailable");
+ });
+ vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
+ throw new Error("Storage unavailable");
+ });
+ restoreTheme();
+ expect(document.documentElement.dataset.theme).toBe("charcoal");
+ applyTheme("paper");
+ expect(document.documentElement.dataset.theme).toBe("paper");
+ });
+});
diff --git a/src/theme.ts b/src/theme.ts
new file mode 100644
index 0000000..316a884
--- /dev/null
+++ b/src/theme.ts
@@ -0,0 +1,30 @@
+import type { Theme } from "./api";
+
+export const THEMES: [Theme, string][] = [
+ ["charcoal", "Charcoal"],
+ ["midnight", "Midnight"],
+ ["paper", "Paper"],
+ ["mist", "Mist"],
+];
+
+const CACHE_KEY = "appearance-theme";
+
+/** The backend is authoritative; this cache supplies the colour before IPC returns. */
+export function applyTheme(theme: Theme): void {
+ document.documentElement.dataset.theme = theme;
+ try {
+ localStorage.setItem(CACHE_KEY, theme);
+ } catch {
+ // Storage can be unavailable; the persisted backend preference still works.
+ }
+}
+
+export function restoreTheme(): void {
+ let cached: string | null = null;
+ try {
+ cached = localStorage.getItem(CACHE_KEY);
+ } catch {
+ // First paint uses Charcoal until the backend supplies the preference.
+ }
+ applyTheme(THEMES.find(([id]) => id === cached)?.[0] ?? "charcoal");
+}
Widget
Tray icon
-
+
Ring shows a colour-coded gauge; Text shows the 5h percentage.