diff --git a/README.md b/README.md index 75f3740..54de92e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ you don't want it. - **Six layouts** — mascot/graph beside, above, or below the tiles, or tiles only — all in a proper settings window. +- **Four themes** — Charcoal (default), Midnight, warm Paper, and cool Mist. + Choose one in Settings → Appearance; it applies to both windows and is + remembered between launches. ## Showcase diff --git a/settings.html b/settings.html index efbc915..2c4726d 100644 --- a/settings.html +++ b/settings.html @@ -7,20 +7,28 @@
+ +
+

Appearance

+
+

Widget

Layout -
+
Size -
+

Or drag the widget's bottom-right grip to any size.

Mascot -
+
Limits @@ -33,7 +41,7 @@

Widget

Tray icon

-
+

Ring shows a colour-coded gauge; Text shows the 5h percentage.

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"); +}