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

  • Change app settings settings.writeHighChanges the app settings.Apply the chosen theme to the window

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

VersionPublishedPlugin APISizePermissionsStatus
0.3.0latestOct 5, 2026>=2 <37.9 KB1 permissionListed

Reviews and comments

0 threads · 0 reviews

No comments yet.