Paste this file into an AI agent along with a description of the design you want.
Create a new design folder in designs/.
A design owns colour, typography, spacing, radius, shadow, and the styling of
every component. It owns nothing about page structure — that belongs to layouts/.
The user will give you a brief. It might be a reference ("like Notion"), a mood ("warm, editorial, calm"), a constraint ("high contrast, accessible, dense"), or a brand. If the brief is thin, make strong choices and write down what you chose and why. A design that hedges is worse than a design that commits to something the user then asks you to change.
Read these four files completely. They are contracts, not suggestions, and most of the work below is defined by them:
shared/TOKENS.md— the CSS variables you must defineshared/COMPONENTS.md— the components you must render, in order, with fixed IDsshared/ACCESSIBILITY.md— the WCAG 2.2 AA baseline, the keyboard and ARIA contract per component, and the contrast minimums your tokens must cleardesigns/slate/DESIGN.md— a worked example of the output quality expected
Then open designs/slate/index.html in a browser so you know what you are aiming
at, and copy designs/_template/ as your starting point.
designs/<name>/
├── DESIGN.md Instructions for an AI implementing this design
├── theme.css Paste-ready CSS custom properties
└── index.html Kitchen sink demo, no build step
<name> is lowercase kebab-case, and it is the label a user will say out loud:
"use the warm-paper design". Name it for the feel, not the implementation.
The full token set from shared/TOKENS.md, in :root and .dark, using oklch()
values, in shadcn/ui's globals.css format so it can be pasted into a real project
verbatim.
Design the dark theme deliberately. Do not derive it by flipping lightness values —
that produces muddy, over-saturated dark modes. Dark surfaces usually want slightly
less chroma than their light counterparts, and dark-mode --primary usually wants
to be lighter and less saturated than light-mode --primary, not the same hue at a
different lightness.
Check contrast on every foreground/background pair. AA minimum: 4.5:1 for body text,
3:1 for large text and UI boundaries. Do not estimate these — run
node scripts/check.mjs, which computes the real ratios from your oklch() values
in both light and dark, and record what it reports in DESIGN.md. Estimated
ratios in this repo have historically been optimistic by 2–4×.
If a pair fails, fix the token — do not note it as a known issue.
Two pairs are missed almost every time, so check them deliberately:
--inputmust clear 3:1 against the surface behind it. If an outlined field's border is the only thing identifying it as a field, that border is a UI boundary under WCAG 1.4.11, not a decorative hairline.--borderand--inputare separate tokens so they can hold different values — use that.--destructivein dark mode. A fill that carries white text at AA in light mode usually fails once it is lightened for dark, because the foreground stays white while the fill moves. This is the single most common failure in this repo.
If this design sets any text uppercase — buttons, captions, table headers, badges, nav labels — check whether it needs an optical-centering correction before shipping it. Uppercase text has no descenders, but the font's line box still reserves space for them below the baseline; unless the font happens to allocate that space evenly, the caps sit visibly high in whatever centers them — a button label, a table header — leaving a gap underneath instead of an even margin above and below.
Measure it, don't guess. Render SETTINGS in the design's actual vendored
sans font at a large size — 200px keeps integer-pixel rounding from hiding the
real number — with a Canvas 2D context, and compare the glyphs' actual ink to
the line box the font reports:
const ctx = document.createElement('canvas').getContext('2d');
ctx.font = "<weight> 200px '<font family>'";
const m = ctx.measureText('SETTINGS');
const inkCenter = (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) / 2;
const boxCenter = (m.fontBoundingBoxAscent - m.fontBoundingBoxDescent) / 2;
const shiftEm = (inkCenter - boxCenter) / 200; // positive = push the text downCheck it at the weights this design actually sets uppercase at — most fonts hold
this constant across weight, but don't assume it without checking (a font with
an MVAR table can vary it). If shiftEm is under roughly 0.005em, there's
nothing worth fixing — don't add a correction for a problem that doesn't exist;
slate and warm-paper (Inter) measured at ~0.0025em and ship with no
treatment for exactly this reason. If it's meaningfully non-zero, record it as
--uppercase-optical-nudge in :root, with a comment stating the measured
value and how you got it, and document it in DESIGN.md's Extensions section.
Never estimate this number — "roughly centered by eye" and "off by 0.03em" look
identical in isolation but compound across every button in the product.
Fix it with the mechanism the text's own centering allows — not one rule for
everything. text-box-trim: trim-both; text-box-edge: cap alphabetic; reads
the font's real metrics and needs no hand-measured number, but it does not work
everywhere. Verify empirically before relying on it: CSS.supports() only
confirms the property parses, not that it does anything — pixel-diff a
rendered element with and without it (screenshot before, apply the rule,
screenshot after, diff the two) the way learn and material-design did.
- Plain block-flow text — a caption or label with normal line-height, not
inside a flex/grid container, not vertical-align-centered: text-box-trim
works. Use it as the primary rule, with an
@supports not (text-box-trim: trim-both)fallback totransform: translateY(var(--uppercase-optical- nudge))for browsers that don't support it yet. - Text centered by flexbox — almost every icon+label button, since the
icon is why it's
flex/inline-flexin the first place: text-box-trim is a proven no-op. A flex container wraps its direct text in an anonymous block box that author CSS cannot select, so a rule on the flex container itself never reaches the text. Usetransform: translateY(var(--uppercase-optical- nudge))unconditionally — there is nothing to fall back from. - Text centered by
vertical-align: middle(table<th>) — text-box-trim does not affect this centering either, even though the property applies without error. Same always-on transform as flex. <th>needs its own selector regardless of the above. If the table markup uses Tailwind's[&_th]:uppercasearbitrary-variant syntax on<thead>— the pattern every kitchen sink in this repo uses — the compiled rule never puts a literal.uppercaseclass on the<th>itself. A.uppercaseselector will silently skip every table header.
A selector split that works for a .uppercase-utility-heavy kitchen sink:
@layer base {
th,
.uppercase.flex,
.uppercase.inline-flex {
transform: translateY(var(--uppercase-optical-nudge));
}
.uppercase:not(.flex):not(.inline-flex) {
text-box-trim: trim-both;
text-box-edge: cap alphabetic;
}
}
@supports not (text-box-trim: trim-both) {
@layer base {
.uppercase:not(.flex):not(.inline-flex) {
transform: translateY(var(--uppercase-optical-nudge));
}
}
}If the design sets text-transform: uppercase directly on a role selector
rather than through the .uppercase utility — learn's button rule does
this, keyed to [data-slot="button"] per this repo's "key CSS to role, not
tag" convention — give that selector the same always-on transform inline
rather than relying on the block above, since it won't carry the .uppercase
class either. See designs/learn/theme.css and designs/material-design/ theme.css for both cases worked through in full, including the comments
explaining why each selector landed where it did.
The kitchen sink. Every section in shared/COMPONENTS.md, in order, with the exact
id attributes specified there.
Technical requirements:
- The theme is not written into
index.html. Copy the loader block fromdesigns/_template/index.htmlverbatim: it fetchestheme.css, prepends@import "tailwindcss";, injects the result as a<style type="text/tailwindcss">, and only then loads the Tailwind browser build.theme.cssis therefore the single copy of the theme, and the demo renders from the exact bytes a consumer pastes into their project.- Do not substitute a
<link rel="stylesheet">. The browser build only readsstyle[type="text/tailwindcss"]elements, so the page would come out with no utilities at all. - Do not move the
@import "tailwindcss";intotheme.css. That file is written to be pasted below an existing import. And do not rely on the compiler adding it for you — it skips its own auto-prepend whenever the stylesheet text contains "@import" anywhere, including inside a comment.
- Do not substitute a
- The only CSS left inline is the demo page's own chrome: the
.ks-*helpers and thehtml { scroll-behavior }rule. Anything that belongs to the design itself belongs intheme.css, even when it is a rule rather than a token. - Kitchen sinks are served over http(s), not opened from
file://. - Tailwind v4 browser build, loaded by that block from
../../vendor/tailwind-browser-<version>.js. Copy the path from an existing design rather than typing it — every page in the repo must load the same vendored build, andcheck.mjsfails if one drifts. - Icons are inline SVG from Lucide. Do not fetch an icon font or sprite.
- Webfonts come from Google Fonts or a system stack, and always with a real fallback stack.
- The dark-mode toggle is a few lines of inline vanilla JS that toggles
.darkon<html>. Persist the choice inlocalStorage. - A sticky in-page table of contents linking to every section ID.
- Overlay components (
Dialog,Sheet,Popover,Tooltip,DropdownMenu, command palette) are rendered inline and open, not behind a click. An agent reading this file must be able to see them without executing anything.
Markup requirements:
- Mirror what shadcn/ui's React components actually render, so translating to JSX
is mechanical. Where shadcn puts a
divwithflex items-center gap-2, put adivwithflex items-center gap-2. - Semantic colours only. Never a raw hex value, never a Tailwind palette class like
bg-blue-500, never anoklch()outside the theme block. Everything routes through a token. A design that hard-codes colour cannot be swapped, which defeats the purpose of the repository. - Every interactive element gets a visible focus ring using
--ring. Tab through the page before you call it done — and see "Horizontal tabs and other overflow-x-auto strips" below before you tab through, because a clipped ring on the first or last item in a scrolling row won't show up unless you specifically focus that item. - The markup is part of the deliverable, not just the styling. Agents copy the
kitchen sink's markup verbatim, so a missing
<label for>or an unlabelled icon-only button here becomes the same bug in every application built from this design. Follow the per-component semantics table inshared/ACCESSIBILITY.md: real<label>s,aria-labelon icon-only buttons,aria-hidden="true"on decorative SVGs,<th scope>andaria-sortin tables,aria-currenton the active nav item, andaria-invalid+aria-describedbyon the error field. - Real content, not lorem ipsum. Plausible product copy makes design decisions legible in a way that placeholder Latin does not — you cannot judge a table's density with fake words in it.
This exact bug shipped identically across five designs before anyone caught it, because it is invisible unless you tab to the first or last item — so check for it deliberately rather than trusting a casual tab-through.
The horizontal-tabs pattern needs overflow-x-auto on the tab <nav> so it
scrolls on narrow screens. Per the CSS overflow spec, setting overflow-x to
anything but visible forces overflow-y to compute to auto as well — there is
no way to scroll one axis and leave the other visible on the same box. A focus
ring drawn with ring-* (box-shadow, extending past the element's own border box)
gets clipped by that computed overflow-y: auto the moment the ring has nowhere
to render into: if the nav's padding box is flush with a tab's own box — which
it is by default, since nothing reserves any extra space — the ring is clipped
top and bottom on every tab, and left/right on the first and last one, where the
nav's own edge is flush with the tab's edge. A tab in the middle of the row looks
fine because its neighbours' gap gives its ring room; only the outer edges show
the bug, which is exactly why it survives a quick tab-through.
The fix is real padding on the scrolling container for the ring to render into, cancelled with an equal negative margin so nothing shifts and no alignment trick elsewhere breaks:
<nav class="flex gap-1 overflow-x-auto pt-1.5 -mt-1.5 pb-1.5 -mb-[7px] pl-1.5 -ml-1.5 pr-1.5 -mr-1.5">The -mb-[7px] (rather than the more obvious -mb-1.5) is pb-1.5 (6px) plus
the 1px that this repo's underline-tabs pattern already relies on to fuse the
active tab's own border-b-2 with the container's separate border-b divider
line into a single line — recompute that constant if your design's tab bar
doesn't use the same border-merge trick, or if the container's border width
differs. Verify the fix, don't eyeball it: check that the tab's own
getBoundingClientRect() is unchanged before/after (confirms no visual shift)
and that the container's bottom edge still equals the active tab's bottom edge
(confirms the border-merge still holds), then screenshot the first and last tab
focused, in both themes.
While you're in this markup, also check that the label isn't sitting flush
against one edge of its own padding (e.g. pb-3 with no matching pt-3) — that
reads as fine on its own but becomes visibly lopsided the moment a focus ring
frames it, and a gap-6-plus-px-1 tab row reads as big gaps between tiny click
targets rather than a deliberately spaced row of controls.
None of this is specific to tabs — any horizontally-scrolling strip of focusable
controls (filter chips, a segmented breadcrumb, a toolbar) needs the same padding
treatment wherever overflow-x-auto is load-bearing for narrow screens.
This is the file an AI reads when a user says "use this design", so write it for an
agent that will not open the HTML. Be concrete. "Generous whitespace" is not
actionable; "section padding is py-16, card padding is p-6, related controls sit
gap-2 apart" is.
Required sections:
Identity — Name, a one-line description, and three to five adjectives. Then "use this when…" and "do not use this when…". The second one matters: a design that suits everything suits nothing.
Influences — What this is drawn from, and specifically what was taken. "Linear's density and its use of a single accent colour, but not its dark-first bias."
Colour — The palette with token names and values, the reasoning behind the primary hue, and how accent, muted, and destructive relate to it. Include the measured contrast ratios.
Typography — Font families with fallback stacks, the full scale (size, weight, line height, letter spacing) for each level, and the rules: what gets which weight, when letter spacing is adjusted, what the measure (max line length) is for body copy.
Spacing & density — The base unit, the spacing scale, standard paddings for cards, sections, and form rows, and a statement of where this design sits on the dense/roomy axis.
Shape & depth — The --radius value and why, the shadow ramp with actual
values, and the rule for when a surface uses a border versus a shadow versus both.
Motion — Duration and easing, what animates and what does not, hover, focus,
active, and disabled treatments. Include a prefers-reduced-motion rule.
Component notes — For each component group in shared/COMPONENTS.md, anything
an implementer would otherwise get wrong. Where a component behaves the same as
stock shadcn, say "stock" and move on; only spend words where this design differs.
Accessibility — Required, and checked by check.mjs. What this design
decides: the focus ring treatment and its measured contrast, the target sizes for
every control class (plus the 44×44 touch floor), how state is encoded in something
other than colour, how charts stay readable without hue, and anything the design
animates that reduced motion must remove. Do not restate shared/ACCESSIBILITY.md
— it applies regardless. End with a "Known gaps" subsection listing anything that
fails the baseline, each with its fix; write "None." if check.mjs is clean.
Never — The prohibitions. This is the most useful section in the file and the one most likely to be skipped. What would make something stop looking like this design? Write five to ten of them, specifically. "Never use a drop shadow on a button." "Never use more than one accent colour on a screen." "Never centre body text."
Extensions — Any tokens added beyond shared/TOKENS.md, and what a consumer
that ignores them will get.
node scripts/check.mjs
node scripts/build-gallery.mjscheck.mjs verifies files, tokens, and section IDs. Fix everything it reports.
build-gallery.mjs adds the design to the root gallery.
Then open designs/<name>/index.html and look at it. Automated checks confirm the
sections exist; they cannot tell you the design is good. Specifically check that:
- Light and dark both look chosen, not merely functional.
- The page still looks like one design at the bottom as at the top.
- Nothing is illegible — thin type on tinted backgrounds is the usual offender.
- The focus ring is visible on every control when tabbing, and never hidden behind a sticky header.
- Tab through the entire page. Every control reachable, nothing trapped, order matching visual order. This is the check that catches the most real bugs.
- In any horizontally-scrolling row (tabs, chips, a toolbar), specifically focus the first and last item, not just one in the middle. See "Horizontal tabs and other overflow-x-auto strips" above — a clipped ring on the outer edges hides behind a normal-looking middle item.
- Take a greyscale screenshot. Every status, every chart series, and every active state must still be distinguishable with hue removed.
- Zoom to 200% and narrow to 320px. Nothing clipped, nothing overlapping.
- If anything is set uppercase, zoom into a button and a table header and look at the gap above and below the letters. If it isn't even, see "Uppercase optical centering" above — measure it, don't nudge it by eye.
- It looks meaningfully different from the other designs in
designs/. If it does not, the repository has not gained anything and you should push the choices further.