Merge ticket 047: unified style presets (colour + layout as one concept)

This commit is contained in:
Hugh Kaznowski
2026-09-06 02:17:27 +01:00
4 changed files with 134 additions and 120 deletions
+8 -6
View File
@@ -50,9 +50,10 @@ const SECTIONS: DocSection[] = [
</ul> </ul>
<p className="font-semibold text-text">Settings</p> <p className="font-semibold text-text">Settings</p>
<p> <p>
One <em>settings</em> file per machine holds the colour scheme, imported/linked CSS, the ntfy sink, and One <em>settings</em> file per machine holds the active style (a built-in colour scheme or an
which profile is active. Settings only tracks profile pointers — the profiles themselves (params, metrics, imported/linked stylesheet — see below), the ntfy sink, and which profile is active. Settings only tracks
dashboards) are stored separately, next to the settings file by default. profile pointers — the profiles themselves (params, metrics, dashboards) are stored separately, next to the
settings file by default.
</p> </p>
<p className="font-semibold text-text">Profiles</p> <p className="font-semibold text-text">Profiles</p>
<p> <p>
@@ -424,9 +425,10 @@ const SECTIONS: DocSection[] = [
body: ( body: (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p> <p>
The Settings panel controls process-wide state: colour scheme, the ntfy sink alert rules deliver to, the The Settings panel controls process-wide state: the active style — one unified list of built-in colour
max number of logs kept in memory (blank means unlimited), and imported/linked CSS. Changes apply schemes and any imported/linked stylesheets, any of which can be a simple palette or a full layout
immediately and persist to the settings file on disk. There is no save button. redesign — the ntfy sink alert rules deliver to, and the max number of logs kept in memory (blank means
unlimited). Changes apply immediately and persist to the settings file on disk. There is no save button.
</p> </p>
<p className="font-semibold text-text">Worked example</p> <p className="font-semibold text-text">Worked example</p>
<p> <p>
+117 -112
View File
@@ -258,8 +258,10 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
setCssUploadError(null) setCssUploadError(null)
} }
// Every imported/linked stylesheet is always a selectable theme (ticket 020), so it's always // Every imported/linked stylesheet is always a selectable style, listed alongside the built-in
// validated against the canonical `--c-*` variable set before it's sent. // colour schemes (ticket 020/047), so it's always validated against the canonical `--c-*`
// variable set before it's sent — that's a floor, not a ceiling: the stylesheet is free to also
// carry rules for any other selector, up to and including a full layout redesign.
async function handleCssImport(e: React.FormEvent) { async function handleCssImport(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
setCssUploadError(null) setCssUploadError(null)
@@ -300,8 +302,14 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
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>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Colour scheme</h3> <div className="mb-2 flex items-center gap-1.5">
<div className="flex flex-wrap gap-1"> <h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Style</h3>
<span className="text-[11px] text-text-faint">
one list of interchangeable styles — a built-in palette or your own imported/linked stylesheet. Any of
them can be a simple colour swap or a full layout redesign; only one is active at a time.
</span>
</div>
<div className="mb-3 flex flex-wrap gap-1">
{COLOR_SCHEMES.map((scheme) => ( {COLOR_SCHEMES.map((scheme) => (
<div key={scheme} className="flex items-center"> <div key={scheme} className="flex items-center">
<button <button
@@ -329,6 +337,111 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
</div> </div>
))} ))}
</div> </div>
{panel.cssEntries.length === 0 ? (
<div className="mb-2 text-xs text-text-faint">No styles imported or linked yet.</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">
<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="text-[10px] uppercase text-text-faint">{entry.kind}</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>
)}
<p className="mb-2 text-[11px] text-text-faint">
Add your own style below — a stylesheet, uploaded or linked by URL/path. It must at least define every{' '}
<code>--c-*</code> variable the built-ins do, but nothing stops it from also targeting any other selector
in the app (layout, spacing, fonts, anything) — it isn't limited to palette overrides.
</p>
<form onSubmit={handleCssImport} className="flex flex-col gap-2">
<input
type="file"
accept=".css,text/css"
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"
/>
<input
type="text"
placeholder="enter name"
value={cssFilename}
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"
/>
<textarea
placeholder="Paste CSS content here"
value={cssContent}
onChange={(e) => setCssContent(e.target.value)}
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"
/>
{cssUploadError && <div className="text-xs text-danger-text-strong">{cssUploadError}</div>}
<button
type="submit"
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Import
</button>
</form>
<form onSubmit={handleCssLink} className="mt-2 flex flex-col gap-2">
<input
type="text"
placeholder="https://example.com/style.css"
value={cssLinkUrl}
onChange={(e) => setCssLinkUrl(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"
/>
<div className="flex items-center gap-2">
<button
type="submit"
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Link
</button>
</div>
</form>
</section> </section>
<section> <section>
@@ -384,114 +497,6 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
</button> </button>
</div> </div>
</section> </section>
<section>
<div className="mb-2 flex items-center gap-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">CSS</h3>
<span className="text-[11px] text-text-faint">
imported/linked stylesheets — each one becomes a selectable alternative to the built-in colour schemes
above
</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">
<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="text-[10px] uppercase text-text-faint">{entry.kind}</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
type="file"
accept=".css,text/css"
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"
/>
<input
type="text"
placeholder="enter name"
value={cssFilename}
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"
/>
<textarea
placeholder="Paste CSS content here"
value={cssContent}
onChange={(e) => setCssContent(e.target.value)}
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"
/>
{cssUploadError && <div className="text-xs text-danger-text-strong">{cssUploadError}</div>}
<button
type="submit"
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Import
</button>
</form>
<form onSubmit={handleCssLink} className="mt-2 flex flex-col gap-2">
<input
type="text"
placeholder="https://example.com/style.css"
value={cssLinkUrl}
onChange={(e) => setCssLinkUrl(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"
/>
<div className="flex items-center gap-2">
<button
type="submit"
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Link
</button>
</div>
</form>
</section>
</div> </div>
) )
} }
+7 -1
View File
@@ -1,4 +1,10 @@
// A theme file is a CSS file with one `:root { --c-*: ...; }` block defining every variable in `THEME_CSS_VARS`; `useSettingsPanel` applies the selected one as an unscoped stylesheet only while no built-in `html.<scheme>` class is present, so it wins the cascade without rewriting the selector. // A user-supplied style is any CSS file that defines every variable in `THEME_CSS_VARS` somewhere in
// a `:root { --c-*: ...; }` block (ticket 020/047: styles are selectable on equal footing with the
// built-ins, and there's no requirement that this block be the *only* thing the file contains — a
// style is free to also target any other selector, e.g. to restyle layout, spacing, or fonts, not
// just the palette). `useSettingsPanel` applies the selected one as an unscoped stylesheet only
// while no built-in `html.<scheme>` class is present, so it wins the cascade without rewriting the
// selector.
/** Canonical list of every `--c-*` variable every theme in `index.css` defines. */ /** Canonical list of every `--c-*` variable every theme in `index.css` defines. */
export const THEME_CSS_VARS = [ export const THEME_CSS_VARS = [
+2 -1
View File
@@ -153,7 +153,8 @@ export function useSettingsPanel() {
[guarded], [guarded],
) )
// Every entry is always a selectable theme (ticket 020), so there's a single import path. // Every entry is always a selectable style, on equal footing with the built-in colour schemes
// (ticket 020 unified the data model; ticket 047 unified the UI), so there's a single import path.
const importCssEntry = useCallback( const importCssEntry = useCallback(
(filename: string, content: string) => (filename: string, content: string) =>
guarded(async () => { guarded(async () => {