Merge ticket/004-012-theme-consolidation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
+15
-1
@@ -18,12 +18,17 @@ use crate::alerts::AlertRule;
|
|||||||
pub type SettingsHandle = Arc<Mutex<Settings>>;
|
pub type SettingsHandle = Arc<Mutex<Settings>>;
|
||||||
|
|
||||||
/// User's preferred colour scheme for the webapp.
|
/// User's preferred colour scheme for the webapp.
|
||||||
|
///
|
||||||
|
/// `Dark` accepts the legacy `"grey"` serde value (ticket 004: "grey" was a
|
||||||
|
/// near-duplicate of "dark" and has been removed as a selectable option) so a
|
||||||
|
/// settings file saved before that change still loads cleanly instead of
|
||||||
|
/// falling back to `System`.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ColorScheme {
|
pub enum ColorScheme {
|
||||||
Light,
|
Light,
|
||||||
|
#[serde(alias = "grey")]
|
||||||
Dark,
|
Dark,
|
||||||
Grey,
|
|
||||||
Red,
|
Red,
|
||||||
Green,
|
Green,
|
||||||
Blue,
|
Blue,
|
||||||
@@ -316,6 +321,15 @@ mod tests {
|
|||||||
assert_eq!(reloaded, settings);
|
assert_eq!(reloaded, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_grey_color_scheme_deserializes_as_dark() {
|
||||||
|
let settings: Settings = serde_json::from_str(
|
||||||
|
r#"{"color_scheme":"grey","css_entries":[],"custom_theme_id":null,"profiles":{"names":[],"active_profile":null,"profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(settings.color_scheme, ColorScheme::Dark);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn round_trips_through_save_and_load() {
|
fn round_trips_through_save_and_load() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
# Remove duplicate "grey" theme, keep "dark"
|
|
||||||
|
|
||||||
Dark and grey are the same thing. Keep dark, remove grey.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- The "grey" theme option is removed from theme selection UI and settings.
|
|
||||||
- Any references/config values for "grey" are migrated or aliased to "dark" so existing saved settings don't break.
|
|
||||||
- "dark" remains as the single equivalent theme.
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# Merge "custom themes" and "configured/imported CSS" into one concept
|
|
||||||
|
|
||||||
Custom themes and configured/imported CSS are the same thing.
|
|
||||||
|
|
||||||
## Current behavior (notes)
|
|
||||||
- Imported CSS (as pasted content or file upload) is base64-encoded and stored in the settings file.
|
|
||||||
- The "name" field is not a file name — it's a human-readable name, labeled just "name" in the UI. It gets displayed alongside the other (built-in) themes.
|
|
||||||
- The built-in themes are also auto-inserted into the settings JSON.
|
|
||||||
- Imported CSS entries can be "deleted", which removes them from the settings JSON.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- The "custom themes" and "configured/imported CSS" features are consolidated into a single mechanism.
|
|
||||||
- Duplicate UI, settings, and code paths for the two are removed.
|
|
||||||
- Existing user configuration for either is migrated or aliased so it keeps working under the unified mechanism.
|
|
||||||
+139
-180
@@ -21,7 +21,6 @@ const COLOR_SCHEMES: ColorScheme[] = [
|
|||||||
'system',
|
'system',
|
||||||
'light',
|
'light',
|
||||||
'dark',
|
'dark',
|
||||||
'grey',
|
|
||||||
'red',
|
'red',
|
||||||
'green',
|
'green',
|
||||||
'blue',
|
'blue',
|
||||||
@@ -185,13 +184,13 @@ function PresetsDropdown({ panel }: { panel: ReturnType<typeof useSettingsPanel>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
|
function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
|
||||||
const [filename, setFilename] = useState('')
|
|
||||||
const [content, setContent] = useState('')
|
|
||||||
const [maxLogsInput, setMaxLogsInput] = useState('')
|
const [maxLogsInput, setMaxLogsInput] = useState('')
|
||||||
const [themeUploadError, setThemeUploadError] = useState<string | null>(null)
|
const [cssFilename, setCssFilename] = useState('')
|
||||||
const [themeFilename, setThemeFilename] = useState('')
|
const [cssContent, setCssContent] = useState('')
|
||||||
const [themeContent, setThemeContent] = useState('')
|
const [cssIsTheme, setCssIsTheme] = useState(false)
|
||||||
const [themeLinkUrl, setThemeLinkUrl] = useState('')
|
const [cssUploadError, setCssUploadError] = useState<string | null>(null)
|
||||||
|
const [cssLinkUrl, setCssLinkUrl] = useState('')
|
||||||
|
const [cssLinkIsTheme, setCssLinkIsTheme] = useState(false)
|
||||||
const [ntfyTopicInput, setNtfyTopicInput] = useState('')
|
const [ntfyTopicInput, setNtfyTopicInput] = useState('')
|
||||||
const [ntfyServerInput, setNtfyServerInput] = useState('')
|
const [ntfyServerInput, setNtfyServerInput] = useState('')
|
||||||
const [ntfyTestError, setNtfyTestError] = useState<string | null>(null)
|
const [ntfyTestError, setNtfyTestError] = useState<string | null>(null)
|
||||||
@@ -247,19 +246,41 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
panel.setMaxLogs(Math.floor(parsed))
|
panel.setMaxLogs(Math.floor(parsed))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleCssFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
setFilename(file.name)
|
setCssFilename(file.name)
|
||||||
setContent(await file.text())
|
setCssContent(await file.text())
|
||||||
|
setCssUploadError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleImport(e: React.FormEvent) {
|
// Single import path for both "configured/imported CSS" and "custom
|
||||||
|
// themes" (ticket 012) — `cssIsTheme` is the only thing that distinguishes
|
||||||
|
// them, so a theme upload is only additionally validated against the
|
||||||
|
// canonical `--c-*` variable set before it's sent.
|
||||||
|
async function handleCssImport(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!filename.trim() || !content.trim()) return
|
setCssUploadError(null)
|
||||||
await panel.importCssEntry(filename.trim(), content)
|
if (!cssFilename.trim() || !cssContent.trim()) return
|
||||||
setFilename('')
|
if (cssIsTheme) {
|
||||||
setContent('')
|
const { missing } = parseThemeCss(cssContent)
|
||||||
|
if (missing.length > 0) {
|
||||||
|
setCssUploadError(`Missing variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await panel.importCssEntry(cssFilename.trim(), cssContent, cssIsTheme)
|
||||||
|
setCssFilename('')
|
||||||
|
setCssContent('')
|
||||||
|
setCssIsTheme(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCssLink(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!cssLinkUrl.trim()) return
|
||||||
|
await panel.linkCssEntry(cssLinkUrl.trim(), cssLinkIsTheme)
|
||||||
|
setCssLinkUrl('')
|
||||||
|
setCssLinkIsTheme(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadBuiltinTheme(name: string) {
|
function downloadBuiltinTheme(name: string) {
|
||||||
@@ -268,7 +289,7 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
downloadThemeFile(`${name}.css`, generateThemeCss(vars))
|
downloadThemeFile(`${name}.css`, generateThemeCss(vars))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadThemeEntry(entry: CssEntry) {
|
async function downloadCssEntry(entry: CssEntry) {
|
||||||
if (entry.kind === 'imported') {
|
if (entry.kind === 'imported') {
|
||||||
const res = await fetch(cssContentUrl(entry.id))
|
const res = await fetch(cssContentUrl(entry.id))
|
||||||
const text = await res.text()
|
const text = await res.text()
|
||||||
@@ -278,38 +299,6 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleThemeFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
setThemeFilename(file.name)
|
|
||||||
setThemeContent(await file.text())
|
|
||||||
setThemeUploadError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleThemeUpload(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
setThemeUploadError(null)
|
|
||||||
if (!themeFilename.trim() || !themeContent.trim()) return
|
|
||||||
const { missing } = parseThemeCss(themeContent)
|
|
||||||
if (missing.length > 0) {
|
|
||||||
setThemeUploadError(`Missing variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await panel.importThemeEntry(themeFilename.trim(), themeContent)
|
|
||||||
setThemeFilename('')
|
|
||||||
setThemeContent('')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleThemeLink(e: React.FormEvent) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!themeLinkUrl.trim()) return
|
|
||||||
await panel.linkThemeEntry(themeLinkUrl.trim())
|
|
||||||
setThemeLinkUrl('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const themeEntries = panel.cssEntries.filter((entry) => entry.is_theme)
|
|
||||||
const arbitraryCssEntries = panel.cssEntries.filter((entry) => !entry.is_theme)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 text-sm text-text-secondary">
|
<div className="flex flex-col gap-5 text-sm text-text-secondary">
|
||||||
<section>
|
<section>
|
||||||
@@ -344,96 +333,6 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Custom themes</h3>
|
|
||||||
{themeEntries.length === 0 ? (
|
|
||||||
<div className="mb-2 text-xs text-text-faint">No custom themes yet.</div>
|
|
||||||
) : (
|
|
||||||
<ul className="mb-2 flex flex-col gap-1">
|
|
||||||
{themeEntries.map((entry) => (
|
|
||||||
<li
|
|
||||||
key={entry.id}
|
|
||||||
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => panel.setCustomTheme(entry.id)}
|
|
||||||
className={`min-w-0 flex-1 truncate text-left capitalize ${
|
|
||||||
panel.activeCustomTheme?.id === entry.id ? 'text-text' : 'text-text-muted hover:text-text'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{entry.filename ?? entry.path_or_url}
|
|
||||||
</button>
|
|
||||||
<div className="ml-2 flex shrink-0 items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => downloadThemeEntry(entry)}
|
|
||||||
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-text"
|
|
||||||
aria-label={`Download ${entry.filename ?? entry.path_or_url}`}
|
|
||||||
title="Download as .css"
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
{entry.kind === 'imported' ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => panel.removeCssEntry(entry.id)}
|
|
||||||
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
|
|
||||||
aria-label={`Delete ${entry.filename}`}
|
|
||||||
>
|
|
||||||
delete
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-text-faintest" title="Linked CSS isn't deletable here">
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleThemeUpload} className="flex flex-col gap-2">
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".css,text/css"
|
|
||||||
onChange={handleThemeFileChange}
|
|
||||||
className="text-xs text-text-muted file:mr-2 file:rounded file:border file:border-border-soft file:bg-surface-2 file:px-2 file:py-1 file:text-text"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="theme name.css"
|
|
||||||
value={themeFilename}
|
|
||||||
onChange={(e) => setThemeFilename(e.target.value)}
|
|
||||||
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
|
||||||
/>
|
|
||||||
{themeUploadError && <div className="text-xs text-danger-text-strong">{themeUploadError}</div>}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
Upload theme
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form onSubmit={handleThemeLink} className="mt-2 flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="https://example.com/theme.css"
|
|
||||||
value={themeLinkUrl}
|
|
||||||
onChange={(e) => setThemeLinkUrl(e.target.value)}
|
|
||||||
className="min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
|
||||||
>
|
|
||||||
Link theme
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Log retention</h3>
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Log retention</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -490,28 +389,97 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Import CSS</h3>
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<form onSubmit={handleImport} className="flex flex-col gap-2">
|
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">CSS</h3>
|
||||||
|
<span className="text-[11px] text-text-faint">
|
||||||
|
imported/linked stylesheets — check "Use as theme" to make one a selectable alternative to the built-in
|
||||||
|
colour schemes above instead of an always-on overlay
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{panel.cssEntries.length === 0 ? (
|
||||||
|
<div className="mb-2 text-xs text-text-faint">No CSS configured.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="mb-2 flex flex-col gap-1">
|
||||||
|
{panel.cssEntries.map((entry) => (
|
||||||
|
<li
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{entry.is_theme ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => panel.setCustomTheme(entry.id)}
|
||||||
|
className={`block w-full truncate text-left capitalize ${
|
||||||
|
panel.activeCustomTheme?.id === entry.id ? 'text-text' : 'text-text-muted hover:text-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.filename ?? entry.path_or_url}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="truncate text-text">{entry.filename ?? entry.path_or_url}</div>
|
||||||
|
)}
|
||||||
|
<div className="text-[10px] uppercase text-text-faint">
|
||||||
|
{entry.kind}
|
||||||
|
{entry.is_theme ? ' · theme' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-2 flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadCssEntry(entry)}
|
||||||
|
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-text"
|
||||||
|
aria-label={`Download ${entry.filename ?? entry.path_or_url}`}
|
||||||
|
title="Download as .css"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
{entry.kind === 'imported' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => panel.removeCssEntry(entry.id)}
|
||||||
|
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
|
||||||
|
aria-label={`Delete ${entry.filename}`}
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-faintest" title="Linked CSS isn't deletable here">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleCssImport} className="flex flex-col gap-2">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".css,text/css"
|
accept=".css,text/css"
|
||||||
onChange={handleFileChange}
|
onChange={handleCssFileChange}
|
||||||
className="text-xs text-text-muted file:mr-2 file:rounded file:border file:border-border-soft file:bg-surface-2 file:px-2 file:py-1 file:text-text"
|
className="text-xs text-text-muted file:mr-2 file:rounded file:border file:border-border-soft file:bg-surface-2 file:px-2 file:py-1 file:text-text"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="filename.css"
|
placeholder="name.css"
|
||||||
value={filename}
|
value={cssFilename}
|
||||||
onChange={(e) => setFilename(e.target.value)}
|
onChange={(e) => setCssFilename(e.target.value)}
|
||||||
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
placeholder="Paste CSS content here"
|
placeholder="Paste CSS content here"
|
||||||
value={content}
|
value={cssContent}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setCssContent(e.target.value)}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="resize-y rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
className="resize-y rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||||
/>
|
/>
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||||
|
<input type="checkbox" checked={cssIsTheme} onChange={(e) => setCssIsTheme(e.target.checked)} />
|
||||||
|
Use as theme
|
||||||
|
</label>
|
||||||
|
{cssUploadError && <div className="text-xs text-danger-text-strong">{cssUploadError}</div>}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
||||||
@@ -519,41 +487,32 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
Import
|
Import
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
<form onSubmit={handleCssLink} className="mt-2 flex flex-col gap-2">
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Configured CSS</h3>
|
<input
|
||||||
{arbitraryCssEntries.length === 0 ? (
|
type="text"
|
||||||
<div className="text-xs text-text-faint">No CSS configured.</div>
|
placeholder="https://example.com/style.css"
|
||||||
) : (
|
value={cssLinkUrl}
|
||||||
<ul className="flex flex-col gap-1">
|
onChange={(e) => setCssLinkUrl(e.target.value)}
|
||||||
{arbitraryCssEntries.map((entry) => (
|
className="min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||||
<li
|
/>
|
||||||
key={entry.id}
|
<div className="flex items-center gap-2">
|
||||||
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
|
<label className="flex items-center gap-1.5 text-xs text-text-muted">
|
||||||
>
|
<input
|
||||||
<div className="min-w-0">
|
type="checkbox"
|
||||||
<div className="truncate text-text">{entry.filename ?? entry.path_or_url}</div>
|
checked={cssLinkIsTheme}
|
||||||
<div className="text-[10px] uppercase text-text-faint">{entry.kind}</div>
|
onChange={(e) => setCssLinkIsTheme(e.target.checked)}
|
||||||
</div>
|
/>
|
||||||
{entry.kind === 'imported' ? (
|
Use as theme
|
||||||
<button
|
</label>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => panel.removeCssEntry(entry.id)}
|
type="submit"
|
||||||
className="ml-2 shrink-0 rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
|
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
||||||
aria-label={`Delete ${entry.filename}`}
|
>
|
||||||
>
|
Link
|
||||||
delete
|
</button>
|
||||||
</button>
|
</div>
|
||||||
) : (
|
</form>
|
||||||
<span className="ml-2 shrink-0 text-text-faintest" title="Linked CSS isn't deletable here">
|
|
||||||
—
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+7
-26
@@ -16,8 +16,13 @@
|
|||||||
* so re-declaring the `--c-*` variables under a `html.<theme>` selector
|
* so re-declaring the `--c-*` variables under a `html.<theme>` selector
|
||||||
* re-skins the whole app with no per-component changes.
|
* re-skins the whole app with no per-component changes.
|
||||||
*
|
*
|
||||||
* Derivation: `dark`/`grey` share one true black-based neutral (S=0) scale,
|
* Derivation: `dark` uses one true black-based neutral (S=0) scale, `light`
|
||||||
* `light` is its exact lightness mirror — these three stay grey throughout.
|
* is its exact lightness mirror — these two stay grey throughout. (There
|
||||||
|
* used to be a separate `grey` theme sharing `dark`'s neutral scale with
|
||||||
|
* only its accent trio differing; it was a near-duplicate of `dark` and was
|
||||||
|
* removed in favour of a single `dark` theme — ticket 004. Settings saved
|
||||||
|
* with `color_scheme: "grey"` still load fine: the backend aliases it to
|
||||||
|
* `dark`, see `ColorScheme` in `src/settings.rs`.)
|
||||||
* Each named theme (red/green/blue/yellow/pink/orange) instead tints its
|
* Each named theme (red/green/blue/yellow/pink/orange) instead tints its
|
||||||
* whole surface/border/text scale in its own hue at low saturation (so the
|
* whole surface/border/text scale in its own hue at low saturation (so the
|
||||||
* chrome itself reads as "a shade of red", not grey-with-a-red-light), and
|
* chrome itself reads as "a shade of red", not grey-with-a-red-light), and
|
||||||
@@ -88,30 +93,6 @@ html.light {
|
|||||||
--c-accent-text: #0369a1;
|
--c-accent-text: #0369a1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Canonical greyscale theme: identical neutral scale to :root (dark), so
|
|
||||||
every other theme's derivation above is measured against this one. */
|
|
||||||
html.grey {
|
|
||||||
--c-surface-0: #0a0a0a;
|
|
||||||
--c-surface-1: #171717;
|
|
||||||
--c-surface-2: #262626;
|
|
||||||
|
|
||||||
--c-border: #262626;
|
|
||||||
--c-border-soft: #404040;
|
|
||||||
--c-border-faint: #171717;
|
|
||||||
--c-border-strong: #a3a3a3;
|
|
||||||
|
|
||||||
--c-text: #f5f5f5;
|
|
||||||
--c-text-secondary: #d4d4d4;
|
|
||||||
--c-text-muted: #a3a3a3;
|
|
||||||
--c-text-subtle: #737373;
|
|
||||||
--c-text-faint: #525252;
|
|
||||||
--c-text-faintest: #404040;
|
|
||||||
|
|
||||||
--c-accent: #38bdf8;
|
|
||||||
--c-accent-soft: #0369a1;
|
|
||||||
--c-accent-text: #7dd3fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* base hue 0 (red): surfaces/borders/text tinted red; accent complement 180
|
/* base hue 0 (red): surfaces/borders/text tinted red; accent complement 180
|
||||||
(cyan), accent-soft composite 150 (spring green) */
|
(cyan), accent-soft composite 150 (spring green) */
|
||||||
html.red {
|
html.red {
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ export type ColorScheme =
|
|||||||
| 'light'
|
| 'light'
|
||||||
| 'dark'
|
| 'dark'
|
||||||
| 'system'
|
| 'system'
|
||||||
| 'grey'
|
|
||||||
| 'red'
|
| 'red'
|
||||||
| 'green'
|
| 'green'
|
||||||
| 'blue'
|
| 'blue'
|
||||||
|
|||||||
@@ -72,28 +72,6 @@ export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
|||||||
'--c-accent-soft': '#7dd3fc',
|
'--c-accent-soft': '#7dd3fc',
|
||||||
'--c-accent-text': '#0369a1',
|
'--c-accent-text': '#0369a1',
|
||||||
},
|
},
|
||||||
grey: {
|
|
||||||
'--c-surface-0': '#0a0a0a',
|
|
||||||
'--c-surface-1': '#171717',
|
|
||||||
'--c-surface-2': '#262626',
|
|
||||||
'--c-border': '#262626',
|
|
||||||
'--c-border-soft': '#404040',
|
|
||||||
'--c-border-faint': '#171717',
|
|
||||||
'--c-border-strong': '#a3a3a3',
|
|
||||||
'--c-text': '#f5f5f5',
|
|
||||||
'--c-text-secondary': '#d4d4d4',
|
|
||||||
'--c-text-muted': '#a3a3a3',
|
|
||||||
'--c-text-subtle': '#737373',
|
|
||||||
'--c-text-faint': '#525252',
|
|
||||||
'--c-text-faintest': '#404040',
|
|
||||||
'--c-danger-bg': '#450a0a',
|
|
||||||
'--c-danger-border': '#7f1d1d',
|
|
||||||
'--c-danger-text': '#f87171',
|
|
||||||
'--c-danger-text-strong': '#fca5a5',
|
|
||||||
'--c-accent': '#38bdf8',
|
|
||||||
'--c-accent-soft': '#0369a1',
|
|
||||||
'--c-accent-text': '#7dd3fc',
|
|
||||||
},
|
|
||||||
red: {
|
red: {
|
||||||
'--c-surface-0': 'hsl(0 32% 10%)',
|
'--c-surface-0': 'hsl(0 32% 10%)',
|
||||||
'--c-surface-1': 'hsl(0 28% 16%)',
|
'--c-surface-1': 'hsl(0 28% 16%)',
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const INJECTED_CSS_ATTR = 'data-ottyl-css-id'
|
|||||||
const INJECTED_THEME_ATTR = 'data-ottyl-theme-css-id'
|
const INJECTED_THEME_ATTR = 'data-ottyl-theme-css-id'
|
||||||
|
|
||||||
// Themes selectable independently of `system`, each backed by a `html.<name>` block in `index.css`.
|
// Themes selectable independently of `system`, each backed by a `html.<name>` block in `index.css`.
|
||||||
const THEME_CLASSES = ['light', 'dark', 'grey', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const
|
const THEME_CLASSES = ['light', 'dark', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const
|
||||||
|
|
||||||
// Reflects `colorScheme` onto `<html>` as a class + `data-color-scheme`; when a custom theme is active, no built-in class is added at all, since a class selector would out-specificity its injected `:root` override (see `applyCustomThemeCss`) and mask it.
|
// Reflects `colorScheme` onto `<html>` as a class + `data-color-scheme`; when a custom theme is active, no built-in class is added at all, since a class selector would out-specificity its injected `:root` override (see `applyCustomThemeCss`) and mask it.
|
||||||
function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) {
|
function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) {
|
||||||
@@ -162,28 +162,22 @@ export function useSettingsPanel() {
|
|||||||
[guarded],
|
[guarded],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// One entry point for both "configured/imported CSS" and "custom themes" —
|
||||||
|
// `isTheme` is the only thing distinguishing them (ticket 012), so there's
|
||||||
|
// no separate theme-only import path.
|
||||||
const importCssEntry = useCallback(
|
const importCssEntry = useCallback(
|
||||||
(filename: string, content: string) =>
|
(filename: string, content: string, isTheme = false) =>
|
||||||
guarded(async () => {
|
guarded(async () => {
|
||||||
await importCss(filename, content)
|
await importCss(filename, content, isTheme)
|
||||||
await refreshCss()
|
await refreshCss()
|
||||||
})(),
|
})(),
|
||||||
[guarded, refreshCss],
|
[guarded, refreshCss],
|
||||||
)
|
)
|
||||||
|
|
||||||
const importThemeEntry = useCallback(
|
const linkCssEntry = useCallback(
|
||||||
(filename: string, content: string) =>
|
(pathOrUrl: string, isTheme = false) =>
|
||||||
guarded(async () => {
|
guarded(async () => {
|
||||||
await importCss(filename, content, true)
|
await linkCss(pathOrUrl, isTheme)
|
||||||
await refreshCss()
|
|
||||||
})(),
|
|
||||||
[guarded, refreshCss],
|
|
||||||
)
|
|
||||||
|
|
||||||
const linkThemeEntry = useCallback(
|
|
||||||
(pathOrUrl: string) =>
|
|
||||||
guarded(async () => {
|
|
||||||
await linkCss(pathOrUrl, true)
|
|
||||||
await refreshCss()
|
await refreshCss()
|
||||||
})(),
|
})(),
|
||||||
[guarded, refreshCss],
|
[guarded, refreshCss],
|
||||||
@@ -241,8 +235,7 @@ export function useSettingsPanel() {
|
|||||||
setMaxLogs,
|
setMaxLogs,
|
||||||
setNtfySink,
|
setNtfySink,
|
||||||
importCssEntry,
|
importCssEntry,
|
||||||
importThemeEntry,
|
linkCssEntry,
|
||||||
linkThemeEntry,
|
|
||||||
removeCssEntry,
|
removeCssEntry,
|
||||||
addProfile,
|
addProfile,
|
||||||
removeProfile,
|
removeProfile,
|
||||||
|
|||||||
Reference in New Issue
Block a user