roundel
Guides

Theme plugins

A plugin contributes tokens as #rrggbb colours under the family's one plugin shape; register() validates it, theme() merges it, later wins, and fly() holds it to the same contrast gate.

roundel hosts one plugin key, tokens. A plugin is the family's one plain object — a name and whatever keys the packages it targets read — so a brand plugin can carry roundel's tokens beside flagstaff's spinners, and each package keeps the key it understands and ignores the rest.

Writing one

brand.mjs
export default {
  name: 'acme-brand',
  tokens: { ground: '#ffffff', error: '#b3261e', ok: '#1b6e3a' },
};

A token in a plugin is a #rrggbb colour, and ground says what the colours will be read on.

Registering and flying it

register() validates the plugin; theme() merges everything registered into one theme; and fly() flies it, contrast gate and all. Registering never flies: a plugin cannot decide when colour is decided.

brand-app.mjs
import { contributions, register, theme } from 'roundel/plugin';
import { fly } from 'roundel/theme';
import { ok } from 'roundel/tokens';

import brand from './brand.mjs';
import { rt } from './rt.mjs';

register(brand);
register({ name: 'team-override', tokens: { ok: '#0a6b47' } });
fly(theme(), rt);

console.log(JSON.stringify(ok('passed')));
for (const { token, value, from, shadowed } of contributions()) {
  console.log(`${token} ${value} from ${from}${shadowed.length > 0 ? `, over ${shadowed.join(', ')}` : ''}`);
}
FORCE_COLOR=3 node brand-app.mjs
"\u001b[38;2;10;107;71mpassed\u001b[39m"
ground #ffffff from acme-brand
error #b3261e from acme-brand
ok #0a6b47 from team-override, over acme-brand

Later wins, like flat config, and contributions() says who won each token and whom it shadowed, so an override is never invisible. A plugin cannot smuggle an unreadable colour past the gate: fly() refuses a plugin's token below 4.5:1 exactly as it refuses a hand-written one.

Refusals

A plugin that would be wrong is refused at register() with a code and a fix, and a refused plugin does not half-register:

refusals.mjs
import { register } from 'roundel/plugin';

for (const plugin of [{ name: 'typo', tokens: { eror: '#b3261e' } }, { name: 'named', tokens: { ok: 'green' } }, { tokens: { ok: '#0a6b47' } }]) {
  try {
    register(plugin);
  } catch (error) {
    console.log(`${error.code}: ${error.message}`);
  }
}
node refusals.mjs
E_PLUGIN_SCHEMA: plugin "typo": "eror" is not a token
E_PLUGIN_SCHEMA: plugin "named": token "ok" is "green"
E_PLUGIN_SCHEMA: a plugin needs a name

A misspelt token is refused rather than silently dropped, and a plugin needs a name because a shadowed token has to be attributable. A plugin that declares a newer contract than this roundel knows is refused with E_PLUGIN_CONTRACT and the upgrade named.

Checking before it ships

npx roundel check loads a plugin file without registering it, validates it against the family's plugin schema, and lists what it contributes:

npx roundel check brand.mjs
acme-brand — 3 tokens
  ground  #ffffff
  error  #b3261e
  ok  #1b6e3a
acme-brand: ok
typo.mjs
export default { name: 'typo', tokens: { eror: '#b3261e' } };
npx roundel check typo.mjs
E_PLUGIN_SCHEMA: plugin "typo": "eror" is not a token
  fix: use one of error, warn, ok, hint, muted, command, flag, value, heading, ground

check is the schema, not the contrast gate. Contrast is judged by fly(), against the ground of the theme every registered plugin merges into, so a plugin with an unreadable colour passes check and is refused when the theme is flown. To see that verdict first, pass the plugin's tokens to audit() (Themes and contrast).

On this page