Skip to content

Design system

The admin has its own design system, browsable at /admin/design-system in any site that installs Ounce. It is deliberately absent from the admin sidebar, so you reach it by typing the URL. That browser shows you what exists. This page is the part that a browser cannot show: when to reach for it, and when to add to it.

The whole system exists for one reason. The admin sits beside a site it knows nothing about, so it cannot afford a personality. Black is the primary colour. Nothing in the admin should ever look like it is trying to be your brand.

  1. Extract a composite the moment something is reused. The second call site is the trigger, not the third.
  2. Tokens live in the foundation. src/ui/ui.ts for surfaces, pills, rows and buttons; src/ui/type.ts for the type scale. One definition, imported.
  3. A base component is one that is used more than once. A component nothing imports is not a foundation, it is a guess.
  4. Use as little colour as you can. Reach for the neutral ramp first, and assume the answer is grey.

ounce.tailwind.mjs sets theme.colors rather than theme.extend.colors, which replaces Tailwind’s palette outright:

colors: {
transparent, current, white, black,
gray: colors.neutral,
red: colors.red,
green: colors.green,
amber: colors.amber,
}

bg-blue-500 is not a class that renders the wrong blue. It is a class that does not exist, so it renders nothing, and you find out by looking. Four ramps is the whole vocabulary:

Ramp Means
gray every surface, border and letter
red destruction and failed validation, never decoration
green published, live, saved: a state that is good, not an action
amber hidden, draft, viewing-as: something needs a decision

A tone is a 100 background with an 800 foreground. Any other pairing fails contrast somewhere. Solid green or amber fills are for state dots only.

src/ui/type.ts declares four heading levels and seven text roles, each with a _USE sentence saying when it applies. Eight pixel sizes exist: 10, 11, 12, 13, 14, 16, 20, 28. A size outside that list has no role, and a role is the thing that tells the next person when to use it.

There is one button, and it is a function:

import { buttonClass } from '@ouncepage/core/ui/ui';
buttonClass('primary'); // bg-gray-900, hover to black
buttonClass('ghost', 'sm');
buttonClass('danger');
buttonClass('quiet', 'md', true); // icon only, square

It returns a class string, so it works on <button> and on <a> without a wrapper. Six variants, two sizes. If a screen needs a seventh variant, that is a conversation about the variant, not a one-off class.

A shared component must forward what it is given

Section titled “A shared component must forward what it is given”

This is the rule with teeth, because breaking it fails silently.

type Props = { level?: HeadingLevel } & Record<string, unknown>;
const { level = 2, ...rest } = Astro.props;
---
<h2 {...rest} class={classes}><slot /></h2>

Without the ...rest, a caller writing <Heading data-dialog-title> gets a heading with no attribute, no warning and no error. That exact omission once took every dialog in the admin out of service at the same time: move to a new path, move to trash, the unsaved-changes warning and the rich text link prompt all stopped opening, because the script that fills the dialog could not find its title element.

test/design-system.test.ts fails if any component in src/ui stops forwarding, and on off-palette colours, raw hex, off-scale sizes and hand-built button shapes. Run it before you add a component, not after.

Add a token when a value is used in two places. Add a component when markup is used in two places. Add a topic to src/design/topics.ts when you add either, so the browser stays the truth rather than a snapshot.

If you genuinely need to go outside the system, do it in one file, and add the exact string to the allowlist in the test with a comment-free, self-evident name. There is one such exception today: the SEO preview imitates a Google search result, so it uses Google’s link blue and 18px type. That is not a precedent; it is a screenshot.