Official
themes
The stock themes: presets of every colour, type size, corner and density token, chosen per light and dark mode and edited in Settings.
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/themes@0.3.0
Permissions in 0.3.0
Files
settings.ts7.5 KB
// The Themes section in Settings (`contributes.settings.view`): the preset of
// each mode, the presets, and the editor of the one picked. Every edit
// writes the plugin's settings, which `main.ts` applies at once.
// HTM whitespace is significant; keep these templates out of Prettier's HTML formatter.
import { html } from "convergence";
import type { Api, Element, ThemeTokens, ViewContext } from "convergence";
import { DEFAULT_SIZES } from "./presets.ts";
import { allPresets, chosen, duplicate, readSettings } from "./settings-model.ts";
import type { Preset, ThemeSettings } from "./settings-model.ts";
const column = { display: "flex", flexDirection: "column" } as const;
// The preset open in the editor, kept while the section is shown.
let editing: string | null = null;
const HEX = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
type Group = "type" | "shape" | "density";
const GROUPS: readonly { readonly key: Group; readonly title: string }[] = [
{ key: "type", title: "Type" },
{ key: "shape", title: "Shape" },
{ key: "density", title: "Density" },
];
// "fieldHeight" → "Field height".
const words = (key: string) => key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
export function render(ctx: ViewContext<object>, api: Api): Element {
const values = readSettings(api.settings.get());
const presets = allPresets(values);
const open = presets.find((preset) => preset.id === editing) ?? null;
const save = (next: ThemeSettings["presets"]) => api.settings.set("presets", next);
const choose = (mode: "light" | "dark", id: string) => api.settings.set("active", { ...values.active, [mode]: id });
const chooser = (mode: "light" | "dark") => html`
<setting title=${mode === "light" ? "Light theme" : "Dark theme"}
description=${`Drawn when the appearance is ${mode === "light" ? "Light" : "Dark"} (or the system's is).`}>
<select id=${`theme-${mode}`} value=${chosen(values, mode).id}
options=${presets.filter((preset) => preset.mode === mode).map((preset) => ({ value: preset.id, label: preset.name }))}
onChange=${(e: { value: string }) => choose(mode, e.value)} />
</setting>`;
const copyOf = (source: Preset) => {
const copy = duplicate(source, `${source.name} copy`, Object.keys(values.presets));
editing = copy.id;
void save({ ...values.presets, [copy.id]: { name: copy.name, mode: copy.mode, tokens: copy.tokens } }).then(() =>
choose(copy.mode, copy.id),
);
};
const row = (preset: Preset) => {
const used = chosen(values, preset.mode).id === preset.id;
const mode = preset.mode === "light" ? "Light" : "Dark";
return html`
<item key=${preset.id} id=${`preset-${preset.id}`} label=${preset.name}
sublabel=${`${mode}${preset.stock ? " · stock" : ""}${used ? " · in use" : ""}`}
icon=${preset.mode === "light" ? "sun" : "moon"} iconTone=${used ? "primary" : undefined} selected=${preset.id === editing}
actions=${[
...(used ? [] : [{ icon: "check", tooltip: `Use for ${mode}`, onClick: () => choose(preset.mode, preset.id) }]),
{ icon: "copy", tooltip: "Duplicate", onClick: () => copyOf(preset) },
...(preset.stock
? []
: [
{
icon: "trash-2",
tooltip: "Delete",
onClick: () => {
const { [preset.id]: _gone, ...rest } = values.presets;
if (editing === preset.id) editing = null;
void save(rest);
},
},
]),
]}
onClick=${() => {
editing = preset.id;
ctx.update();
}} />`;
};
return html`
<div ...${column} gap="large">
<div ...${column}>
<setting heading title="In use" description="The preset drawn in each appearance; System, Light and Dark in General choose between them." />
<card divided>${chooser("light")}${chooser("dark")}</card>
</div>
<div ...${column}>
<setting heading title="Presets" description="Open one to see its tokens. A stock preset is read only: duplicate it to edit a copy." />
<card divided>${presets.map(row)}</card>
</div>
${open ? editor(open) : null}
</div>`;
function editor(preset: Preset): Element {
if (preset.stock) {
return html`
<callout title=${preset.name} text="A stock preset does not change. Duplicate it to make one of your own to edit.">
<button id="duplicate-open" label="Duplicate" onClick=${() => copyOf(preset)} />
</callout>`;
}
const own = values.presets[preset.id]!;
const write = (tokens: ThemeTokens) => void save({ ...values.presets, [preset.id]: { ...own, tokens } });
const colors = preset.tokens.colors ?? {};
const sizes = (group: Group): Readonly<Record<string, number>> => ({
...DEFAULT_SIZES[group],
...((preset.tokens[group] ?? {}) as Readonly<Record<string, number>>),
});
return html`
<div ...${column} gap="large">
<div ...${column}>
<setting heading title=${preset.name} description="Every change applies at once." />
<card divided>
<setting title="Name">
<input id="preset-name" value=${preset.name}
onChange=${(e: { value: string }) => e.value.trim() && void save({ ...values.presets, [preset.id]: { ...own, name: e.value.trim(), tokens: { ...own.tokens, name: e.value.trim() } } })} />
</setting>
<setting title="Ground opacity" description="How much of the ground colour lies over the blurred desktop, 0 to 1 (macOS).">
<input id="preset-ground" value=${String(preset.tokens.groundAlpha ?? 0.95)}
onChange=${(e: { value: string }) => {
const n = Number(e.value);
if (e.value.trim() && n >= 0 && n <= 1) write({ ...own.tokens, groundAlpha: n });
}} />
</setting>
</card>
</div>
${GROUPS.map(
({ key, title }) => html`
<div ...${column}>
<setting heading title=${title} />
<card divided>
${Object.entries(sizes(key)).map(
([name, value]) => html`
<setting key=${name} title=${words(name)}>
<input id=${`preset-${key}-${name}`} value=${String(value)}
onChange=${(e: { value: string }) => {
const n = Number(e.value);
if (e.value.trim() && Number.isFinite(n) && n >= 0)
write({ ...own.tokens, [key]: { ...sizes(key), [name]: n } });
}} />
</setting>`,
)}
</card>
</div>`,
)}
<div ...${column}>
<setting heading title="Colors" description="Hex values, #rrggbb or #rrggbbaa." />
<card divided>
${Object.entries(colors)
.filter(([, value]) => value.startsWith("#"))
.map(
([name, value]) => html`
<setting key=${name} title=${name}>
<div display="flex" flexDirection="row" alignItems="center" gap="small">
<div width=${16} height=${16} borderRadius="sm" background=${value} bordered />
<input id=${`preset-color-${name}`} value=${value}
onChange=${(e: { value: string }) => {
const v = e.value.trim();
if (HEX.test(v)) write({ ...own.tokens, colors: { ...colors, [name]: v.toLowerCase() } });
}} />
</div>
</setting>`,
)}
</card>
</div>
</div>`;
}
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.3.0latest | Oct 5, 2026 | >=2 <3 | 7.9 KB | 1 permission | Listed |
No comments yet.