Adding a component
This page is for working on the library itself. If you only want to use a component in your own project, the installation page is what you want.
1. The catalogue
scripts/catalogue.mjs is the list of what this
library is, independent of how any style draws it. Adding a line there is what makes
the component appear in the sidebar, the registry, the ⌘K palette and the CLI.
// scripts/catalogue.mjs — the list every style must implement
export const COMPONENTS = [
// …
['Callout', 'feedback', 'Short aside that sits inside running copy.'],
// …
];Write the description in terms of what the component does, never what it looks like — every style shares this one line.
2. Behaviour goes to core
Anything with no opinion about looks — keyboard handling, focus containment, parsing, sorting,
validation — belongs in src/lib/core/. There is no matte version of a
focus trap, and writing one per style is how the styles quietly drift apart in quality.
// src/lib/core/callout.ts — anything with no opinion about looks
export function calloutRole(tone: Tone): 'alert' | 'status' {
return tone === 'danger' ? 'alert' : 'status';
}3. One file per style
Svelte 5 runes, a Props interface extending
the matching HTML attributes, class merged
last so callers can override, and ...rest spread
onto the root element. The props and the behaviour must match across styles; the markup and the classes
are free to differ completely.
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { toneSoft, type Tone } from '../../core/tones.js';
interface Props extends HTMLAttributes<HTMLDivElement> {
tone?: Tone;
children: Snippet;
}
let { tone = 'brand', class: className = '', children, ...rest }: Props = $props();
</script>
<div class="p-4 font-sans {toneSoft[tone]} {className}" {...rest}>
{@render children()}
</div>Take colours from the tone maps, never as raw hex. Any user-visible string the component renders
on its own — a label, an aria-label, an empty-state
line — is written inline in English, and exposed as a prop when a caller would plausibly want to
change it.
4. A demo
Write it once, against matte. It is rendered
live on the component's page and shown as the code sample, so the two can never drift
apart. bun run demos copies it into every
other style with the import path rewritten — which is why the /paper page really mounts paper's component rather
than repainting matte's.
<script lang="ts">
import Callout from '$lib/styles/matte/Callout.svelte';
</script>
<Callout tone="accent">Something worth noticing.</Callout>5. Regenerate
bun run index # regenerates every style's barrel
bun run registry # fails if a style is missing the component
bun run exports # adds the subpath for each style
bun run lint # fails if any of the three is stale
bun run build # rebuilds dist/Every style's barrel is generated, so there is no export list to keep in step by hand. After this the component appears in the nav, the ⌘K palette and the CLI without further wiring.
Traps that fail silently
Interpolated class names
Tailwind scans source text. A class it never sees written out is never generated — no error, just an unstyled element.
<!-- Tailwind only sees literal class strings -->
<div class="bg-{tone}">…</div> <!-- generates nothing -->
<div class={toneFill[tone]}>…</div> <!-- correct -->Two colours for one property
Which wins is decided by the order the rules appear in the stylesheet, not the order in your class attribute. Set the colour once per variant, or use a side-specific utility.
<!-- two colours for the same property: CSS order decides, not class order -->
border border-hairline border-brand <!-- unpredictable -->
border border-hairline border-l-brand <!-- fine -->The guards catch the structural half
bun run lint fails when a style is missing a component from the catalogue,
and when the barrels, the exports map or the registry are stale — so a component cannot ship in one
style and not another. The parity suite then mounts every style and asserts they behave the same.
None of that can catch a missing Tailwind class; only looking at the result does.