mirror of
https://github.com/ratspeak/Ratspeak
synced 2026-08-12 18:07:35 -04:00
ui: add extensible appearance themes
This commit is contained in:
parent
c80568db55
commit
43f7f4598e
18 changed files with 1554 additions and 121 deletions
|
|
@ -47,6 +47,28 @@ use crate::helpers::sanitize_text;
|
|||
use crate::state::{ActivityRequestFence, AppState, RNodeLifecycleOperationLease};
|
||||
|
||||
const DEFAULT_PEERS_SORT: &str = "last_seen";
|
||||
const DEFAULT_THEME_FAMILY: &str = "ratspeak";
|
||||
const DEFAULT_THEME_MODE: &str = "auto";
|
||||
|
||||
fn normalize_theme_family(family: &str) -> Option<&'static str> {
|
||||
match family.trim() {
|
||||
"ratspeak" => Some("ratspeak"),
|
||||
"nord" => Some("nord"),
|
||||
"solarized" => Some("solarized"),
|
||||
"gruvbox" => Some("gruvbox"),
|
||||
"catppuccin" => Some("catppuccin"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_theme_mode(mode: &str) -> Option<&'static str> {
|
||||
match mode.trim() {
|
||||
"light" => Some("light"),
|
||||
"auto" => Some("auto"),
|
||||
"dark" => Some("dark"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_peers_sort(sort: &str) -> Option<&'static str> {
|
||||
match sort.trim() {
|
||||
|
|
@ -960,6 +982,8 @@ pub async fn api_app_settings(state: State<'_, Arc<AppState>>) -> AppResult<Valu
|
|||
channel_hosting_enabled,
|
||||
activity_identity_protection,
|
||||
text_scale_percent,
|
||||
theme_family,
|
||||
theme_mode,
|
||||
) = db::spawn_db(state.db.clone(), |p| {
|
||||
let hw_timeout = db::get_setting(&p, "hardware_session_timeout")
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
|
|
@ -975,6 +999,12 @@ pub async fn api_app_settings(state: State<'_, Arc<AppState>>) -> AppResult<Valu
|
|||
.and_then(|value| value.parse::<u16>().ok())
|
||||
.map(|value| (value.clamp(100, 140) + 5) / 10 * 10)
|
||||
.unwrap_or(100);
|
||||
let theme_family = db::get_setting(&p, "theme_family")
|
||||
.and_then(|value| normalize_theme_family(&value).map(str::to_string))
|
||||
.unwrap_or_else(|| DEFAULT_THEME_FAMILY.to_string());
|
||||
let theme_mode = db::get_setting(&p, "theme_mode")
|
||||
.and_then(|value| normalize_theme_mode(&value).map(str::to_string))
|
||||
.unwrap_or_else(|| DEFAULT_THEME_MODE.to_string());
|
||||
(
|
||||
hw_timeout,
|
||||
developer_mode,
|
||||
|
|
@ -982,10 +1012,21 @@ pub async fn api_app_settings(state: State<'_, Arc<AppState>>) -> AppResult<Valu
|
|||
channel_hosting_enabled,
|
||||
activity_identity_protection,
|
||||
text_scale_percent,
|
||||
theme_family,
|
||||
theme_mode,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap_or((0, false, "auto".to_string(), false, true, 100));
|
||||
.unwrap_or((
|
||||
0,
|
||||
false,
|
||||
"auto".to_string(),
|
||||
false,
|
||||
true,
|
||||
100,
|
||||
DEFAULT_THEME_FAMILY.to_string(),
|
||||
DEFAULT_THEME_MODE.to_string(),
|
||||
));
|
||||
Ok(json!({
|
||||
"auto_announce_interval": *state.announce_interval_rx.borrow(),
|
||||
"announce_ratspeak_usage": state.announce_ratspeak_usage_enabled(),
|
||||
|
|
@ -996,9 +1037,68 @@ pub async fn api_app_settings(state: State<'_, Arc<AppState>>) -> AppResult<Valu
|
|||
"channel_hosting_enabled": channel_hosting_enabled,
|
||||
"activity_identity_protection": activity_identity_protection,
|
||||
"text_scale_percent": text_scale_percent,
|
||||
"theme_family": theme_family,
|
||||
"theme_mode": theme_mode,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_appearance(
|
||||
state: State<'_, Arc<AppState>>,
|
||||
family: String,
|
||||
mode: String,
|
||||
) -> AppResult<Value> {
|
||||
let family = normalize_theme_family(&family)
|
||||
.ok_or_else(|| AppError::bad_request("unknown theme family"))?;
|
||||
let mode = normalize_theme_mode(&mode)
|
||||
.ok_or_else(|| AppError::bad_request("theme mode must be light | auto | dark"))?;
|
||||
let family_owned = family.to_string();
|
||||
let mode_owned = mode.to_string();
|
||||
let stored_family = family_owned.clone();
|
||||
let stored_mode = mode_owned.clone();
|
||||
|
||||
db::spawn_db(state.db.clone(), move |p| {
|
||||
db::try_set_settings(
|
||||
&p,
|
||||
&[
|
||||
("theme_family".to_string(), stored_family),
|
||||
("theme_mode".to_string(), stored_mode),
|
||||
],
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| AppError::internal("set_appearance db task panicked"))?
|
||||
.map_err(|error| {
|
||||
AppError::database_unavailable(format!("Failed to save appearance: {error}"))
|
||||
})?;
|
||||
|
||||
let payload = json!({
|
||||
"theme_family": family_owned,
|
||||
"theme_mode": mode_owned,
|
||||
});
|
||||
state.emit_to_all("app_settings_updated", payload.clone());
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_native_theme(window: tauri::WebviewWindow, theme: String) -> AppResult<Value> {
|
||||
let theme_name = theme.trim();
|
||||
let native_theme = match theme_name {
|
||||
"light" => tauri::Theme::Light,
|
||||
"dark" => tauri::Theme::Dark,
|
||||
_ => return Err(AppError::bad_request("native theme must be light | dark")),
|
||||
};
|
||||
|
||||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
window
|
||||
.set_theme(Some(native_theme))
|
||||
.map_err(|error| AppError::internal(format!("Failed to update native theme: {error}")))?;
|
||||
#[cfg(any(target_os = "android", target_os = "ios"))]
|
||||
let _ = (window, native_theme);
|
||||
|
||||
Ok(json!({ "theme": theme_name }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_text_scale(state: State<'_, Arc<AppState>>, percent: u16) -> AppResult<Value> {
|
||||
let percent = (percent.clamp(100, 140) + 5) / 10 * 10;
|
||||
|
|
|
|||
|
|
@ -1167,7 +1167,7 @@ fn activity_identity_protection_is_default_on_durable_and_event_scoped() {
|
|||
assert!(!activity.contains("activityRevealField(event, 'destination');"));
|
||||
assert!(interfaces.contains("pub async fn set_activity_identity_protection"));
|
||||
assert!(interfaces.contains("\"activity_identity_protection\""));
|
||||
assert!(interfaces.contains(".unwrap_or((0, false, \"auto\".to_string(), false, true, 100))"));
|
||||
assert!(interfaces.contains(".is_none_or(|value| value != \"false\")"));
|
||||
assert!(tauri_lib.contains("set_activity_identity_protection"));
|
||||
}
|
||||
|
||||
|
|
@ -1189,6 +1189,41 @@ fn text_scale_presets_are_durable_and_backend_validated() {
|
|||
assert!(tauri_lib.contains("set_text_scale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appearance_families_are_durable_validated_and_native_aware() {
|
||||
let root = repo_root();
|
||||
let index = read_source(root.join("dashboard/index.html")).expect("dashboard index");
|
||||
let theme = read_source(root.join("dashboard/static/js/theme.js")).expect("theme js");
|
||||
let settings = read_source(root.join("dashboard/static/js/settings.js")).expect("settings js");
|
||||
let interfaces = read_source(root.join("crates/ratspeak-tauri/src/commands/interfaces.rs"))
|
||||
.expect("interfaces commands");
|
||||
let tauri_lib = read_source(root.join("src-tauri/src/lib.rs")).expect("tauri lib");
|
||||
let android = read_source(
|
||||
root.join("src-tauri/gen/android/app/src/main/java/org/ratspeak/android/MainActivity.kt"),
|
||||
)
|
||||
.expect("Android activity");
|
||||
|
||||
assert!(index.contains("id=\"theme-family-picker\""));
|
||||
assert!(index.contains("id=\"theme-toggle\""));
|
||||
for family in ["ratspeak", "nord", "solarized", "gruvbox", "catppuccin"] {
|
||||
assert!(theme.contains(&format!("id: '{family}'")));
|
||||
assert!(interfaces.contains(&format!("\"{family}\" => Some(\"{family}\")")));
|
||||
}
|
||||
assert!(theme.contains("data-theme-family"));
|
||||
assert!(theme.contains("data-theme-preference"));
|
||||
assert!(theme.contains("ratspeak-theme-changed"));
|
||||
assert!(settings.contains("RS.invoke('set_appearance'"));
|
||||
assert!(settings.contains("data.theme_family"));
|
||||
assert!(settings.contains("data.theme_mode"));
|
||||
assert!(interfaces.contains("pub async fn set_appearance"));
|
||||
assert!(interfaces.contains("db::try_set_settings("));
|
||||
assert!(interfaces.contains("pub fn set_native_theme"));
|
||||
assert!(tauri_lib.contains("set_appearance"));
|
||||
assert!(tauri_lib.contains("set_native_theme"));
|
||||
assert!(android.contains("fun setColorMode(mode: String)"));
|
||||
assert!(android.contains("applySystemBarColorMode(mode)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_shells_advertise_only_portrait_orientations() {
|
||||
let root = repo_root();
|
||||
|
|
@ -1199,7 +1234,7 @@ fn mobile_shells_advertise_only_portrait_orientations() {
|
|||
let ios_project =
|
||||
read_source(root.join("src-tauri/gen/apple/project.yml")).expect("iOS project source");
|
||||
|
||||
assert!(manifest.contains("android:screenOrientation=\"sensorPortrait\""));
|
||||
assert!(manifest.contains("android:screenOrientation=\"portrait\""));
|
||||
assert!(manifest.contains("tools:ignore=\"DiscouragedApi,LockedOrientationActivity\""));
|
||||
assert!(ios_info.contains("UIInterfaceOrientationPortrait"));
|
||||
assert!(!ios_info.contains("UIInterfaceOrientationLandscape"));
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ OUT="$SCRIPT_DIR/static/style.css"
|
|||
|
||||
MODULES=(
|
||||
00-tokens.css
|
||||
00-palettes.css
|
||||
01-reset.css
|
||||
02-typography.css
|
||||
03-scrollbar.css
|
||||
|
|
|
|||
|
|
@ -1186,19 +1186,29 @@
|
|||
<div class="panel settings-panel settings-panel-selected" id="panel-settings-general">
|
||||
<div class="panel-header">General</div>
|
||||
<div class="panel-body">
|
||||
<div class="settings-row">
|
||||
<div class="settings-row settings-theme-family-row">
|
||||
<div class="settings-row-info">
|
||||
<span class="settings-row-label">Theme</span>
|
||||
<span class="settings-row-desc">Choose light, dark, or match your system</span>
|
||||
<span class="settings-row-desc" id="settings-theme-family-desc">Choose a color system for every part of Ratspeak.</span>
|
||||
</div>
|
||||
<div class="theme-toggle" id="theme-toggle">
|
||||
<button class="theme-toggle-btn" data-theme="light" aria-label="Light theme">
|
||||
<fieldset class="theme-family-picker" id="theme-family-picker" aria-describedby="settings-theme-family-desc">
|
||||
<legend class="theme-family-legend">Theme family</legend>
|
||||
<div class="theme-family-grid" id="theme-family-grid"></div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="settings-row settings-theme-mode-row">
|
||||
<div class="settings-row-info">
|
||||
<span class="settings-row-label">Color mode</span>
|
||||
<span class="settings-row-desc">Choose light, dark, or match your system.</span>
|
||||
</div>
|
||||
<div class="theme-toggle" id="theme-toggle" role="group" aria-label="Color mode">
|
||||
<button class="theme-toggle-btn" type="button" data-theme="light" aria-label="Use light mode" title="Light">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
</button>
|
||||
<button class="theme-toggle-btn" data-theme="auto" aria-label="System theme">
|
||||
<button class="theme-toggle-btn" type="button" data-theme="auto" aria-label="Match system color mode" title="System">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
</button>
|
||||
<button class="theme-toggle-btn" data-theme="dark" aria-label="Dark theme">
|
||||
<button class="theme-toggle-btn" type="button" data-theme="dark" aria-label="Use dark mode" title="Dark">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
104
dashboard/scripts/test_appearance_themes.js
Normal file
104
dashboard/scripts/test_appearance_themes.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
var assert = require('assert');
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var vm = require('vm');
|
||||
|
||||
var source = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'static', 'js', 'theme.js'),
|
||||
'utf8'
|
||||
);
|
||||
var storage = {
|
||||
'rs-theme': 'sepia',
|
||||
'rs-theme-family': 'unknown'
|
||||
};
|
||||
var attrs = {};
|
||||
var events = [];
|
||||
var mediaListeners = [];
|
||||
var media = { matches: true };
|
||||
var meta = {
|
||||
content: '',
|
||||
setAttribute: function(name, value) {
|
||||
if (name === 'content') this.content = value;
|
||||
}
|
||||
};
|
||||
var root = {
|
||||
setAttribute: function(name, value) { attrs[name] = value; },
|
||||
getAttribute: function(name) { return attrs[name] || null; }
|
||||
};
|
||||
function CustomEvent(name, options) {
|
||||
this.type = name;
|
||||
this.detail = options.detail;
|
||||
}
|
||||
var context = {
|
||||
CustomEvent: CustomEvent,
|
||||
localStorage: {
|
||||
getItem: function(key) {
|
||||
return Object.prototype.hasOwnProperty.call(storage, key) ? storage[key] : null;
|
||||
},
|
||||
setItem: function(key, value) { storage[key] = String(value); },
|
||||
removeItem: function(key) { delete storage[key]; }
|
||||
},
|
||||
document: {
|
||||
documentElement: root,
|
||||
querySelector: function(selector) {
|
||||
return selector === 'meta[name="theme-color"]' ? meta : null;
|
||||
},
|
||||
addEventListener: function() {}
|
||||
},
|
||||
window: {
|
||||
CustomEvent: CustomEvent,
|
||||
dispatchEvent: function(event) { events.push(event); },
|
||||
matchMedia: function() {
|
||||
return {
|
||||
get matches() { return media.matches; },
|
||||
addEventListener: function(type, listener) {
|
||||
if (type === 'change') mediaListeners.push(listener);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
context.window.window = context.window;
|
||||
context.window.document = context.document;
|
||||
context.window.localStorage = context.localStorage;
|
||||
vm.createContext(context);
|
||||
vm.runInContext(source, context, { filename: 'theme.js' });
|
||||
|
||||
var appearance = context.window.RS.appearance;
|
||||
assert.strictEqual(
|
||||
JSON.stringify(appearance.families.map(function(family) { return family.id; })),
|
||||
JSON.stringify(['ratspeak', 'nord', 'solarized', 'gruvbox', 'catppuccin'])
|
||||
);
|
||||
assert.strictEqual(storage['rs-theme'], undefined, 'invalid legacy mode cache must be removed');
|
||||
assert.strictEqual(storage['rs-theme-family'], undefined, 'invalid family cache must be removed');
|
||||
assert.strictEqual(attrs['data-theme-family'], 'ratspeak');
|
||||
assert.strictEqual(attrs['data-theme-preference'], 'auto');
|
||||
assert.strictEqual(attrs['data-theme'], 'dark');
|
||||
assert.strictEqual(meta.content, '#18171A');
|
||||
|
||||
appearance.commit('nord', 'light');
|
||||
assert.strictEqual(storage['rs-theme-family'], 'nord');
|
||||
assert.strictEqual(storage['rs-theme'], 'light');
|
||||
assert.strictEqual(attrs['data-theme-family'], 'nord');
|
||||
assert.strictEqual(attrs['data-theme'], 'light');
|
||||
assert.strictEqual(meta.content, '#ECEFF4');
|
||||
assert.strictEqual(events[events.length - 1].type, 'ratspeak-theme-changed');
|
||||
assert.strictEqual(events[events.length - 1].detail.family, 'nord');
|
||||
assert.strictEqual(events[events.length - 1].detail.preference, 'light');
|
||||
|
||||
appearance.commit('catppuccin', 'dark');
|
||||
assert.strictEqual(meta.content, '#11111B');
|
||||
assert.strictEqual(events[events.length - 1].detail.mode, 'dark');
|
||||
|
||||
appearance.commit('ratspeak', 'auto');
|
||||
assert.strictEqual(storage['rs-theme-family'], undefined, 'default family should not need a cache entry');
|
||||
assert.strictEqual(storage['rs-theme'], undefined, 'system mode should preserve legacy missing-key semantics');
|
||||
media.matches = false;
|
||||
mediaListeners.forEach(function(listener) { listener({ matches: false }); });
|
||||
assert.strictEqual(attrs['data-theme'], 'light', 'system-mode changes must resolve immediately');
|
||||
assert.strictEqual(events[events.length - 1].detail.preference, 'auto');
|
||||
|
||||
console.log('Appearance theme tests passed');
|
||||
|
|
@ -10,6 +10,7 @@ var dashboardRoot = path.join(__dirname, '..');
|
|||
var cssRoot = path.join(dashboardRoot, 'static', 'css');
|
||||
var modules = [
|
||||
'00-tokens.css',
|
||||
'00-palettes.css',
|
||||
'01-reset.css',
|
||||
'02-typography.css',
|
||||
'03-scrollbar.css',
|
||||
|
|
@ -32,6 +33,9 @@ var sources = modules.map(function(name) {
|
|||
text: fs.readFileSync(path.join(cssRoot, name), 'utf8')
|
||||
};
|
||||
});
|
||||
var sourceByName = Object.fromEntries(sources.map(function(source) {
|
||||
return [source.name, source.text];
|
||||
}));
|
||||
var expectedBundle = sources.map(function(source) { return source.text + '\n'; }).join('');
|
||||
var shellBuilder = fs.readFileSync(path.join(dashboardRoot, 'build-css.sh'), 'utf8');
|
||||
var shellModules = Array.from(shellBuilder.matchAll(/^\s+([0-9][^\s]+\.css)\s*$/gm), function(match) { return match[1]; });
|
||||
|
|
@ -86,24 +90,156 @@ keyframes.forEach(function(owners, name) {
|
|||
});
|
||||
assert.deepStrictEqual(duplicates, [], 'animation keyframes must have one owner');
|
||||
|
||||
var tokens = sources[0].text;
|
||||
var tokens = sourceByName['00-tokens.css'];
|
||||
assert(/--text-base:\s*0\.9375rem/.test(tokens), 'text tokens must scale from rem units');
|
||||
assert(/--text-3xl:\s*1\.75rem/.test(tokens), 'the complete text scale must be defined');
|
||||
assert(!/font-weight:\s*800\b/.test(allCss), 'CSS must not request an unloaded Outfit weight');
|
||||
assert(!/--font-sans\s*:/.test(sources[10].text), 'Channels must inherit the app font contract');
|
||||
assert(/\.nav-item\s*\{[^}]*min-height:\s*46px/s.test(sources[4].text),
|
||||
assert(!/--font-sans\s*:/.test(sourceByName['09-channels.css']), 'Channels must inherit the app font contract');
|
||||
assert(/\.nav-item\s*\{[^}]*min-height:\s*46px/s.test(sourceByName['04-layout.css']),
|
||||
'navigation labels must grow instead of clipping scaled text');
|
||||
var settingsFieldRule = sources[6].text.match(/\.view-grid-settings input\[type="text"\][\s\S]*?\.view-grid-settings \.modal-input\s*\{([^}]*)\}/);
|
||||
var settingsFieldRule = sourceByName['06-forms.css'].match(/\.view-grid-settings input\[type="text"\][\s\S]*?\.view-grid-settings \.modal-input\s*\{([^}]*)\}/);
|
||||
assert(settingsFieldRule && /min-height:\s*34px/.test(settingsFieldRule[1]) && /height:\s*auto/.test(settingsFieldRule[1]),
|
||||
'settings fields must grow with scaled text');
|
||||
assert(/data-text-scale-tier="xlarge"[\s\S]*?\.channels-layout/.test(sources[14].text),
|
||||
assert(/data-text-scale-tier="xlarge"[\s\S]*?\.channels-layout/.test(sourceByName['13-responsive.css']),
|
||||
'the largest text tier must simplify the Channels layout');
|
||||
assert(/\.settings-type-presets\s*\{[\s\S]*?repeat\(5,/.test(sources[11].text),
|
||||
assert(/\.settings-type-presets\s*\{[\s\S]*?repeat\(5,/.test(sourceByName['10-views.css']),
|
||||
'text sizing must expose five deliberate presets instead of a continuous slider');
|
||||
assert(/data-scale="100"[\s\S]*?--type-preview-size:\s*18px/.test(sources[11].text) &&
|
||||
/data-scale="140"[\s\S]*?--type-preview-size:\s*34px/.test(sources[11].text),
|
||||
assert(/data-scale="100"[\s\S]*?--type-preview-size:\s*18px/.test(sourceByName['10-views.css']) &&
|
||||
/data-scale="140"[\s\S]*?--type-preview-size:\s*34px/.test(sourceByName['10-views.css']),
|
||||
'text-size specimens must make the preset progression visually distinct');
|
||||
assert(/data-text-scale-tier="large"\] \.settings-text-scale-row \.settings-row-info\s*\{[^}]*flex:\s*0 0 auto/s.test(sources[11].text),
|
||||
assert(/data-text-scale-tier="large"\] \.settings-text-scale-row \.settings-row-info\s*\{[^}]*flex:\s*0 0 auto/s.test(sourceByName['10-views.css']),
|
||||
'the 130% tier must not stretch the text-size introduction away from its presets');
|
||||
|
||||
var paletteCss = sourceByName['00-palettes.css'];
|
||||
var themeSource = fs.readFileSync(path.join(dashboardRoot, 'static', 'js', 'theme.js'), 'utf8');
|
||||
var themeFamilies = ['ratspeak', 'nord', 'solarized', 'gruvbox', 'catppuccin'];
|
||||
var registeredFamilies = Array.from(themeSource.matchAll(/\bid:\s*'([^']+)'/g), function(entry) {
|
||||
return entry[1];
|
||||
});
|
||||
assert.deepStrictEqual(registeredFamilies, themeFamilies,
|
||||
'the appearance registry must expose the canonical ordered family set');
|
||||
|
||||
var requiredPaletteTokens = [
|
||||
'--theme-page-rgb', '--theme-ink-rgb', '--theme-border-rgb',
|
||||
'--theme-strong-border-rgb', '--theme-focus',
|
||||
'--bg-primary', '--bg-secondary', '--bg-tertiary', '--bg-card', '--bg-dark',
|
||||
'--border', '--border-light', '--border-subtle', '--border-card', '--border-control',
|
||||
'--text-primary', '--text-secondary', '--text-muted', '--text-disabled',
|
||||
'--accent', '--accent-dim', '--accent-dark', '--accent-light', '--accent-rgb', '--on-accent',
|
||||
'--status-online', '--status-online-fg', '--status-online-rgb',
|
||||
'--status-error', '--status-error-fg', '--status-error-rgb',
|
||||
'--status-warning', '--status-warning-fg', '--status-warning-rgb',
|
||||
'--status-info', '--status-info-fg', '--status-info-rgb',
|
||||
'--status-purple', '--status-purple-fg', '--status-purple-rgb',
|
||||
'--ble-accent', '--ble-accent-fg', '--ble-accent-rgb',
|
||||
'--surface-elevation-0', '--surface-elevation-1', '--surface-elevation-2',
|
||||
'--surface-elevation-3', '--surface-elevation-4', '--surface-elevation-5',
|
||||
'--chess-light', '--chess-dark', '--chess-border',
|
||||
'--chess-coord-light', '--chess-coord-dark',
|
||||
'--status-discovered', '--status-discovered-rgb', '--surface-game-gradient'
|
||||
];
|
||||
|
||||
function paletteValue(body, token) {
|
||||
var match = body.match(new RegExp(token + '\\s*:\\s*([^;]+)\\s*;'));
|
||||
assert(match, 'missing value for ' + token);
|
||||
var value = match[1].trim();
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(value)) return value;
|
||||
var alias = value.match(/^var\((--[a-zA-Z0-9_-]+)\)$/);
|
||||
assert(alias, 'expected a hex value or direct alias for ' + token + ', got ' + value);
|
||||
return paletteValue(body, alias[1]);
|
||||
}
|
||||
|
||||
function channel(value) {
|
||||
value /= 255;
|
||||
return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
function luminance(hex) {
|
||||
var value = parseInt(hex.slice(1), 16);
|
||||
return 0.2126 * channel((value >> 16) & 255) +
|
||||
0.7152 * channel((value >> 8) & 255) +
|
||||
0.0722 * channel(value & 255);
|
||||
}
|
||||
|
||||
function contrast(a, b) {
|
||||
var first = luminance(a);
|
||||
var second = luminance(b);
|
||||
var lighter = Math.max(first, second);
|
||||
var darker = Math.min(first, second);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
function requireContrast(body, foreground, background, minimum, label) {
|
||||
var ratio = contrast(paletteValue(body, foreground), paletteValue(body, background));
|
||||
assert(ratio >= minimum, label + ' contrast is ' + ratio.toFixed(2) + ':1; expected ' + minimum + ':1');
|
||||
}
|
||||
|
||||
function paletteBody(family, mode) {
|
||||
var source;
|
||||
var selector;
|
||||
if (family === 'ratspeak') {
|
||||
source = tokens;
|
||||
selector = mode === 'light' ? ':root {' : '[data-theme="dark"] {';
|
||||
} else {
|
||||
source = paletteCss;
|
||||
selector = ':root[data-theme-family="' + family + '"][data-theme="' + mode + '"]';
|
||||
}
|
||||
var start = source.indexOf(selector);
|
||||
assert(start >= 0, family + ' must define a ' + mode + ' palette');
|
||||
var bodyStart = source.indexOf('{', start);
|
||||
var bodyEnd = source.indexOf('\n}', bodyStart);
|
||||
return source.slice(bodyStart + 1, bodyEnd);
|
||||
}
|
||||
|
||||
themeFamilies.forEach(function(family) {
|
||||
var familyStart = themeSource.indexOf("id: '" + family + "'");
|
||||
var nextFamilyStart = themeSource.indexOf("\n {\n id:", familyStart + 1);
|
||||
var familySource = themeSource.slice(
|
||||
familyStart,
|
||||
nextFamilyStart >= 0 ? nextFamilyStart : themeSource.indexOf('\n ];', familyStart)
|
||||
);
|
||||
['light', 'dark'].forEach(function(mode) {
|
||||
var body = paletteBody(family, mode);
|
||||
requiredPaletteTokens.forEach(function(token) {
|
||||
assert(new RegExp(token + '\\s*:').test(body),
|
||||
family + '/' + mode + ' is missing ' + token);
|
||||
});
|
||||
['--text-primary', '--text-secondary', '--text-muted', '--accent', '--accent-dim'].forEach(function(token) {
|
||||
requireContrast(body, token, '--bg-primary', 4.5, family + '/' + mode + ' ' + token + ' on page');
|
||||
requireContrast(body, token, '--bg-card', 4.5, family + '/' + mode + ' ' + token + ' on panel');
|
||||
});
|
||||
requireContrast(body, '--on-accent', '--accent', 4.5,
|
||||
family + '/' + mode + ' accent foreground');
|
||||
requireContrast(body, '--on-accent', '--accent-dim', 4.5,
|
||||
family + '/' + mode + ' accent hover foreground');
|
||||
requireContrast(body, '--theme-focus', '--bg-primary', 3,
|
||||
family + '/' + mode + ' focus ring');
|
||||
requireContrast(body, '--border-control', '--bg-primary', 3,
|
||||
family + '/' + mode + ' control border on page');
|
||||
['--status-online-fg', '--status-error-fg', '--status-warning-fg',
|
||||
'--status-info-fg', '--status-purple-fg', '--ble-accent-fg'].forEach(function(token) {
|
||||
requireContrast(body, token, '--bg-primary', 4.5,
|
||||
family + '/' + mode + ' ' + token + ' on page');
|
||||
});
|
||||
|
||||
var previewMatch = familySource.match(new RegExp(
|
||||
mode + ": \\['(#[0-9A-Fa-f]{6})', '(#[0-9A-Fa-f]{6})', '(#[0-9A-Fa-f]{6})'\\]"
|
||||
));
|
||||
assert(previewMatch, family + '/' + mode + ' must expose one picker preview triplet');
|
||||
assert.deepStrictEqual(
|
||||
previewMatch.slice(1).map(function(value) { return value.toUpperCase(); }),
|
||||
['--bg-primary', '--bg-card', '--accent'].map(function(token) {
|
||||
return paletteValue(body, token).toUpperCase();
|
||||
}),
|
||||
family + '/' + mode + ' preview and native chrome colors must match its palette'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
assert(/data-theme-family', family/.test(themeSource) &&
|
||||
/data-theme-preference', preference/.test(themeSource),
|
||||
'family, preference, and resolved mode must remain independent appearance axes');
|
||||
assert(/ratspeak-theme-changed/.test(themeSource),
|
||||
'appearance changes must publish one shared frontend event');
|
||||
|
||||
console.log('CSS contract tests passed');
|
||||
|
|
|
|||
594
dashboard/static/css/00-palettes.css
Normal file
594
dashboard/static/css/00-palettes.css
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
/*
|
||||
* Named appearance families. The resolved light/dark mode remains on
|
||||
* data-theme so existing component selectors continue to work. Every block
|
||||
* supplies the same complete palette contract; component CSS consumes semantic
|
||||
* role tokens from 00-tokens.css instead of knowing a family name.
|
||||
*/
|
||||
|
||||
:root[data-theme-family]:not([data-theme-family="ratspeak"]) {
|
||||
--accent-a05: rgba(var(--accent-rgb), 0.05);
|
||||
--accent-a12: rgba(var(--accent-rgb), 0.12);
|
||||
--accent-a15: rgba(var(--accent-rgb), 0.15);
|
||||
--accent-a18: rgba(var(--accent-rgb), 0.18);
|
||||
--accent-a20: rgba(var(--accent-rgb), 0.20);
|
||||
--accent-a25: rgba(var(--accent-rgb), 0.25);
|
||||
--accent-a40: rgba(var(--accent-rgb), 0.40);
|
||||
--accent-a45: rgba(var(--accent-rgb), 0.45);
|
||||
|
||||
--accent-bg: rgba(var(--accent-rgb), 0.09);
|
||||
--accent-bg-strong: rgba(var(--accent-rgb), 0.15);
|
||||
--accent-border: rgba(var(--accent-rgb), 0.32);
|
||||
|
||||
--status-online-bg: rgba(var(--status-online-rgb), 0.10);
|
||||
--status-online-bg-strong: rgba(var(--status-online-rgb), 0.16);
|
||||
--status-online-border: rgba(var(--status-online-rgb), 0.28);
|
||||
--status-error-bg: rgba(var(--status-error-rgb), 0.10);
|
||||
--status-error-border: rgba(var(--status-error-rgb), 0.28);
|
||||
--status-info-bg: rgba(var(--status-info-rgb), 0.10);
|
||||
--status-info-border: rgba(var(--status-info-rgb), 0.28);
|
||||
--status-warning-bg: rgba(var(--status-warning-rgb), 0.11);
|
||||
--status-warning-border: rgba(var(--status-warning-rgb), 0.30);
|
||||
--status-purple-bg: rgba(var(--status-purple-rgb), 0.12);
|
||||
--status-purple-border: rgba(var(--status-purple-rgb), 0.28);
|
||||
--ble-bg: rgba(var(--ble-accent-rgb), 0.10);
|
||||
--ble-bg-strong: rgba(var(--ble-accent-rgb), 0.16);
|
||||
--ble-border: rgba(var(--ble-accent-rgb), 0.28);
|
||||
|
||||
--gradient-header: var(--bg-primary);
|
||||
--surface-row-selected: var(--accent-bg-strong);
|
||||
--button-shadow-hover: 0 4px 16px var(--accent-bg-strong);
|
||||
--focus-ring: var(--theme-focus);
|
||||
--focus-ring-bg: var(--accent-bg);
|
||||
--text-link: var(--accent);
|
||||
--text-link-hover: var(--accent-dim);
|
||||
--input-bg: var(--bg-primary);
|
||||
--input-border: var(--border-control);
|
||||
--input-text: var(--text-primary);
|
||||
--input-placeholder: var(--text-muted);
|
||||
}
|
||||
|
||||
:root[data-theme-family]:not([data-theme-family="ratspeak"])[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--glass-bg: rgba(var(--theme-page-rgb), 0.88);
|
||||
--glass-border: rgba(var(--theme-border-rgb), 0.82);
|
||||
--surface-sunken: rgba(var(--theme-ink-rgb), 0.045);
|
||||
--surface-overlay: rgba(12, 16, 22, 0.38);
|
||||
--divider: rgba(var(--theme-border-rgb), 0.68);
|
||||
--divider-strong: rgba(var(--theme-strong-border-rgb), 0.92);
|
||||
--badge-neutral-bg: rgba(var(--theme-ink-rgb), 0.07);
|
||||
--hover-subtle: rgba(var(--theme-ink-rgb), 0.045);
|
||||
--hover-light: rgba(var(--theme-ink-rgb), 0.07);
|
||||
--hover-medium: rgba(var(--theme-ink-rgb), 0.10);
|
||||
--shadow-sm: 0 1px 2px rgba(25, 31, 42, 0.05), 0 5px 14px rgba(25, 31, 42, 0.045);
|
||||
--shadow-md: 0 2px 4px rgba(25, 31, 42, 0.07), 0 14px 34px rgba(25, 31, 42, 0.09);
|
||||
--shadow-xl: 0 8px 30px rgba(25, 31, 42, 0.12), 0 2px 9px rgba(25, 31, 42, 0.07);
|
||||
--shadow-modal: 0 20px 62px rgba(25, 31, 42, 0.16), 0 4px 16px rgba(25, 31, 42, 0.09);
|
||||
--shadow-hover: 0 8px 24px rgba(25, 31, 42, 0.13);
|
||||
--shadow-panel: var(--shadow-sm);
|
||||
--shadow-panel-hover: var(--shadow-sm);
|
||||
--shadow-card: var(--shadow-sm);
|
||||
--shadow-card-hover: var(--shadow-md);
|
||||
--shadow-popover: var(--shadow-xl);
|
||||
--glass-dark-bg: rgba(var(--theme-page-rgb), 0.90);
|
||||
--glass-dark-bg-muted: rgba(var(--theme-page-rgb), 0.58);
|
||||
--sheet-bg: rgba(var(--theme-page-rgb), 0.95);
|
||||
--sheet-handle: rgba(var(--theme-ink-rgb), 0.20);
|
||||
--sheet-divider: var(--divider);
|
||||
--sheet-border: var(--border-card);
|
||||
--sheet-shadow: 0 -4px 32px rgba(25, 31, 42, 0.10);
|
||||
}
|
||||
|
||||
:root[data-theme-family]:not([data-theme-family="ratspeak"])[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--glass-bg: rgba(var(--theme-page-rgb), 0.82);
|
||||
--glass-border: rgba(var(--theme-border-rgb), 0.76);
|
||||
--surface-sunken: rgba(0, 0, 0, 0.22);
|
||||
--surface-overlay: rgba(0, 0, 0, 0.67);
|
||||
--divider: rgba(var(--theme-border-rgb), 0.80);
|
||||
--divider-strong: rgba(var(--theme-strong-border-rgb), 0.92);
|
||||
--badge-neutral-bg: rgba(var(--theme-ink-rgb), 0.085);
|
||||
--hover-subtle: rgba(var(--theme-ink-rgb), 0.05);
|
||||
--hover-light: rgba(var(--theme-ink-rgb), 0.08);
|
||||
--hover-medium: rgba(var(--theme-ink-rgb), 0.12);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.34), 0 8px 22px rgba(0, 0, 0, 0.19);
|
||||
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.40), 0 14px 36px rgba(0, 0, 0, 0.26);
|
||||
--shadow-xl: 0 8px 34px rgba(0, 0, 0, 0.46), 0 2px 10px rgba(0, 0, 0, 0.30);
|
||||
--shadow-modal: 0 24px 82px rgba(0, 0, 0, 0.54);
|
||||
--shadow-hover: 0 5px 18px rgba(0, 0, 0, 0.42);
|
||||
--shadow-panel: var(--shadow-sm);
|
||||
--shadow-panel-hover: var(--shadow-sm);
|
||||
--shadow-card: var(--shadow-sm);
|
||||
--shadow-card-hover: var(--shadow-md);
|
||||
--shadow-popover: var(--shadow-xl);
|
||||
--glass-dark-bg: rgba(var(--theme-page-rgb), 0.90);
|
||||
--glass-dark-bg-muted: rgba(var(--theme-page-rgb), 0.58);
|
||||
--sheet-bg: rgba(var(--theme-page-rgb), 0.95);
|
||||
--sheet-handle: rgba(var(--theme-ink-rgb), 0.20);
|
||||
--sheet-divider: var(--divider);
|
||||
--sheet-border: var(--border);
|
||||
--sheet-shadow: 0 -4px 34px rgba(0, 0, 0, 0.44);
|
||||
}
|
||||
|
||||
/* Nord — Polar Night / Snow Storm with an accessible Frost derivative. */
|
||||
:root[data-theme-family="nord"][data-theme="light"] {
|
||||
--theme-page-rgb: 236, 239, 244;
|
||||
--theme-ink-rgb: 46, 52, 64;
|
||||
--theme-border-rgb: 197, 206, 218;
|
||||
--theme-strong-border-rgb: 116, 128, 150;
|
||||
--theme-focus: #5E81AC;
|
||||
--bg-primary: #ECEFF4;
|
||||
--bg-secondary: #E5E9F0;
|
||||
--bg-tertiary: #D8DEE9;
|
||||
--bg-card: #F8FAFC;
|
||||
--bg-dark: #2E3440;
|
||||
--border: #C5CEDA;
|
||||
--border-light: #AAB6C5;
|
||||
--border-subtle: #D8DEE9;
|
||||
--border-card: #C5CEDA;
|
||||
--border-control: #748096;
|
||||
--text-primary: #2E3440;
|
||||
--text-secondary: #3B4252;
|
||||
--text-muted: #4C566A;
|
||||
--text-disabled: #7C8797;
|
||||
--accent: #46658A;
|
||||
--accent-dim: #385473;
|
||||
--accent-dark: #2F465F;
|
||||
--accent-light: #D3DFEA;
|
||||
--accent-rgb: 70, 101, 138;
|
||||
--on-accent: #FFFFFF;
|
||||
--status-online: #A3BE8C;
|
||||
--status-online-fg: #4F6B3F;
|
||||
--status-online-rgb: 163, 190, 140;
|
||||
--status-error: #BF616A;
|
||||
--status-error-fg: #8F3F49;
|
||||
--status-error-rgb: 191, 97, 106;
|
||||
--status-warning: #EBCB8B;
|
||||
--status-warning-fg: #6C5600;
|
||||
--status-warning-rgb: 235, 203, 139;
|
||||
--status-info: #5E81AC;
|
||||
--status-info-fg: #405F87;
|
||||
--status-info-rgb: 94, 129, 172;
|
||||
--status-purple: #B48EAD;
|
||||
--status-purple-fg: #735583;
|
||||
--status-purple-rgb: 180, 142, 173;
|
||||
--ble-accent: #8FBCBB;
|
||||
--ble-accent-fg: #3F6F70;
|
||||
--ble-accent-rgb: 143, 188, 187;
|
||||
--surface-elevation-0: #E5E9F0;
|
||||
--surface-elevation-1: #ECEFF4;
|
||||
--surface-elevation-2: #E5E9F0;
|
||||
--surface-elevation-3: #D8DEE9;
|
||||
--surface-elevation-4: #C5CEDA;
|
||||
--surface-elevation-5: #AAB6C5;
|
||||
--chess-light: #D8DEE9;
|
||||
--chess-dark: #81A1C1;
|
||||
--chess-border: #5E81AC;
|
||||
--chess-coord-light: #5E81AC;
|
||||
--chess-coord-dark: #ECEFF4;
|
||||
--status-discovered: #748096;
|
||||
--status-discovered-rgb: 116, 128, 150;
|
||||
--surface-game-gradient: #D8DEE9;
|
||||
}
|
||||
|
||||
:root[data-theme-family="nord"][data-theme="dark"] {
|
||||
--theme-page-rgb: 37, 42, 52;
|
||||
--theme-ink-rgb: 236, 239, 244;
|
||||
--theme-border-rgb: 76, 86, 106;
|
||||
--theme-strong-border-rgb: 130, 144, 164;
|
||||
--theme-focus: #88C0D0;
|
||||
--bg-primary: #252A34;
|
||||
--bg-secondary: #2E3440;
|
||||
--bg-tertiary: #434C5E;
|
||||
--bg-card: #3B4252;
|
||||
--bg-dark: #ECEFF4;
|
||||
--border: #4C566A;
|
||||
--border-light: #667387;
|
||||
--border-subtle: #3B4252;
|
||||
--border-card: #4C566A;
|
||||
--border-control: #8290A4;
|
||||
--text-primary: #ECEFF4;
|
||||
--text-secondary: #D8DEE9;
|
||||
--text-muted: #B7C1CE;
|
||||
--text-disabled: #7D899A;
|
||||
--accent: #88C0D0;
|
||||
--accent-dim: #92C8D6;
|
||||
--accent-dark: #5E81AC;
|
||||
--accent-light: #344B57;
|
||||
--accent-rgb: 136, 192, 208;
|
||||
--on-accent: #2E3440;
|
||||
--status-online: #A3BE8C;
|
||||
--status-online-fg: #A3BE8C;
|
||||
--status-online-rgb: 163, 190, 140;
|
||||
--status-error: #E88A93;
|
||||
--status-error-fg: #E88A93;
|
||||
--status-error-rgb: 232, 138, 147;
|
||||
--status-warning: #EBCB8B;
|
||||
--status-warning-fg: #EBCB8B;
|
||||
--status-warning-rgb: 235, 203, 139;
|
||||
--status-info: #9DB9D3;
|
||||
--status-info-fg: #9DB9D3;
|
||||
--status-info-rgb: 157, 185, 211;
|
||||
--status-purple: #C0A1C7;
|
||||
--status-purple-fg: #C0A1C7;
|
||||
--status-purple-rgb: 192, 161, 199;
|
||||
--ble-accent: #8FBCBB;
|
||||
--ble-accent-fg: #8FBCBB;
|
||||
--ble-accent-rgb: 143, 188, 187;
|
||||
--surface-elevation-0: #2E3440;
|
||||
--surface-elevation-1: #353C4A;
|
||||
--surface-elevation-2: #3B4252;
|
||||
--surface-elevation-3: #434C5E;
|
||||
--surface-elevation-4: #4C566A;
|
||||
--surface-elevation-5: #5C687E;
|
||||
--chess-light: #D8DEE9;
|
||||
--chess-dark: #5E81AC;
|
||||
--chess-border: #8290A4;
|
||||
--chess-coord-light: #5E81AC;
|
||||
--chess-coord-dark: #ECEFF4;
|
||||
--status-discovered: #8290A4;
|
||||
--status-discovered-rgb: 130, 144, 164;
|
||||
--surface-game-gradient: #434C5E;
|
||||
}
|
||||
|
||||
/* Solarized — the canonical inversion pair with contrast-safe UI roles. */
|
||||
:root[data-theme-family="solarized"][data-theme="light"] {
|
||||
--theme-page-rgb: 253, 246, 227;
|
||||
--theme-ink-rgb: 64, 92, 99;
|
||||
--theme-border-rgb: 215, 207, 186;
|
||||
--theme-strong-border-rgb: 122, 139, 140;
|
||||
--theme-focus: #268BD2;
|
||||
--bg-primary: #FDF6E3;
|
||||
--bg-secondary: #EEE8D5;
|
||||
--bg-tertiary: #E6DEC9;
|
||||
--bg-card: #FFFCF0;
|
||||
--bg-dark: #002B36;
|
||||
--border: #D7CFBA;
|
||||
--border-light: #B8B3A3;
|
||||
--border-subtle: #E7DFC9;
|
||||
--border-card: #D7CFBA;
|
||||
--border-control: #7A8B8C;
|
||||
--text-primary: #405C63;
|
||||
--text-secondary: #586E75;
|
||||
--text-muted: #5F737A;
|
||||
--text-disabled: #899797;
|
||||
--accent: #0F6F91;
|
||||
--accent-dim: #0B5D7A;
|
||||
--accent-dark: #08475E;
|
||||
--accent-light: #D9E8E3;
|
||||
--accent-rgb: 15, 111, 145;
|
||||
--on-accent: #FFFFFF;
|
||||
--status-online: #859900;
|
||||
--status-online-fg: #5E7000;
|
||||
--status-online-rgb: 133, 153, 0;
|
||||
--status-error: #DC322F;
|
||||
--status-error-fg: #B52A2A;
|
||||
--status-error-rgb: 220, 50, 47;
|
||||
--status-warning: #B58900;
|
||||
--status-warning-fg: #765A00;
|
||||
--status-warning-rgb: 181, 137, 0;
|
||||
--status-info: #268BD2;
|
||||
--status-info-fg: #0F6F91;
|
||||
--status-info-rgb: 38, 139, 210;
|
||||
--status-purple: #6C71C4;
|
||||
--status-purple-fg: #5559A7;
|
||||
--status-purple-rgb: 108, 113, 196;
|
||||
--ble-accent: #2AA198;
|
||||
--ble-accent-fg: #14756F;
|
||||
--ble-accent-rgb: 42, 161, 152;
|
||||
--surface-elevation-0: #EEE8D5;
|
||||
--surface-elevation-1: #FDF6E3;
|
||||
--surface-elevation-2: #EEE8D5;
|
||||
--surface-elevation-3: #E6DEC9;
|
||||
--surface-elevation-4: #D7CFBA;
|
||||
--surface-elevation-5: #B8B3A3;
|
||||
--chess-light: #EEE8D5;
|
||||
--chess-dark: #93A1A1;
|
||||
--chess-border: #657B83;
|
||||
--chess-coord-light: #657B83;
|
||||
--chess-coord-dark: #FDF6E3;
|
||||
--status-discovered: #7A8B8C;
|
||||
--status-discovered-rgb: 122, 139, 140;
|
||||
--surface-game-gradient: #E6DEC9;
|
||||
}
|
||||
|
||||
:root[data-theme-family="solarized"][data-theme="dark"] {
|
||||
--theme-page-rgb: 0, 43, 54;
|
||||
--theme-ink-rgb: 238, 232, 213;
|
||||
--theme-border-rgb: 49, 84, 93;
|
||||
--theme-strong-border-rgb: 115, 135, 139;
|
||||
--theme-focus: #5AADE3;
|
||||
--bg-primary: #002B36;
|
||||
--bg-secondary: #073642;
|
||||
--bg-tertiary: #124853;
|
||||
--bg-card: #0B3B46;
|
||||
--bg-dark: #FDF6E3;
|
||||
--border: #31545D;
|
||||
--border-light: #4E6870;
|
||||
--border-subtle: #16434D;
|
||||
--border-card: #31545D;
|
||||
--border-control: #73878B;
|
||||
--text-primary: #EEE8D5;
|
||||
--text-secondary: #A7B4B2;
|
||||
--text-muted: #93A1A1;
|
||||
--text-disabled: #60767B;
|
||||
--accent: #5AADE3;
|
||||
--accent-dim: #6BB8E8;
|
||||
--accent-dark: #268BD2;
|
||||
--accent-light: #123F52;
|
||||
--accent-rgb: 90, 173, 227;
|
||||
--on-accent: #002B36;
|
||||
--status-online: #A4B82C;
|
||||
--status-online-fg: #A4B82C;
|
||||
--status-online-rgb: 164, 184, 44;
|
||||
--status-error: #FF827B;
|
||||
--status-error-fg: #FF827B;
|
||||
--status-error-rgb: 255, 130, 123;
|
||||
--status-warning: #D6AA2F;
|
||||
--status-warning-fg: #D6AA2F;
|
||||
--status-warning-rgb: 214, 170, 47;
|
||||
--status-info: #5AADE3;
|
||||
--status-info-fg: #5AADE3;
|
||||
--status-info-rgb: 90, 173, 227;
|
||||
--status-purple: #8F94DB;
|
||||
--status-purple-fg: #8F94DB;
|
||||
--status-purple-rgb: 143, 148, 219;
|
||||
--ble-accent: #4BB7AD;
|
||||
--ble-accent-fg: #4BB7AD;
|
||||
--ble-accent-rgb: 75, 183, 173;
|
||||
--surface-elevation-0: #073642;
|
||||
--surface-elevation-1: #0B3B46;
|
||||
--surface-elevation-2: #124853;
|
||||
--surface-elevation-3: #1A4E58;
|
||||
--surface-elevation-4: #31545D;
|
||||
--surface-elevation-5: #4E6870;
|
||||
--chess-light: #EEE8D5;
|
||||
--chess-dark: #268BD2;
|
||||
--chess-border: #73878B;
|
||||
--chess-coord-light: #268BD2;
|
||||
--chess-coord-dark: #EEE8D5;
|
||||
--status-discovered: #73878B;
|
||||
--status-discovered-rgb: 115, 135, 139;
|
||||
--surface-game-gradient: #124853;
|
||||
}
|
||||
|
||||
/* Gruvbox — retro groove neutrals with aqua actions instead of Classic rust. */
|
||||
:root[data-theme-family="gruvbox"][data-theme="light"] {
|
||||
--theme-page-rgb: 251, 241, 199;
|
||||
--theme-ink-rgb: 40, 40, 40;
|
||||
--theme-border-rgb: 189, 174, 147;
|
||||
--theme-strong-border-rgb: 124, 111, 100;
|
||||
--theme-focus: #076678;
|
||||
--bg-primary: #FBF1C7;
|
||||
--bg-secondary: #EBDBB2;
|
||||
--bg-tertiary: #D5C4A1;
|
||||
--bg-card: #F9F5D7;
|
||||
--bg-dark: #282828;
|
||||
--border: #BDAE93;
|
||||
--border-light: #A89984;
|
||||
--border-subtle: #DED2AC;
|
||||
--border-card: #BDAE93;
|
||||
--border-control: #7C6F64;
|
||||
--text-primary: #282828;
|
||||
--text-secondary: #504945;
|
||||
--text-muted: #665C54;
|
||||
--text-disabled: #928374;
|
||||
--accent: #076678;
|
||||
--accent-dim: #075766;
|
||||
--accent-dark: #054550;
|
||||
--accent-light: #D5E0D1;
|
||||
--accent-rgb: 7, 102, 120;
|
||||
--on-accent: #FFFFFF;
|
||||
--status-online: #79740E;
|
||||
--status-online-fg: #356A49;
|
||||
--status-online-rgb: 121, 116, 14;
|
||||
--status-error: #9D0006;
|
||||
--status-error-fg: #9D0006;
|
||||
--status-error-rgb: 157, 0, 6;
|
||||
--status-warning: #B57614;
|
||||
--status-warning-fg: #7A5700;
|
||||
--status-warning-rgb: 181, 118, 20;
|
||||
--status-info: #076678;
|
||||
--status-info-fg: #076678;
|
||||
--status-info-rgb: 7, 102, 120;
|
||||
--status-purple: #8F3F71;
|
||||
--status-purple-fg: #7A315F;
|
||||
--status-purple-rgb: 143, 63, 113;
|
||||
--ble-accent: #427B58;
|
||||
--ble-accent-fg: #356A49;
|
||||
--ble-accent-rgb: 66, 123, 88;
|
||||
--surface-elevation-0: #EBDBB2;
|
||||
--surface-elevation-1: #FBF1C7;
|
||||
--surface-elevation-2: #EBDBB2;
|
||||
--surface-elevation-3: #D5C4A1;
|
||||
--surface-elevation-4: #BDAE93;
|
||||
--surface-elevation-5: #A89984;
|
||||
--chess-light: #EBDBB2;
|
||||
--chess-dark: #83A598;
|
||||
--chess-border: #076678;
|
||||
--chess-coord-light: #076678;
|
||||
--chess-coord-dark: #FBF1C7;
|
||||
--status-discovered: #7C6F64;
|
||||
--status-discovered-rgb: 124, 111, 100;
|
||||
--surface-game-gradient: #D5C4A1;
|
||||
}
|
||||
|
||||
:root[data-theme-family="gruvbox"][data-theme="dark"] {
|
||||
--theme-page-rgb: 29, 32, 33;
|
||||
--theme-ink-rgb: 251, 241, 199;
|
||||
--theme-border-rgb: 80, 73, 69;
|
||||
--theme-strong-border-rgb: 139, 124, 112;
|
||||
--theme-focus: #83A598;
|
||||
--bg-primary: #1D2021;
|
||||
--bg-secondary: #282828;
|
||||
--bg-tertiary: #3C3836;
|
||||
--bg-card: #32302F;
|
||||
--bg-dark: #FBF1C7;
|
||||
--border: #504945;
|
||||
--border-light: #665C54;
|
||||
--border-subtle: #3C3836;
|
||||
--border-card: #504945;
|
||||
--border-control: #8B7C70;
|
||||
--text-primary: #FBF1C7;
|
||||
--text-secondary: #D5C4A1;
|
||||
--text-muted: #BDAE93;
|
||||
--text-disabled: #7C6F64;
|
||||
--accent: #83A598;
|
||||
--accent-dim: #8AAFA1;
|
||||
--accent-dark: #5C7A70;
|
||||
--accent-light: #354447;
|
||||
--accent-rgb: 131, 165, 152;
|
||||
--on-accent: #282828;
|
||||
--status-online: #B8BB26;
|
||||
--status-online-fg: #B8BB26;
|
||||
--status-online-rgb: 184, 187, 38;
|
||||
--status-error: #FF7C6C;
|
||||
--status-error-fg: #FF7C6C;
|
||||
--status-error-rgb: 255, 124, 108;
|
||||
--status-warning: #FABD2F;
|
||||
--status-warning-fg: #FABD2F;
|
||||
--status-warning-rgb: 250, 189, 47;
|
||||
--status-info: #9CB8AE;
|
||||
--status-info-fg: #9CB8AE;
|
||||
--status-info-rgb: 156, 184, 174;
|
||||
--status-purple: #D3869B;
|
||||
--status-purple-fg: #D3869B;
|
||||
--status-purple-rgb: 211, 134, 155;
|
||||
--ble-accent: #8EC07C;
|
||||
--ble-accent-fg: #8EC07C;
|
||||
--ble-accent-rgb: 142, 192, 124;
|
||||
--surface-elevation-0: #282828;
|
||||
--surface-elevation-1: #2D2B2A;
|
||||
--surface-elevation-2: #32302F;
|
||||
--surface-elevation-3: #3C3836;
|
||||
--surface-elevation-4: #504945;
|
||||
--surface-elevation-5: #665C54;
|
||||
--chess-light: #D5C4A1;
|
||||
--chess-dark: #83A598;
|
||||
--chess-border: #8B7C70;
|
||||
--chess-coord-light: #83A598;
|
||||
--chess-coord-dark: #FBF1C7;
|
||||
--status-discovered: #8B7C70;
|
||||
--status-discovered-rgb: 139, 124, 112;
|
||||
--surface-game-gradient: #3C3836;
|
||||
}
|
||||
|
||||
/* Catppuccin — Latte / Mocha, with Mauve as the family signature. */
|
||||
:root[data-theme-family="catppuccin"][data-theme="light"] {
|
||||
--theme-page-rgb: 239, 241, 245;
|
||||
--theme-ink-rgb: 76, 79, 105;
|
||||
--theme-border-rgb: 188, 192, 204;
|
||||
--theme-strong-border-rgb: 124, 127, 147;
|
||||
--theme-focus: #8839EF;
|
||||
--bg-primary: #EFF1F5;
|
||||
--bg-secondary: #E6E9EF;
|
||||
--bg-tertiary: #CCD0DA;
|
||||
--bg-card: #F8F9FC;
|
||||
--bg-dark: #4C4F69;
|
||||
--border: #BCC0CC;
|
||||
--border-light: #9CA0B0;
|
||||
--border-subtle: #DCE0E8;
|
||||
--border-card: #BCC0CC;
|
||||
--border-control: #7C7F93;
|
||||
--text-primary: #4C4F69;
|
||||
--text-secondary: #5C5F77;
|
||||
--text-muted: #666A80;
|
||||
--text-disabled: #8C8FA1;
|
||||
--accent: #8839EF;
|
||||
--accent-dim: #7130C7;
|
||||
--accent-dark: #5D249F;
|
||||
--accent-light: #E6D9F5;
|
||||
--accent-rgb: 136, 57, 239;
|
||||
--on-accent: #FFFFFF;
|
||||
--status-online: #40A02B;
|
||||
--status-online-fg: #286B20;
|
||||
--status-online-rgb: 64, 160, 43;
|
||||
--status-error: #D20F39;
|
||||
--status-error-fg: #B90D32;
|
||||
--status-error-rgb: 210, 15, 57;
|
||||
--status-warning: #DF8E1D;
|
||||
--status-warning-fg: #8A5700;
|
||||
--status-warning-rgb: 223, 142, 29;
|
||||
--status-info: #1E66F5;
|
||||
--status-info-fg: #1859D1;
|
||||
--status-info-rgb: 30, 102, 245;
|
||||
--status-purple: #8839EF;
|
||||
--status-purple-fg: #7130C7;
|
||||
--status-purple-rgb: 136, 57, 239;
|
||||
--ble-accent: #179299;
|
||||
--ble-accent-fg: #0E747C;
|
||||
--ble-accent-rgb: 23, 146, 153;
|
||||
--surface-elevation-0: #E6E9EF;
|
||||
--surface-elevation-1: #EFF1F5;
|
||||
--surface-elevation-2: #E6E9EF;
|
||||
--surface-elevation-3: #DCE0E8;
|
||||
--surface-elevation-4: #CCD0DA;
|
||||
--surface-elevation-5: #BCC0CC;
|
||||
--chess-light: #DCE0E8;
|
||||
--chess-dark: #7287FD;
|
||||
--chess-border: #8839EF;
|
||||
--chess-coord-light: #8839EF;
|
||||
--chess-coord-dark: #EFF1F5;
|
||||
--status-discovered: #7C7F93;
|
||||
--status-discovered-rgb: 124, 127, 147;
|
||||
--surface-game-gradient: #DCE0E8;
|
||||
}
|
||||
|
||||
:root[data-theme-family="catppuccin"][data-theme="dark"] {
|
||||
--theme-page-rgb: 17, 17, 27;
|
||||
--theme-ink-rgb: 205, 214, 244;
|
||||
--theme-border-rgb: 88, 91, 112;
|
||||
--theme-strong-border-rgb: 127, 132, 156;
|
||||
--theme-focus: #B4BEFE;
|
||||
--bg-primary: #11111B;
|
||||
--bg-secondary: #1E1E2E;
|
||||
--bg-tertiary: #45475A;
|
||||
--bg-card: #313244;
|
||||
--bg-dark: #CDD6F4;
|
||||
--border: #585B70;
|
||||
--border-light: #6C7086;
|
||||
--border-subtle: #45475A;
|
||||
--border-card: #585B70;
|
||||
--border-control: #7F849C;
|
||||
--text-primary: #CDD6F4;
|
||||
--text-secondary: #BAC2DE;
|
||||
--text-muted: #A6ADC8;
|
||||
--text-disabled: #6C7086;
|
||||
--accent: #CBA6F7;
|
||||
--accent-dim: #B893E6;
|
||||
--accent-dark: #A77CD8;
|
||||
--accent-light: #3B3350;
|
||||
--accent-rgb: 203, 166, 247;
|
||||
--on-accent: #1E1E2E;
|
||||
--status-online: #A6E3A1;
|
||||
--status-online-fg: #A6E3A1;
|
||||
--status-online-rgb: 166, 227, 161;
|
||||
--status-error: #F38BA8;
|
||||
--status-error-fg: #F38BA8;
|
||||
--status-error-rgb: 243, 139, 168;
|
||||
--status-warning: #F9E2AF;
|
||||
--status-warning-fg: #F9E2AF;
|
||||
--status-warning-rgb: 249, 226, 175;
|
||||
--status-info: #89B4FA;
|
||||
--status-info-fg: #89B4FA;
|
||||
--status-info-rgb: 137, 180, 250;
|
||||
--status-purple: #CBA6F7;
|
||||
--status-purple-fg: #CBA6F7;
|
||||
--status-purple-rgb: 203, 166, 247;
|
||||
--ble-accent: #94E2D5;
|
||||
--ble-accent-fg: #94E2D5;
|
||||
--ble-accent-rgb: 148, 226, 213;
|
||||
--surface-elevation-0: #1E1E2E;
|
||||
--surface-elevation-1: #262637;
|
||||
--surface-elevation-2: #313244;
|
||||
--surface-elevation-3: #45475A;
|
||||
--surface-elevation-4: #585B70;
|
||||
--surface-elevation-5: #6C7086;
|
||||
--chess-light: #BAC2DE;
|
||||
--chess-dark: #7287FD;
|
||||
--chess-border: #B4BEFE;
|
||||
--chess-coord-light: #7287FD;
|
||||
--chess-coord-dark: #CDD6F4;
|
||||
--status-discovered: #7F849C;
|
||||
--status-discovered-rgb: 127, 132, 156;
|
||||
--surface-game-gradient: #45475A;
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
--theme-page-rgb: 250, 247, 243;
|
||||
--theme-ink-rgb: 36, 31, 28;
|
||||
--theme-border-rgb: 222, 212, 200;
|
||||
--theme-strong-border-rgb: 146, 134, 124;
|
||||
--theme-focus: #B14F24;
|
||||
|
||||
--bg-primary: #FAF7F3;
|
||||
--bg-secondary: #F3EFEA;
|
||||
--bg-tertiary: #ECE6DE;
|
||||
|
|
@ -100,7 +106,7 @@
|
|||
--border-surface-soft: var(--divider);
|
||||
--border-surface-strong: var(--divider-strong);
|
||||
--border-row: var(--divider);
|
||||
--border-control: var(--border);
|
||||
--border-control: #92867C;
|
||||
--bg-elevated: var(--surface-panel);
|
||||
|
||||
--control-height-xs: 28px;
|
||||
|
|
@ -327,6 +333,12 @@
|
|||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--theme-page-rgb: 24, 23, 26;
|
||||
--theme-ink-rgb: 242, 238, 234;
|
||||
--theme-border-rgb: 50, 46, 48;
|
||||
--theme-strong-border-rgb: 116, 105, 112;
|
||||
--theme-focus: #D2693B;
|
||||
|
||||
--bg-primary: #18171a;
|
||||
--bg-secondary: #1f1d20;
|
||||
--bg-tertiary: #26242a;
|
||||
|
|
@ -343,11 +355,11 @@
|
|||
--text-disabled: #6F6661;
|
||||
|
||||
--accent: #D2693B;
|
||||
--accent-dim: #B85829;
|
||||
--accent-dim: #D9784B;
|
||||
--accent-dark: #B85829;
|
||||
--accent-light: #2a1f18;
|
||||
--accent-rgb: 210, 105, 59;
|
||||
--on-accent: #FFFFFF;
|
||||
--on-accent: #18171A;
|
||||
--accent-a05: rgba(var(--accent-rgb), 0.05);
|
||||
--accent-a12: rgba(var(--accent-rgb), 0.12);
|
||||
--accent-a15: rgba(var(--accent-rgb), 0.15);
|
||||
|
|
@ -413,7 +425,7 @@
|
|||
--border-surface-soft: var(--divider);
|
||||
--border-surface-strong: var(--divider-strong);
|
||||
--border-row: var(--divider);
|
||||
--border-control: var(--border);
|
||||
--border-control: #746970;
|
||||
--bg-elevated: var(--surface-panel);
|
||||
|
||||
--divider: rgba(50, 46, 48, 0.82);
|
||||
|
|
@ -502,6 +514,12 @@
|
|||
:root:not([data-theme]) {
|
||||
color-scheme: dark;
|
||||
|
||||
--theme-page-rgb: 24, 23, 26;
|
||||
--theme-ink-rgb: 242, 238, 234;
|
||||
--theme-border-rgb: 50, 46, 48;
|
||||
--theme-strong-border-rgb: 116, 105, 112;
|
||||
--theme-focus: #D2693B;
|
||||
|
||||
--bg-primary: #18171a;
|
||||
--bg-secondary: #1f1d20;
|
||||
--bg-tertiary: #26242a;
|
||||
|
|
@ -518,11 +536,11 @@
|
|||
--text-disabled: #6F6661;
|
||||
|
||||
--accent: #D2693B;
|
||||
--accent-dim: #B85829;
|
||||
--accent-dim: #D9784B;
|
||||
--accent-dark: #B85829;
|
||||
--accent-light: #2a1f18;
|
||||
--accent-rgb: 210, 105, 59;
|
||||
--on-accent: #FFFFFF;
|
||||
--on-accent: #18171A;
|
||||
--accent-a05: rgba(var(--accent-rgb), 0.05);
|
||||
--accent-a12: rgba(var(--accent-rgb), 0.12);
|
||||
--accent-a15: rgba(var(--accent-rgb), 0.15);
|
||||
|
|
@ -588,7 +606,7 @@
|
|||
--border-surface-soft: var(--divider);
|
||||
--border-surface-strong: var(--divider-strong);
|
||||
--border-row: var(--divider);
|
||||
--border-control: var(--border);
|
||||
--border-control: #746970;
|
||||
--bg-elevated: var(--surface-panel);
|
||||
|
||||
--divider: rgba(50, 46, 48, 0.82);
|
||||
|
|
|
|||
|
|
@ -2746,8 +2746,8 @@ body.setup-active .main-content {
|
|||
#setup-finish-btn,
|
||||
#setup-mnemonic-continue-btn {
|
||||
margin-top: var(--space-7);
|
||||
background: linear-gradient(135deg, var(--accent-dim), var(--accent-dark));
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
border: none;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
|
|
@ -2760,7 +2760,7 @@ body.setup-active .main-content {
|
|||
}
|
||||
#setup-generate-btn:hover,
|
||||
#setup-finish-btn:hover,
|
||||
#setup-mnemonic-continue-btn:not(:disabled):hover { filter: brightness(1.15); }
|
||||
#setup-mnemonic-continue-btn:not(:disabled):hover { filter: saturate(1.08); }
|
||||
#setup-generate-btn:active,
|
||||
#setup-finish-btn:active,
|
||||
#setup-mnemonic-continue-btn:not(:disabled):active { transform: scale(0.97); }
|
||||
|
|
@ -3286,7 +3286,7 @@ body.setup-active .main-content {
|
|||
}
|
||||
.identity-select-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
background: var(--hover-medium);
|
||||
}
|
||||
|
||||
.identity-actions-grid {
|
||||
|
|
@ -3731,6 +3731,137 @@ body.setup-active .main-content {
|
|||
border: 1px solid var(--accent-border);
|
||||
}
|
||||
|
||||
.settings-theme-family-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
.theme-family-picker {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.theme-family-legend {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
.theme-family-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.theme-family-option {
|
||||
position: relative;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.theme-family-option input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.theme-family-card {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 86px;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
color: var(--text-secondary);
|
||||
background: var(--surface-control);
|
||||
border: 1px solid var(--border-control);
|
||||
border-radius: var(--radius-lg-plus);
|
||||
transition: border-color var(--transition-fast), background var(--transition-fast),
|
||||
box-shadow var(--transition-fast), color var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
.theme-family-option:hover .theme-family-card {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-control-hover);
|
||||
border-color: var(--border-surface-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.theme-family-option input:focus-visible + .theme-family-card {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.theme-family-option input:checked + .theme-family-card {
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-border);
|
||||
}
|
||||
.theme-family-preview {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
height: 42px;
|
||||
overflow: hidden;
|
||||
background: var(--preview-light-bg, var(--bg-primary));
|
||||
border: 1px solid var(--border-surface-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.theme-family-preview-half {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.theme-family-preview-light { background: var(--preview-light-bg, var(--bg-primary)); }
|
||||
.theme-family-preview-dark { background: var(--preview-dark-bg, var(--bg-dark)); }
|
||||
.theme-family-preview-half::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
bottom: 7px;
|
||||
left: 7px;
|
||||
background: var(--preview-light-panel, var(--bg-card));
|
||||
border-radius: var(--radius-xs);
|
||||
}
|
||||
.theme-family-preview-dark::before { background: var(--preview-dark-panel, var(--bg-tertiary)); }
|
||||
.theme-family-preview-half::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
width: 12px;
|
||||
height: 3px;
|
||||
background: var(--preview-light-accent, var(--accent));
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
.theme-family-preview-dark::after { background: var(--preview-dark-accent, var(--accent)); }
|
||||
.theme-family-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--type-weight-semibold);
|
||||
line-height: var(--type-leading-tight);
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.theme-family-picker[aria-busy="true"] .theme-family-option {
|
||||
pointer-events: none;
|
||||
opacity: var(--opacity-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.theme-family-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
[data-text-scale-tier="large"] .theme-family-grid,
|
||||
[data-text-scale-tier="xlarge"] .theme-family-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.view-grid-identity {
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
|
|
|
|||
|
|
@ -1206,7 +1206,7 @@
|
|||
.bottom-sheet-badge {
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
color: var(--on-accent);
|
||||
font-size: var(--text-2xs);
|
||||
font-weight: 600;
|
||||
min-width: 18px;
|
||||
|
|
@ -1375,7 +1375,7 @@
|
|||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(var(--ble-accent-rgb), 0.24), rgba(var(--accent-rgb), 0.18)),
|
||||
rgba(10, 14, 18, 0.72);
|
||||
var(--chess-border);
|
||||
box-shadow: 0 18px 38px rgba(0,0,0,0.22), 0 0 0 1px rgba(255,255,255,0.06);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
|
@ -1390,8 +1390,8 @@
|
|||
cursor: default;
|
||||
transition: background-color 120ms ease-out;
|
||||
}
|
||||
.chess-square.light { background: #dde3eb; }
|
||||
.chess-square.dark { background: #586f87; }
|
||||
.chess-square.light { background: var(--chess-light); }
|
||||
.chess-square.dark { background: var(--chess-dark); }
|
||||
.chess-square.clickable { cursor: pointer; }
|
||||
.chess-square.clickable:hover {
|
||||
filter: brightness(1.08);
|
||||
|
|
@ -1399,11 +1399,11 @@
|
|||
|
||||
.chess-square.last-move-from,
|
||||
.chess-square.last-move-to {
|
||||
background: color-mix(in oklch, var(--accent) 28%, var(--last-move-base, #edeed1));
|
||||
background: color-mix(in oklch, var(--accent) 28%, var(--chess-light));
|
||||
}
|
||||
.chess-square.dark.last-move-from,
|
||||
.chess-square.dark.last-move-to {
|
||||
background: color-mix(in oklch, var(--accent) 30%, #586f87);
|
||||
background: color-mix(in oklch, var(--accent) 30%, var(--chess-dark));
|
||||
}
|
||||
|
||||
.chess-square.selected {
|
||||
|
|
@ -1429,12 +1429,13 @@
|
|||
.chess-square.dark.legal-target::before { background: rgba(0,0,0,0.45); }
|
||||
|
||||
.chess-square.in-check {
|
||||
background: radial-gradient(circle, #e83b3b 0%, #cc3030 45%, transparent 70%),
|
||||
var(--check-square-bg, #dce1e8);
|
||||
background: radial-gradient(circle, rgba(var(--status-error-rgb), 0.95) 0%, rgba(var(--status-error-rgb), 0.78) 45%, transparent 70%),
|
||||
var(--chess-light);
|
||||
animation: chessCheckPulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
.chess-square.dark.in-check {
|
||||
background: radial-gradient(circle, #e83b3b 0%, #cc3030 45%, transparent 70%), #5f7185;
|
||||
background: radial-gradient(circle, rgba(var(--status-error-rgb), 0.95) 0%, rgba(var(--status-error-rgb), 0.78) 45%, transparent 70%),
|
||||
var(--chess-dark);
|
||||
}
|
||||
@keyframes chessCheckPulse {
|
||||
0%, 100% { filter: brightness(1); }
|
||||
|
|
@ -1461,15 +1462,15 @@
|
|||
}
|
||||
.chess-coord-rank { top: 1px; left: 3px; }
|
||||
.chess-coord-file { bottom: 0; right: 3px; }
|
||||
.chess-square.light .chess-coord { color: #5f7185; }
|
||||
.chess-square.dark .chess-coord { color: #dce1e8; }
|
||||
.chess-square.light .chess-coord { color: var(--chess-coord-light); }
|
||||
.chess-square.dark .chess-coord { color: var(--chess-coord-dark); }
|
||||
|
||||
.chess-promotion-chooser {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface, #fff);
|
||||
background: var(--surface-panel);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: 0 4px 18px rgba(0,0,0,0.4);
|
||||
overflow: hidden;
|
||||
|
|
@ -1490,7 +1491,7 @@
|
|||
.chess-board-overlay {
|
||||
position: absolute; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(28,35,51,0.55);
|
||||
background: var(--surface-overlay);
|
||||
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
||||
border-radius: var(--radius-md); z-index: 2;
|
||||
font-weight: 600; color: var(--text-primary); text-align: center;
|
||||
|
|
@ -1502,7 +1503,7 @@
|
|||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(var(--accent-rgb), 0.18), rgba(var(--ble-accent-rgb), 0.12)),
|
||||
rgba(28,35,51,0.78);
|
||||
var(--surface-overlay);
|
||||
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
|
||||
border-radius: var(--radius-md); z-index: 2; gap: 6px;
|
||||
pointer-events: none;
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@
|
|||
}
|
||||
|
||||
@keyframes btnSuccessFlash {
|
||||
0%, 40% { background: var(--accent); color: #fff; }
|
||||
0%, 40% { background: var(--accent); color: var(--on-accent); }
|
||||
100% { background: transparent; color: inherit; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -579,6 +579,7 @@
|
|||
.log-filter, .panel-header-btn, .modal-close, .mobile-back-btn,
|
||||
.toolbar-dropdown-btn, .toolbar-dropdown-item,
|
||||
.theme-toggle-btn, .theme-toggle, .selector-badge, .settings-row,
|
||||
.theme-family-option,
|
||||
.settings-nav-item, .settings-mobile-back-btn,
|
||||
.emoji-picker-item {
|
||||
touch-action: manipulation;
|
||||
|
|
@ -635,6 +636,8 @@
|
|||
}
|
||||
|
||||
.theme-toggle-btn { min-width: 44px; min-height: 44px; }
|
||||
.theme-family-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.theme-family-option:hover .theme-family-card { transform: none; }
|
||||
.selector-badge { min-height: 44px; padding: var(--space-4) var(--space-6); }
|
||||
.settings-row { min-height: 48px; }
|
||||
.settings-radio-option span { min-height: 40px; min-width: 58px; }
|
||||
|
|
@ -4178,3 +4181,9 @@ html[data-text-scale-tier="xlarge"] .activity-event-details > div {
|
|||
column-gap: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
[data-text-scale-tier="xlarge"] .theme-family-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,8 +114,8 @@
|
|||
if (appId === 'chess') {
|
||||
var cs = getComputedStyle(document.documentElement);
|
||||
opts.colors = [
|
||||
'#dce1e8',
|
||||
'#5f7185',
|
||||
(cs.getPropertyValue('--chess-light') || '#D4BC9E').trim(),
|
||||
(cs.getPropertyValue('--chess-dark') || '#9B8365').trim(),
|
||||
(cs.getPropertyValue('--accent') || '#D2693B').trim(),
|
||||
(cs.getPropertyValue('--status-online') || '#2E8B57').trim(),
|
||||
(cs.getPropertyValue('--ble-accent') || '#0E9AA7').trim(),
|
||||
|
|
|
|||
|
|
@ -1503,6 +1503,14 @@ function applyAppSettingsPayload(data) {
|
|||
if (data.text_scale_percent !== undefined && RS.textScale) {
|
||||
RS.textScale.commit(data.text_scale_percent);
|
||||
}
|
||||
if (RS.appearance && (data.theme_family !== undefined || data.theme_mode !== undefined)) {
|
||||
var appearance = RS.appearance.get();
|
||||
RS.appearance.commit(
|
||||
data.theme_family !== undefined ? data.theme_family : appearance.family,
|
||||
data.theme_mode !== undefined ? data.theme_mode : appearance.preference
|
||||
);
|
||||
syncAppearanceControls();
|
||||
}
|
||||
var hwBadge = document.getElementById('hw-lock-timeout-select');
|
||||
if (hwBadge && data.hardware_session_timeout !== undefined) {
|
||||
var t = parseInt(data.hardware_session_timeout, 10);
|
||||
|
|
@ -1896,6 +1904,12 @@ function confirmDangerAction(action, onClose) {
|
|||
RS.invoke('api_factory_reset')
|
||||
.then(function() {
|
||||
if (typeof clearFirstRunAnnounceHintDone === 'function') clearFirstRunAnnounceHintDone();
|
||||
if (RS.appearance) {
|
||||
RS.appearance.commit(
|
||||
RS.appearance.DEFAULT_FAMILY,
|
||||
RS.appearance.DEFAULT_MODE
|
||||
);
|
||||
}
|
||||
// reload() re-requests tauri://localhost/. location.href='/'
|
||||
// breaks on dev-contaminated builds (TAURI_CONFIG leak → dev URL).
|
||||
setTimeout(function() { window.location.reload(); }, 1500);
|
||||
|
|
@ -1924,35 +1938,134 @@ function confirmDangerAction(action, onClose) {
|
|||
});
|
||||
}
|
||||
|
||||
var _themeToggleInitialized = false;
|
||||
var _appearanceControlsInitialized = false;
|
||||
var _appearanceSaving = false;
|
||||
var _hapticsToggleInitialized = false;
|
||||
var _textScaleInitialized = false;
|
||||
var _textScaleSaving = false;
|
||||
|
||||
function initThemeToggle() {
|
||||
function renderThemeFamilyPicker() {
|
||||
var grid = document.getElementById('theme-family-grid');
|
||||
if (!grid || grid.childElementCount || !RS.appearance) return;
|
||||
|
||||
RS.appearance.families.forEach(function(family) {
|
||||
var label = document.createElement('label');
|
||||
label.className = 'theme-family-option';
|
||||
label.setAttribute('data-family', family.id);
|
||||
label.title = family.name + ' — ' + family.description;
|
||||
|
||||
var input = document.createElement('input');
|
||||
input.type = 'radio';
|
||||
input.name = 'settings-theme-family';
|
||||
input.value = family.id;
|
||||
input.setAttribute('aria-label', family.name + ': ' + family.description);
|
||||
|
||||
var card = document.createElement('span');
|
||||
card.className = 'theme-family-card';
|
||||
|
||||
var preview = document.createElement('span');
|
||||
preview.className = 'theme-family-preview';
|
||||
preview.setAttribute('aria-hidden', 'true');
|
||||
preview.style.setProperty('--preview-light-bg', family.preview.light[0]);
|
||||
preview.style.setProperty('--preview-light-panel', family.preview.light[1]);
|
||||
preview.style.setProperty('--preview-light-accent', family.preview.light[2]);
|
||||
preview.style.setProperty('--preview-dark-bg', family.preview.dark[0]);
|
||||
preview.style.setProperty('--preview-dark-panel', family.preview.dark[1]);
|
||||
preview.style.setProperty('--preview-dark-accent', family.preview.dark[2]);
|
||||
|
||||
var light = document.createElement('span');
|
||||
light.className = 'theme-family-preview-half theme-family-preview-light';
|
||||
var dark = document.createElement('span');
|
||||
dark.className = 'theme-family-preview-half theme-family-preview-dark';
|
||||
preview.appendChild(light);
|
||||
preview.appendChild(dark);
|
||||
|
||||
var name = document.createElement('span');
|
||||
name.className = 'theme-family-name';
|
||||
name.textContent = family.name;
|
||||
|
||||
card.appendChild(preview);
|
||||
card.appendChild(name);
|
||||
label.appendChild(input);
|
||||
label.appendChild(card);
|
||||
grid.appendChild(label);
|
||||
});
|
||||
}
|
||||
|
||||
function syncAppearanceControls() {
|
||||
var toggle = document.getElementById('theme-toggle');
|
||||
if (!toggle) return;
|
||||
var picker = document.getElementById('theme-family-picker');
|
||||
if (!toggle || !picker || !RS.appearance) return;
|
||||
|
||||
var btns = toggle.querySelectorAll('.theme-toggle-btn');
|
||||
var pref = typeof getThemePreference === 'function' ? getThemePreference() : 'auto';
|
||||
|
||||
// Re-sync on every call so view re-entry / identity switch refreshes it.
|
||||
btns.forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-theme') === pref);
|
||||
renderThemeFamilyPicker();
|
||||
var current = RS.appearance.get();
|
||||
var familyInputs = picker.querySelectorAll('input[name="settings-theme-family"]');
|
||||
familyInputs.forEach(function(input) {
|
||||
input.checked = input.value === current.family;
|
||||
input.disabled = _appearanceSaving;
|
||||
});
|
||||
|
||||
if (!_themeToggleInitialized) {
|
||||
_themeToggleInitialized = true;
|
||||
btns.forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var theme = this.getAttribute('data-theme');
|
||||
if (typeof setTheme === 'function') setTheme(theme);
|
||||
btns.forEach(function(b) {
|
||||
b.classList.toggle('active', b.getAttribute('data-theme') === theme);
|
||||
});
|
||||
});
|
||||
var btns = toggle.querySelectorAll('.theme-toggle-btn');
|
||||
btns.forEach(function(btn) {
|
||||
var selected = btn.getAttribute('data-theme') === current.preference;
|
||||
btn.classList.toggle('active', selected);
|
||||
btn.setAttribute('aria-pressed', selected ? 'true' : 'false');
|
||||
btn.disabled = _appearanceSaving;
|
||||
});
|
||||
picker.setAttribute('aria-busy', _appearanceSaving ? 'true' : 'false');
|
||||
toggle.setAttribute('aria-busy', _appearanceSaving ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function saveAppearance(family, preference) {
|
||||
if (_appearanceSaving || !RS.appearance) return;
|
||||
var previous = RS.appearance.get();
|
||||
var next = RS.appearance.commit(family, preference);
|
||||
_appearanceSaving = true;
|
||||
syncAppearanceControls();
|
||||
|
||||
RS.invoke('set_appearance', {
|
||||
family: next.family,
|
||||
mode: next.preference
|
||||
}).then(function(result) {
|
||||
RS.appearance.commit(
|
||||
result && result.theme_family !== undefined ? result.theme_family : next.family,
|
||||
result && result.theme_mode !== undefined ? result.theme_mode : next.preference
|
||||
);
|
||||
}).catch(function(error) {
|
||||
RS.appearance.commit(previous.family, previous.preference);
|
||||
if (typeof showToast === 'function') {
|
||||
showToast((error && error.message) || 'Could not save appearance', 'toast-red', 4000);
|
||||
}
|
||||
}).then(function() {
|
||||
_appearanceSaving = false;
|
||||
syncAppearanceControls();
|
||||
});
|
||||
}
|
||||
|
||||
function initThemeToggle() {
|
||||
var toggle = document.getElementById('theme-toggle');
|
||||
var picker = document.getElementById('theme-family-picker');
|
||||
if (!toggle || !picker || !RS.appearance) return;
|
||||
|
||||
renderThemeFamilyPicker();
|
||||
syncAppearanceControls();
|
||||
|
||||
if (_appearanceControlsInitialized) return;
|
||||
_appearanceControlsInitialized = true;
|
||||
|
||||
picker.addEventListener('change', function(event) {
|
||||
var input = event.target.closest('input[name="settings-theme-family"]');
|
||||
if (!input || !input.checked) return;
|
||||
var current = RS.appearance.get();
|
||||
saveAppearance(input.value, current.preference);
|
||||
});
|
||||
toggle.querySelectorAll('.theme-toggle-btn').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
var current = RS.appearance.get();
|
||||
saveAppearance(current.family, this.getAttribute('data-theme'));
|
||||
});
|
||||
}
|
||||
});
|
||||
window.addEventListener('ratspeak-theme-changed', syncAppearanceControls);
|
||||
}
|
||||
|
||||
function initHapticsToggle() {
|
||||
|
|
|
|||
|
|
@ -1,57 +1,228 @@
|
|||
// Runs as IIFE in <head> before CSS to prevent FOUC.
|
||||
// Applies appearance before CSS paints. Keep this file dependency-free: it runs
|
||||
// in <head>, before the rest of the dashboard runtime is available.
|
||||
(function() {
|
||||
var STORAGE_KEY = 'rs-theme';
|
||||
var stored = null;
|
||||
try { stored = localStorage.getItem(STORAGE_KEY); } catch(e) {}
|
||||
'use strict';
|
||||
|
||||
if (stored === 'dark' || stored === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', stored);
|
||||
} else {
|
||||
var MODE_STORAGE_KEY = 'rs-theme';
|
||||
var FAMILY_STORAGE_KEY = 'rs-theme-family';
|
||||
var DEFAULT_FAMILY = 'ratspeak';
|
||||
var DEFAULT_MODE = 'auto';
|
||||
var MODES = ['light', 'auto', 'dark'];
|
||||
var lastNativeMode = null;
|
||||
var FAMILIES = [
|
||||
{
|
||||
id: 'ratspeak',
|
||||
name: 'Ratspeak',
|
||||
description: 'Warm clay and paper',
|
||||
preview: {
|
||||
light: ['#FAF7F3', '#FFFFFF', '#B14F24'],
|
||||
dark: ['#18171A', '#1D1B1E', '#D2693B']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'nord',
|
||||
name: 'Nord',
|
||||
description: 'Arctic blue and frost',
|
||||
preview: {
|
||||
light: ['#ECEFF4', '#F8FAFC', '#46658A'],
|
||||
dark: ['#252A34', '#3B4252', '#88C0D0']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'solarized',
|
||||
name: 'Solarized',
|
||||
description: 'Balanced cyan and amber',
|
||||
preview: {
|
||||
light: ['#FDF6E3', '#FFFCF0', '#0F6F91'],
|
||||
dark: ['#002B36', '#0B3B46', '#5AADE3']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'gruvbox',
|
||||
name: 'Gruvbox',
|
||||
description: 'Earthy retro contrast',
|
||||
preview: {
|
||||
light: ['#FBF1C7', '#F9F5D7', '#076678'],
|
||||
dark: ['#1D2021', '#32302F', '#83A598']
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'catppuccin',
|
||||
name: 'Catppuccin',
|
||||
description: 'Soft lavender and mauve',
|
||||
preview: {
|
||||
light: ['#EFF1F5', '#F8F9FC', '#8839EF'],
|
||||
dark: ['#11111B', '#313244', '#CBA6F7']
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function isFamily(value) {
|
||||
return FAMILIES.some(function(family) { return family.id === value; });
|
||||
}
|
||||
|
||||
function normalizeFamily(value) {
|
||||
return isFamily(value) ? value : DEFAULT_FAMILY;
|
||||
}
|
||||
|
||||
function normalizeMode(value) {
|
||||
return MODES.indexOf(value) !== -1 ? value : DEFAULT_MODE;
|
||||
}
|
||||
|
||||
function readStored(key) {
|
||||
try { return localStorage.getItem(key); } catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function removeStored(key) {
|
||||
try { localStorage.removeItem(key); } catch (_) {}
|
||||
}
|
||||
|
||||
function storedFamily() {
|
||||
var value = readStored(FAMILY_STORAGE_KEY);
|
||||
if (value && !isFamily(value)) removeStored(FAMILY_STORAGE_KEY);
|
||||
return normalizeFamily(value);
|
||||
}
|
||||
|
||||
function storedMode() {
|
||||
var value = readStored(MODE_STORAGE_KEY);
|
||||
if (value && MODES.indexOf(value) === -1) removeStored(MODE_STORAGE_KEY);
|
||||
return normalizeMode(value);
|
||||
}
|
||||
|
||||
function resolvedMode(preference) {
|
||||
var mode = normalizeMode(preference);
|
||||
if (mode !== 'auto') return mode;
|
||||
var prefersDark = window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
|
||||
return prefersDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function updateThemeColor() {
|
||||
function familyById(id) {
|
||||
var normalized = normalizeFamily(id);
|
||||
for (var i = 0; i < FAMILIES.length; i += 1) {
|
||||
if (FAMILIES[i].id === normalized) return FAMILIES[i];
|
||||
}
|
||||
return FAMILIES[0];
|
||||
}
|
||||
|
||||
function updateThemeColor(family, mode) {
|
||||
var meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (meta) {
|
||||
var isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
meta.setAttribute('content', isDark ? '#18171a' : '#FAF7F3');
|
||||
if (!meta) return;
|
||||
var entry = familyById(family);
|
||||
var preview = entry.preview[mode] || entry.preview.light;
|
||||
meta.setAttribute('content', preview[0]);
|
||||
}
|
||||
|
||||
function syncNativeMode(mode) {
|
||||
if (lastNativeMode === mode) return;
|
||||
if (window.RatspeakAndroid &&
|
||||
typeof window.RatspeakAndroid.setColorMode === 'function') {
|
||||
try {
|
||||
window.RatspeakAndroid.setColorMode(mode);
|
||||
lastNativeMode = mode;
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
if (window.__RATSPEAK_DESKTOP__ === true && window.RS &&
|
||||
typeof window.RS.invoke === 'function') {
|
||||
lastNativeMode = mode;
|
||||
window.RS.invoke('set_native_theme', { theme: mode }).catch(function() {
|
||||
lastNativeMode = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
updateThemeColor();
|
||||
|
||||
function writePreference(family, mode) {
|
||||
try {
|
||||
if (family === DEFAULT_FAMILY) localStorage.removeItem(FAMILY_STORAGE_KEY);
|
||||
else localStorage.setItem(FAMILY_STORAGE_KEY, family);
|
||||
if (mode === DEFAULT_MODE) localStorage.removeItem(MODE_STORAGE_KEY);
|
||||
else localStorage.setItem(MODE_STORAGE_KEY, mode);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function apply(familyValue, modeValue, committed, announce) {
|
||||
var family = normalizeFamily(familyValue);
|
||||
var preference = normalizeMode(modeValue);
|
||||
var mode = resolvedMode(preference);
|
||||
var root = document.documentElement;
|
||||
|
||||
root.setAttribute('data-theme-family', family);
|
||||
root.setAttribute('data-theme', mode);
|
||||
root.setAttribute('data-theme-preference', preference);
|
||||
|
||||
if (committed) writePreference(family, preference);
|
||||
updateThemeColor(family, mode);
|
||||
syncNativeMode(mode);
|
||||
|
||||
if (announce !== false && typeof window.CustomEvent === 'function') {
|
||||
window.dispatchEvent(new CustomEvent('ratspeak-theme-changed', {
|
||||
detail: {
|
||||
family: family,
|
||||
preference: preference,
|
||||
mode: mode,
|
||||
committed: !!committed
|
||||
}
|
||||
}));
|
||||
}
|
||||
return { family: family, preference: preference, mode: mode };
|
||||
}
|
||||
|
||||
window.RS = window.RS || {};
|
||||
window.RS.appearance = {
|
||||
DEFAULT_FAMILY: DEFAULT_FAMILY,
|
||||
DEFAULT_MODE: DEFAULT_MODE,
|
||||
families: FAMILIES,
|
||||
modes: MODES.slice(),
|
||||
get: function() {
|
||||
return {
|
||||
family: normalizeFamily(document.documentElement.getAttribute('data-theme-family')),
|
||||
preference: normalizeMode(document.documentElement.getAttribute('data-theme-preference')),
|
||||
mode: document.documentElement.getAttribute('data-theme') || 'light'
|
||||
};
|
||||
},
|
||||
preview: function(family, mode) { return apply(family, mode, false, true); },
|
||||
commit: function(family, mode) { return apply(family, mode, true, true); },
|
||||
normalizeFamily: normalizeFamily,
|
||||
normalizeMode: normalizeMode
|
||||
};
|
||||
|
||||
// Compatibility for callers that only know about the original mode API.
|
||||
window.setTheme = function(mode) {
|
||||
return window.RS.appearance.commit(window.RS.appearance.get().family, mode);
|
||||
};
|
||||
window.setThemeFamily = function(family) {
|
||||
var current = window.RS.appearance.get();
|
||||
return window.RS.appearance.commit(family, current.preference);
|
||||
};
|
||||
window.getTheme = function() {
|
||||
return window.RS.appearance.get().mode;
|
||||
};
|
||||
window.getThemePreference = function() {
|
||||
return window.RS.appearance.get().preference;
|
||||
};
|
||||
window.getThemeFamily = function() {
|
||||
return window.RS.appearance.get().family;
|
||||
};
|
||||
|
||||
apply(storedFamily(), storedMode(), false, false);
|
||||
|
||||
if (window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
||||
var current;
|
||||
try { current = localStorage.getItem(STORAGE_KEY); } catch(ex) {}
|
||||
if (!current) {
|
||||
document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
|
||||
updateThemeColor();
|
||||
}
|
||||
});
|
||||
var colorSchemeQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
var handleColorSchemeChange = function() {
|
||||
var current = window.RS.appearance.get();
|
||||
if (current.preference === 'auto') apply(current.family, 'auto', false, true);
|
||||
};
|
||||
if (typeof colorSchemeQuery.addEventListener === 'function') {
|
||||
colorSchemeQuery.addEventListener('change', handleColorSchemeChange);
|
||||
} else if (typeof colorSchemeQuery.addListener === 'function') {
|
||||
colorSchemeQuery.addListener(handleColorSchemeChange);
|
||||
}
|
||||
}
|
||||
|
||||
window.setTheme = function(theme) {
|
||||
if (theme === 'auto') {
|
||||
try { localStorage.removeItem(STORAGE_KEY); } catch(e) {}
|
||||
var prefersDark = window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
document.documentElement.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
|
||||
} else {
|
||||
try { localStorage.setItem(STORAGE_KEY, theme); } catch(e) {}
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
updateThemeColor();
|
||||
};
|
||||
|
||||
window.getTheme = function() {
|
||||
return document.documentElement.getAttribute('data-theme') || 'light';
|
||||
};
|
||||
|
||||
window.getThemePreference = function() {
|
||||
var s;
|
||||
try { s = localStorage.getItem(STORAGE_KEY); } catch(e) {}
|
||||
return s || 'auto';
|
||||
};
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lastNativeMode = null;
|
||||
syncNativeMode(window.RS.appearance.get().mode);
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ fn build_dashboard_css() {
|
|||
let out = dashboard_dir.join("static/style.css");
|
||||
let modules = [
|
||||
"00-tokens.css",
|
||||
"00-palettes.css",
|
||||
"01-reset.css",
|
||||
"02-typography.css",
|
||||
"03-scrollbar.css",
|
||||
|
|
|
|||
|
|
@ -363,6 +363,17 @@ class MainActivity : TauriActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun applySystemBarColorMode(mode: String) {
|
||||
if (mode != "light" && mode != "dark") return
|
||||
val isLight = mode == "light"
|
||||
handler.post {
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
// The foreground service (RatspeakService) owns mesh lifetime, but the BLE
|
||||
// GATT handle lives on this Activity. If the Activity is destroyed, close
|
||||
|
|
@ -529,9 +540,8 @@ class MainActivity : TauriActivity() {
|
|||
|
||||
/**
|
||||
* Poll the WebView's data-theme attribute and update system bar icon colors.
|
||||
* Runs every 3s for 30s after page load to catch theme changes during init,
|
||||
* then stops. User-initiated theme changes after that are cosmetic-only for
|
||||
* the status bar until next app restart.
|
||||
* Runs every 3s for 30s after page load as an initialization fallback.
|
||||
* Later user changes arrive immediately through setColorMode().
|
||||
*/
|
||||
private fun startThemePolling() {
|
||||
var pollCount = 0
|
||||
|
|
@ -545,15 +555,7 @@ class MainActivity : TauriActivity() {
|
|||
) { value ->
|
||||
// evaluateJavascript returns JSON-quoted string e.g. "\"dark\""
|
||||
val theme = value?.trim()?.removeSurrounding("\"") ?: ""
|
||||
if (theme == "light" || theme == "dark") {
|
||||
val isLight = theme == "light"
|
||||
handler.post {
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
}
|
||||
}
|
||||
applySystemBarColorMode(theme)
|
||||
}
|
||||
handler.postDelayed(this, 3000)
|
||||
}
|
||||
|
|
@ -1632,6 +1634,11 @@ class MainActivity : TauriActivity() {
|
|||
* BluetoothManager API (works on Android 13–16+).
|
||||
*/
|
||||
inner class BlePermissionBridge {
|
||||
@JavascriptInterface
|
||||
fun setColorMode(mode: String) {
|
||||
applySystemBarColorMode(mode)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun exportIdentityBackup(fileName: String, backupBase64: String) {
|
||||
val safeName = sanitizeIdentityBackupFileName(fileName)
|
||||
|
|
|
|||
|
|
@ -770,6 +770,8 @@ pub fn run() {
|
|||
ratspeak_tauri::commands::interfaces::set_developer_mode,
|
||||
ratspeak_tauri::commands::interfaces::set_announce_ratspeak_usage,
|
||||
ratspeak_tauri::commands::interfaces::set_activity_identity_protection,
|
||||
ratspeak_tauri::commands::interfaces::set_appearance,
|
||||
ratspeak_tauri::commands::interfaces::set_native_theme,
|
||||
ratspeak_tauri::commands::interfaces::set_text_scale,
|
||||
ratspeak_tauri::commands::interfaces::api_notification_settings,
|
||||
ratspeak_tauri::commands::interfaces::set_desktop_notifications,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue