diff --git a/index.html b/index.html
index a464349..9ed1987 100644
--- a/index.html
+++ b/index.html
@@ -9,6 +9,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PixelVault
Copy an image — paste a hosted URL.
@@ -28,9 +57,6 @@ PixelVault
⌘V to paste it into your coding agent.
-
- pixelvault.dev
-
Free & open source · by
PixelVault
diff --git a/src-tauri/src/auth.rs b/src-tauri/src/auth.rs
index adb17ef..99bd0bf 100644
--- a/src-tauri/src/auth.rs
+++ b/src-tauri/src/auth.rs
@@ -2,6 +2,10 @@
//!
//! Flow: `device_start(email)` emails a 6-digit code; `device_complete(email,
//! code)` exchanges it for an app-scoped API key, stored in the OS keychain.
+//!
+//! Keychain access **fails closed**: a real access error propagates as `Err`
+//! rather than being flattened to "signed out", so a transient keychain failure
+//! can't silently downgrade a signed-in user to anonymous uploads.
use std::time::Duration;
@@ -11,18 +15,49 @@ use crate::config::api_base;
const KEYRING_SERVICE: &str = "dev.pixelvault.desktop";
+/// A signed-in session. Both fields are always present together.
+#[derive(Clone)]
+pub struct Session {
+ pub email: String,
+ pub api_key: String,
+}
+
fn entry(user: &str) -> Result {
keyring::Entry::new(KEYRING_SERVICE, user).map_err(|e| e.to_string())
}
-/// The stored API key, if signed in.
-pub fn stored_key() -> Option {
- entry("api_key").ok().and_then(|e| e.get_password().ok())
+/// Read one keychain item, distinguishing "absent" (`Ok(None)`) from a real
+/// access error (`Err`).
+fn read(user: &str) -> Result, String> {
+ match entry(user)?.get_password() {
+ Ok(v) => Ok(Some(v)),
+ Err(keyring::Error::NoEntry) => Ok(None),
+ Err(e) => Err(e.to_string()),
+ }
}
-/// The signed-in email, if any.
-pub fn stored_email() -> Option {
- entry("email").ok().and_then(|e| e.get_password().ok())
+/// Delete one keychain item. "Not found" counts as success.
+fn delete(user: &str) -> Result<(), String> {
+ match entry(user)?.delete_credential() {
+ Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
+ Err(e) => Err(e.to_string()),
+ }
+}
+
+/// Load the persisted session. Requires BOTH the key and the email; a real
+/// access error propagates (fail closed). A half-written state (only one
+/// present) is cleared and treated as signed out.
+pub fn load_session() -> Result, String> {
+ let api_key = read("api_key")?;
+ let email = read("email")?;
+ match (api_key, email) {
+ (Some(api_key), Some(email)) => Ok(Some(Session { email, api_key })),
+ (None, None) => Ok(None),
+ _ => {
+ let _ = sign_out();
+ Ok(None)
+ }
+ }
}
fn store(api_key: &str, email: &str) -> Result<(), String> {
@@ -35,14 +70,12 @@ fn store(api_key: &str, email: &str) -> Result<(), String> {
Ok(())
}
+/// Delete the stored session. Surfaces any real delete error so we never claim
+/// a sign-out that actually left the API key behind. Attempts both regardless.
pub fn sign_out() -> Result<(), String> {
- if let Ok(e) = entry("api_key") {
- let _ = e.delete_credential();
- }
- if let Ok(e) = entry("email") {
- let _ = e.delete_credential();
- }
- Ok(())
+ let a = delete("api_key");
+ let b = delete("email");
+ a.and(b)
}
fn client() -> Result {
@@ -93,8 +126,9 @@ struct CompleteData {
email: String,
}
-/// Exchange `code` for an API key and store it. Returns the signed-in email.
-pub fn device_complete(email: &str, code: &str) -> Result {
+/// Exchange `code` for an API key, store it in the keychain, and return the
+/// resulting session so the caller can cache it in memory.
+pub fn device_complete(email: &str, code: &str) -> Result {
let resp = client()?
.post(format!("{}/v1/auth/device/complete", api_base()))
.json(&serde_json::json!({ "email": email, "code": code }))
@@ -107,5 +141,8 @@ pub fn device_complete(email: &str, code: &str) -> Result {
}
let env: CompleteEnvelope = resp.json().map_err(|e| format!("bad response: {e}"))?;
store(&env.data.api_key, &env.data.email)?;
- Ok(env.data.email)
+ Ok(Session {
+ email: env.data.email,
+ api_key: env.data.api_key,
+ })
}
diff --git a/src-tauri/src/capture.rs b/src-tauri/src/capture.rs
index da473a4..3eee37a 100644
--- a/src-tauri/src/capture.rs
+++ b/src-tauri/src/capture.rs
@@ -50,11 +50,12 @@ pub fn trigger(app: AppHandle) {
let _ = std::fs::remove_file(&path);
match crate::upload_and_notify(&app, bytes) {
- Ok(url) => {
+ Ok(Some(url)) => {
if let Ok(mut clipboard) = arboard::Clipboard::new() {
let _ = clipboard.set_text(url);
}
}
+ Ok(None) => {} // gated (free trial used up) — sign-in was prompted
Err(e) => crate::notify(&app, "Upload failed", &e),
}
});
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 5618757..8be4f29 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -38,10 +38,47 @@ pub struct AppState {
pub account_item: Mutex>>,
/// The tray icon, so we can flash a busy title while uploading.
pub tray_icon: Mutex >>,
+ /// Cached signed-in session. Loaded once from the keychain at startup and
+ /// updated on sign-in/out, so uploads never hit the keychain (which could
+ /// transiently fail and silently downgrade a signed-in user to anonymous).
+ pub session: Mutex >,
/// Whether the passive clipboard watcher is active.
pub watching: AtomicBool,
}
+/// The cached signed-in API key, if any.
+fn current_key(app: &AppHandle) -> Option {
+ app.state::()
+ .session
+ .lock()
+ .ok()
+ .and_then(|g| g.as_ref().map(|s| s.api_key.clone()))
+}
+
+/// Whether a session is currently cached.
+pub fn is_signed_in(app: &AppHandle) -> bool {
+ app.state::()
+ .session
+ .lock()
+ .map(|g| g.is_some())
+ .unwrap_or(false)
+}
+
+/// Clear the session (revoked/expired key) and prompt re-sign-in.
+fn handle_unauthorized(app: &AppHandle) {
+ let _ = auth::sign_out();
+ if let Ok(mut g) = app.state::().session.lock() {
+ *g = None;
+ }
+ refresh_account(app);
+ notify(
+ app,
+ "Session expired",
+ "Please sign in again (Account & Settings).",
+ );
+ open_settings(app);
+}
+
/// Show a native notification. Safe to call from any thread.
pub fn notify(app: &AppHandle, title: &str, body: &str) {
use tauri_plugin_notification::NotificationExt;
@@ -57,49 +94,80 @@ pub fn notify(app: &AppHandle, title: &str, body: &str) {
const EPHEMERAL_SECS: u64 = 30 * 24 * 60 * 60;
/// Shared Mode A/B pipeline: upload PNG bytes → record + refresh tray → notify.
-/// Returns the hosted URL. Does NOT touch the clipboard — the caller places it.
-pub fn upload_and_notify(app: &AppHandle, png_bytes: Vec) -> Result {
+/// Returns `Some(url)` on upload (caller places it on the clipboard), or `None`
+/// when the free trial is exhausted and the upload was gated (sign-in prompted).
+pub fn upload_and_notify(app: &AppHandle, png_bytes: Vec) -> Result, String> {
set_busy(app, true);
let result = run_upload(app, png_bytes);
set_busy(app, false);
result
}
-fn run_upload(app: &AppHandle, png_bytes: Vec) -> Result {
- match auth::stored_key() {
+fn run_upload(app: &AppHandle, png_bytes: Vec) -> Result, String> {
+ match current_key(app) {
// Signed in → keyed, ephemeral (30d) upload; not part of the free trial.
- Some(key) => {
- let url = upload::upload_png(png_bytes, Some(&key), Some(EPHEMERAL_SECS))?;
- app.state::().trial.push_recent(&url);
- refresh_recent(app);
- notify(app, "Image URL copied", &url);
- Ok(url)
- }
- // Signed out → anonymous trial upload. Crossing the free limit only
- // nudges (the real ceiling is the server-side anonymous rate limiter).
+ Some(key) => match upload::upload_png(png_bytes, Some(&key), Some(EPHEMERAL_SECS)) {
+ Ok(url) => {
+ app.state::().trial.push_recent(&url);
+ refresh_recent(app);
+ notify(app, "Image URL copied", &url);
+ Ok(Some(url))
+ }
+ // Revoked/expired key — clear the session and prompt re-sign-in.
+ Err(upload::UploadError::Unauthorized) => {
+ handle_unauthorized(app);
+ Ok(None)
+ }
+ Err(e) => Err(e.message()),
+ },
+ // Signed out → anonymous trial. Reserve a slot atomically; if the free
+ // limit is reached, HARD-gate: stop and prompt sign-in (a trial that
+ // never blocks converts no one). Bypassable by design (client-side).
None => {
- if app.state::().trial.remaining() == 0 {
+ if !app.state::().trial.try_reserve() {
notify(
app,
- "Free uploads used up",
- "You've used your 5 free uploads. Sign in (Settings) to keep going — still uploading for now.",
+ "Sign in to keep uploading",
+ "You've used your 5 free uploads. Sign in (Account & Settings) for unlimited uploads + history.",
);
+ open_settings(app);
+ return Ok(None);
+ }
+ match upload::upload_png(png_bytes, None, None) {
+ Ok(url) => {
+ app.state::().trial.commit_reserved(&url);
+ refresh_counter(app);
+ refresh_recent(app);
+ let remaining = app.state::().trial.remaining();
+ notify(
+ app,
+ "Image URL copied",
+ &format!("{url}\n{remaining} of {} free uploads left", state::FREE_UPLOAD_LIMIT),
+ );
+ Ok(Some(url))
+ }
+ Err(e) => {
+ // Upload failed — release the reserved slot so it isn't burned.
+ app.state::().trial.release();
+ Err(e.message())
+ }
}
- let url = upload::upload_png(png_bytes, None, None)?;
- app.state::().trial.record_upload(&url);
- refresh_counter(app);
- refresh_recent(app);
- let remaining = app.state::().trial.remaining();
- notify(
- app,
- "Image URL copied",
- &format!("{url}\n{remaining} of {} free uploads left", state::FREE_UPLOAD_LIMIT),
- );
- Ok(url)
}
}
}
+/// Show + focus the settings/account window. Window ops run on the main thread
+/// (this is called from the background watcher/capture threads on the gate path).
+fn open_settings(app: &AppHandle) {
+ let app = app.clone();
+ let _ = app.clone().run_on_main_thread(move || {
+ if let Some(w) = app.get_webview_window("main") {
+ let _ = w.show();
+ let _ = w.set_focus();
+ }
+ });
+}
+
/// Flash the tray title while an upload is in flight (an always-visible "busy"
/// cue in the menu bar). Menu/tray mutations run on the main thread (AppKit).
fn set_busy(app: &AppHandle, busy: bool) {
@@ -130,8 +198,14 @@ pub fn refresh_counter(app: &AppHandle) {
}
}
-/// Refresh the tray account status item from the keychain.
+/// Refresh the tray account status item from the cached session.
pub fn refresh_account(app: &AppHandle) {
+ let email = app
+ .state::()
+ .session
+ .lock()
+ .ok()
+ .and_then(|g| g.as_ref().map(|s| s.email.clone()));
let item = app
.state::()
.account_item
@@ -139,7 +213,7 @@ pub fn refresh_account(app: &AppHandle) {
.ok()
.and_then(|g| g.clone());
if let Some(item) = item {
- let text = match auth::stored_email() {
+ let text = match email {
Some(email) => format!("Signed in as {email}"),
None => "Not signed in".to_string(),
};
@@ -205,9 +279,13 @@ fn sign_in_start(email: String) -> Result<(), String> {
#[tauri::command]
fn sign_in_complete(app: AppHandle, email: String, code: String) -> Result {
- let signed_in = auth::device_complete(email.trim(), code.trim())?;
+ let session = auth::device_complete(email.trim(), code.trim())?;
+ let email = session.email.clone();
+ if let Ok(mut g) = app.state::().session.lock() {
+ *g = Some(session);
+ }
refresh_account(&app);
- Ok(signed_in)
+ Ok(email)
}
#[derive(serde::Serialize)]
@@ -219,7 +297,12 @@ struct AuthStatus {
#[tauri::command]
fn auth_status(app: AppHandle) -> AuthStatus {
- let email = auth::stored_email();
+ let email = app
+ .state::()
+ .session
+ .lock()
+ .ok()
+ .and_then(|g| g.as_ref().map(|s| s.email.clone()));
AuthStatus {
signed_in: email.is_some(),
email,
@@ -230,6 +313,9 @@ fn auth_status(app: AppHandle) -> AuthStatus {
#[tauri::command]
fn sign_out(app: AppHandle) -> Result<(), String> {
auth::sign_out()?;
+ if let Ok(mut g) = app.state::().session.lock() {
+ *g = None;
+ }
refresh_account(&app);
Ok(())
}
@@ -256,6 +342,14 @@ pub fn run() {
.app_config_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("."));
+ // Load the session once at startup. A keychain access error → treat
+ // as signed out (log it) rather than crashing; uploads then use the
+ // anonymous trial instead of a broken key.
+ let session = auth::load_session().unwrap_or_else(|e| {
+ eprintln!("[pixelvault] keychain read failed at startup: {e}");
+ None
+ });
+
app.manage(AppState {
trial: state::TrialState::load(config_dir),
counter_item: Mutex::new(None),
@@ -263,6 +357,7 @@ pub fn run() {
recent_items: Mutex::new(Vec::new()),
account_item: Mutex::new(None),
tray_icon: Mutex::new(None),
+ session: Mutex::new(session),
watching: AtomicBool::new(true),
});
diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs
index 891f0ac..7e66790 100644
--- a/src-tauri/src/state.rs
+++ b/src-tauri/src/state.rs
@@ -57,12 +57,49 @@ impl TrialState {
self.recent.lock().map(|r| r.clone()).unwrap_or_default()
}
- /// Record an anonymous upload: bump the trial counter and push the URL to
- /// the recent list. Persists both.
- pub fn record_upload(&self, url: &str) {
- let uploads = self.uploads.fetch_add(1, Ordering::Relaxed) + 1;
+ /// Atomically reserve one anonymous-trial slot. Returns `true` if a slot was
+ /// available (counter incremented), `false` if the free limit is reached.
+ /// A compare-and-swap loop makes admission atomic across the watcher and
+ /// hotkey-capture threads, so two concurrent uploads can't both slip past
+ /// the last free slot.
+ pub fn try_reserve(&self) -> bool {
+ let mut current = self.uploads.load(Ordering::Relaxed);
+ loop {
+ if current >= FREE_UPLOAD_LIMIT {
+ return false;
+ }
+ match self.uploads.compare_exchange_weak(
+ current,
+ current + 1,
+ Ordering::SeqCst,
+ Ordering::Relaxed,
+ ) {
+ Ok(_) => return true,
+ Err(actual) => current = actual,
+ }
+ }
+ }
+
+ /// Release a reserved slot (upload failed after `try_reserve`).
+ pub fn release(&self) {
+ loop {
+ let current = self.uploads.load(Ordering::Relaxed);
+ let next = current.saturating_sub(1);
+ if self
+ .uploads
+ .compare_exchange_weak(current, next, Ordering::SeqCst, Ordering::Relaxed)
+ .is_ok()
+ {
+ return;
+ }
+ }
+ }
+
+ /// Commit a reserved anonymous upload: push the URL to recent and persist.
+ /// (The counter was already incremented by `try_reserve`.)
+ pub fn commit_reserved(&self, url: &str) {
let recent = self.push_recent_inner(url);
- self.persist(uploads, recent);
+ self.persist(self.uploads(), recent);
}
/// Push a URL to the recent list WITHOUT touching the trial counter — used
diff --git a/src-tauri/src/upload.rs b/src-tauri/src/upload.rs
index 74a71da..cd0998c 100644
--- a/src-tauri/src/upload.rs
+++ b/src-tauri/src/upload.rs
@@ -11,6 +11,23 @@ use serde::Deserialize;
use crate::config::api_base;
+/// Upload failure. `Unauthorized` (401/403) is separated so the caller can clear
+/// a revoked/expired key and prompt re-sign-in. Messages are user-safe (never
+/// the raw response body).
+pub enum UploadError {
+ Unauthorized,
+ Failed(String),
+}
+
+impl UploadError {
+ pub fn message(&self) -> String {
+ match self {
+ UploadError::Unauthorized => "Your session has expired.".to_string(),
+ UploadError::Failed(m) => m.clone(),
+ }
+ }
+}
+
#[derive(Deserialize)]
struct UploadEnvelope {
data: UploadData,
@@ -41,16 +58,16 @@ pub fn upload_png(
png: Vec,
api_key: Option<&str>,
expires_in: Option,
-) -> Result {
+) -> Result {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(30))
.build()
- .map_err(|e| e.to_string())?;
+ .map_err(|e| UploadError::Failed(e.to_string()))?;
let part = reqwest::blocking::multipart::Part::bytes(png)
.file_name("clipboard.png")
.mime_str("image/png")
- .map_err(|e| e.to_string())?;
+ .map_err(|e| UploadError::Failed(e.to_string()))?;
let mut form = reqwest::blocking::multipart::Form::new().part("file", part);
if api_key.is_some() {
if let Some(secs) = expires_in {
@@ -63,13 +80,23 @@ pub fn upload_png(
req = req.bearer_auth(key);
}
- let resp = req.send().map_err(|e| e.to_string())?;
+ let resp = req.send().map_err(|e| UploadError::Failed(e.to_string()))?;
let status = resp.status();
if !status.is_success() {
- let body = resp.text().unwrap_or_default();
- return Err(format!("upload failed ({status}): {body}"));
+ if status.as_u16() == 401 || status.as_u16() == 403 {
+ return Err(UploadError::Unauthorized);
+ }
+ // Friendly, body-free messages for the common cases.
+ let msg = match status.as_u16() {
+ 413 => "Image is too large.".to_string(),
+ 429 => "Rate limit reached — try again shortly.".to_string(),
+ s => format!("Upload failed ({s})."),
+ };
+ return Err(UploadError::Failed(msg));
}
- let env: UploadEnvelope = resp.json().map_err(|e| format!("bad response: {e}"))?;
+ let env: UploadEnvelope = resp
+ .json()
+ .map_err(|e| UploadError::Failed(format!("bad response: {e}")))?;
Ok(env.data.url)
}
diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs
index 26c853e..ab37e9f 100644
--- a/src-tauri/src/watcher.rs
+++ b/src-tauri/src/watcher.rs
@@ -29,6 +29,11 @@ pub fn spawn(app: AppHandle) {
// content on every poll. (A deliberate re-copy of the *same* image
// within a session won't re-upload — acceptable for v0.)
let mut last_hash: Option = None;
+ // Hash of an image that was gated (free trial used up). We keep it in the
+ // clipboard and skip re-prompting for it while signed out — but once the
+ // user signs in, we let it through so the exact image that triggered the
+ // sign-in gets uploaded without needing a re-copy.
+ let mut gated_hash: Option = None;
loop {
std::thread::sleep(POLL_INTERVAL);
@@ -47,7 +52,11 @@ pub fn spawn(app: AppHandle) {
if Some(hash) == last_hash {
continue;
}
- last_hash = Some(hash);
+ // Previously gated this exact image and still signed out → don't
+ // re-prompt every poll. (Once signed in, fall through and upload it.)
+ if Some(hash) == gated_hash && !crate::is_signed_in(&app) {
+ continue;
+ }
let width = img.width as u32;
let height = img.height as u32;
@@ -57,18 +66,28 @@ pub fn spawn(app: AppHandle) {
Ok(p) => p,
Err(e) => {
eprintln!("[pixelvault] encode error: {e}");
+ last_hash = Some(hash);
continue;
}
};
match crate::upload_and_notify(&app, png) {
- Ok(url) => {
+ Ok(Some(url)) => {
+ last_hash = Some(hash);
+ gated_hash = None;
// Swap the URL onto the clipboard (replaces the image).
if let Err(e) = clipboard.set_text(url) {
eprintln!("[pixelvault] failed to set clipboard text: {e}");
}
}
+ // Gated (free trial used up) — sign-in was prompted. Remember the
+ // hash (don't advance last_hash) so a subsequent sign-in retries
+ // this exact image.
+ Ok(None) => {
+ gated_hash = Some(hash);
+ }
Err(e) => {
+ last_hash = Some(hash);
eprintln!("[pixelvault] upload error: {e}");
crate::notify(&app, "Upload failed", &e);
}
diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json
index 5cc9213..6adfc07 100644
--- a/src-tauri/tauri.conf.json
+++ b/src-tauri/tauri.conf.json
@@ -16,7 +16,7 @@
"label": "main",
"title": "PixelVault",
"width": 440,
- "height": 540,
+ "height": 650,
"resizable": false,
"visible": false
}
diff --git a/src/main.ts b/src/main.ts
index 08b2c4a..2f2693d 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,6 +1,7 @@
// The menubar tray (built in Rust) is the primary UI. This window is a
// status/settings surface — its main job is the sign-in flow.
import { invoke } from "@tauri-apps/api/core";
+import { getCurrentWindow } from "@tauri-apps/api/window";
interface AuthStatus {
signed_in: boolean;
@@ -116,4 +117,13 @@ function renderSignedIn(email: string): void {
window.addEventListener("DOMContentLoaded", () => {
refresh().catch((e) => console.error(e));
+
+ // The window is hidden/shown from the tray (and the hard gate), not reloaded,
+ // so DOMContentLoaded fires only once. Re-render whenever it regains focus so
+ // the counter + auth state are never stale.
+ getCurrentWindow()
+ .onFocusChanged(({ payload: focused }) => {
+ if (focused) refresh().catch((e) => console.error(e));
+ })
+ .catch((e) => console.error(e));
});
diff --git a/src/styles.css b/src/styles.css
index bd2be0b..ad7b8dd 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1,41 +1,79 @@
+/* PixelVault brand palette (mirrors apps/web design tokens). Dark, gold accent. */
:root {
+ --bg-deep: #08090c;
+ --bg-surface: #0f1117;
+ --bg-elevated: #161820;
+ --bg-card: #1a1c26;
+ --border: #252730;
+ --border-hover: #3a3d4a;
+ --text-primary: #e8e9ed;
+ --text-secondary: #8e919e;
+ --text-muted: #5c5f6e;
+ --accent: #e4a44c;
+ --accent-hover: #f0b866;
+ --btn-primary-text: #0a0a0a;
+ --red: #f87171;
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
line-height: 1.5;
- color: #111;
- background-color: #f6f6f6;
+ color: var(--text-primary);
+ background-color: var(--bg-deep);
-webkit-font-smoothing: antialiased;
+ color-scheme: dark;
+}
+
+html,
+body {
+ margin: 0;
+ background-color: var(--bg-deep);
}
.wrap {
margin: 0 auto;
- max-width: 360px;
- padding: 32px 24px;
+ max-width: 380px;
+ padding: 18px 24px;
text-align: center;
}
+.logo {
+ margin: 0 auto 8px;
+ width: 52px;
+ height: 52px;
+}
+.logo svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
h1 {
margin: 0 0 4px;
font-size: 22px;
+ color: var(--text-primary);
}
.tag {
- margin: 0 0 20px;
+ margin: 0 0 16px;
font-weight: 600;
- color: #396cd8;
+ color: var(--accent);
}
.status {
- margin: 0 0 16px;
+ margin: 0 0 12px;
text-align: left;
+ color: var(--text-secondary);
+}
+.status strong {
+ color: var(--text-primary);
}
.modes {
- margin: 0 0 16px;
+ margin: 0 0 12px;
padding-left: 20px;
text-align: left;
+ color: var(--text-secondary);
}
-
.modes li {
margin-bottom: 6px;
}
@@ -43,52 +81,47 @@ h1 {
kbd {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
- background: #fff;
- border: 1px solid #d0d0d0;
+ color: var(--text-primary);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
border-radius: 6px;
padding: 1px 6px;
- box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08);
-}
-
-.muted {
- margin: 0 0 20px;
- font-size: 13px;
- color: #666;
- text-align: left;
}
.link {
font-weight: 500;
- color: #396cd8;
+ color: var(--accent);
text-decoration: none;
}
.link:hover {
+ color: var(--accent-hover);
text-decoration: underline;
}
.foss {
- margin: 24px 0 0;
+ margin: 16px 0 0;
font-size: 12px;
- color: #888;
+ color: var(--text-muted);
}
/* Account / sign-in card */
.account {
- margin: 0 0 20px;
+ margin: 0 0 16px;
padding: 16px;
- background: #fff;
- border: 1px solid #e5e7eb;
- border-radius: 10px;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 12px;
text-align: left;
}
.acct-title {
margin: 0 0 4px;
font-weight: 600;
+ color: var(--text-primary);
}
.acct-sub {
margin: 0 0 12px;
font-size: 12px;
- color: #666;
+ color: var(--text-secondary);
}
.acct-row {
display: flex;
@@ -98,67 +131,53 @@ kbd {
.acct-row input {
flex: 1;
min-width: 0;
- padding: 8px 10px;
- border: 1px solid #d0d0d0;
+ padding: 9px 11px;
+ color: var(--text-primary);
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
border-radius: 8px;
font: inherit;
}
+.acct-row input::placeholder {
+ color: var(--text-muted);
+}
+.acct-row input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
.acct-row button,
button.secondary {
- padding: 8px 14px;
+ padding: 9px 15px;
border: 1px solid transparent;
border-radius: 8px;
- background: #396cd8;
- color: #fff;
+ background: var(--accent);
+ color: var(--btn-primary-text);
font: inherit;
font-weight: 600;
cursor: pointer;
+ transition: background 0.15s;
+}
+.acct-row button:hover {
+ background: var(--accent-hover);
}
.acct-row button:disabled {
- opacity: 0.6;
+ opacity: 0.55;
cursor: default;
}
button.secondary {
- background: #eee;
- color: #333;
+ background: var(--bg-elevated);
+ color: var(--text-primary);
+ border-color: var(--border);
+}
+button.secondary:hover {
+ border-color: var(--border-hover);
}
.acct-msg {
margin: 8px 0 0;
font-size: 12px;
- color: #666;
+ color: var(--text-secondary);
min-height: 1em;
}
.acct-msg.error {
- color: #c0392b;
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- color: #f6f6f6;
- background-color: #2f2f2f;
- }
- kbd {
- background: #1c1c1c;
- border-color: #444;
- }
- .muted {
- color: #aaa;
- }
- .account {
- background: #1c1c1c;
- border-color: #444;
- }
- .acct-sub,
- .acct-msg {
- color: #aaa;
- }
- .acct-row input {
- background: #111;
- border-color: #444;
- color: #f6f6f6;
- }
- button.secondary {
- background: #333;
- color: #eee;
- }
+ color: var(--red);
}