ui: add extensible appearance themes

This commit is contained in:
DeFiDude 2026-08-04 12:43:29 -06:00
parent c80568db55
commit 43f7f4598e
18 changed files with 1554 additions and 121 deletions

View file

@ -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;

View file

@ -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"));

View file

@ -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

View file

@ -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>

View 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');

View file

@ -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');

View 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;
}

View file

@ -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);

View file

@ -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);

View file

@ -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;

View file

@ -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; }
}

View file

@ -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;
}
}

View file

@ -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(),

View file

@ -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() {

View file

@ -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);
});
})();

View file

@ -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",

View file

@ -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 1316+).
*/
inner class BlePermissionBridge {
@JavascriptInterface
fun setColorMode(mode: String) {
applySystemBarColorMode(mode)
}
@JavascriptInterface
fun exportIdentityBackup(fileName: String, backupBase64: String) {
val safeName = sanitizeIdentityBackupFileName(fileName)

View file

@ -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,