Merge ticket 047: unified style presets (colour + layout as one concept)
This commit is contained in:
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user