Authoring a component

The nine rules, the ten ways it goes wrong when you break them, and the MCP server that checks what you wrote.

A complete component

@layer largen.components {
  .notification, notification {
    --bg: var(--tone-soft);
    --fg: var(--tone-ink);
    --border-width: 0 0 0 3px;
    --border-color: var(--tone);
    --border-style: solid;
    --radius: var(--radius-md);
    --pad: .75em 1em;
    --gap: .75em;
    display: grid;
    grid-template-columns: auto 1fr;
    align-items: center;
  }
}
<notification data-tone="warning">Two accounts need review.</notification>
<div class="notification" data-tone="warning">…same thing…</div>

That is a complete component: seven tones, four variants, five sizes, every state and both themes, none of which it mentions.

The rules

1. Author inside @layer largen.components. Override from outside it.

Unlayered CSS beats every layer. That is what makes overriding largen work without !important. It is also exactly what breaks an unlayered component: it outranks largen.modifiers, so data-variant silently stops applying while tone and size keep working. If a variant "isn't applying", check this before anything else.

2. A component sets slots. It does not set colours.

Write --bg: var(--tone-soft), never --bg: #fee, and never --bg: var(--danger). A colour literal cannot follow a theme swap, and reaching past --tone* to a raw semantic token breaks tone inheritance. The component pins itself to one colour and stops responding to the data-tone on its container. largen verify enforces both.

3. Never write a dark-mode rule.

--tone-soft and --tone-ink resolve against --canvas and --ink, so a theme swap carries every component with it. If a component seems to need a dark-mode rule, the algebra has failed to cover something, and that failure is the bug worth fixing. A dark rule would only hide it.

4. Never write a size variant.

Multiply by var(--scale) where you set a size, express the rest in em, and padding follows type automatically. Five hand-written size variants per component is the cost the size axis exists to remove.

The rule is about not writing the same component five times. It is not a rule about units, and the distinction matters for the shape it appears to forbid: a control that shrinks its box while holding its type at one size. em is what couples padding to type; rem is what decouples them. Set --pad in rem and the box stops following the size axis while the type keeps up with it. Measured at data-size="lg": padding in em grows from 16px to 18px, padding in rem stays at 12.8px. That is a unit choice inside one component, not a size variant.

5. Reach for HTML first.

<dialog> is the modal, <details> is the collapse. Both are already themed by largen.elements. Reimplementing focus trapping or open-state management in CSS and JS buys nothing the platform has not already shipped and tested.

6. A coined tag is for a container with no role. Otherwise use the element.

<notification> is fine; <button-primary> is not: it claims a role it lacks. A coined tag has no role and no accessible name, so assistive technology sees a span. largen paints from slots, so <button class="button-primary"> styles identically and keeps the role, focus ring and keyboard behaviour. Use the native element, or set role=.

7. Never add an initial-value to a slot.

With universal syntax, omitting initial-value makes an unset slot the guaranteed-invalid value, so var(--pad, revert-layer) falls back and hands the property back to the UA stylesheet untouched. Add an initial value and the slot is never unset, the fallback never fires, and the universal paint rule resets every element's UA defaults. Every <ul> loses its indent and every <h1> its size. This descriptor is the reason largen needs no build step.

One registered property does carry an initial value, deliberately: --scale is declared initial-value: 1. It is not a paint slot, since nothing reads it through var(--scale, revert-layer). It is a multiplier that every size calculation depends on, so it must always resolve to a number. largen verify exempts it by name for exactly this reason. The rule holds for the paint slots without exception.

8. Two token tiers. A palette, if you need one, goes in your theme.

Larger systems have three tiers: reference (a raw palette, blue-500), system (roles, --primary), component. largen has two — tokens, which are roles, and slots, which a component fills — and a theme writes values straight into the semantic tokens with no palette between. At this size that is one less indirection to hold in your head, not an omission.

A project outgrows it predictably: when one brand palette feeds several themes and the same hex appears in three files. Do not wait for largen to ship a reference tier — put the palette in your own theme, above largen's tokens, and point the roles at it: --brand-blue-60: #1c6fd6; --primary: var(--brand-blue-60). largen needs to know nothing about it, because a theme sets largen's token names and where it got them is its own business.

The placement that does not work is a palette in a component: naming --brand-blue-60 there pins it to one colour and drops it out of the tone axis, the same mistake as naming --danger directly.

9. Density and direction are not axes. Query for them inside the component.

The axes are tone, variant, size and state. Some systems add a context dimension — density, form factor, writing direction — and largen does not, now or planned. Said out loud so nobody waits for it: there will be no data-density.

The escape hatch is ordinary CSS and it is normal. A media or container query INSIDE a component is allowed by every rule here: @container (inline-size < 30em) { .toolbar { --pad: .5em .75em } }, beside the component it tightens. That queries the container rather than adding a variant, so it composes with tone, size and state instead of multiplying against them. What it must not become is a size variant: data-size already sets --scale, and a rule re-setting --pad keyed off a size-axis value is what largen verify reports.

Direction needs nothing. largen is written in logical properties throughout — padding-inline, margin-block, inset-inline-start — so a component built the same way follows dir="rtl" with no rule of its own. Reaching for padding-left is what breaks it.

When it goes wrong

The first one is the one to memorise. It is the only failure in largen that looks like success.

A gradient set through --bg produces no background at all. Cause: --bg drives background-color, which cannot take a gradient. Fix: Keep the slot and write the gradient beside it: --bg: var(--tone); background-image: linear-gradient(…).

The declaration is dropped and the element paints transparent, with no warning anywhere. Nothing about --bg says "colour only". It is named for background, and every other slot takes whatever its property takes. This is the general shape for anything the algebra does not model: write the slot, then the plain property. The slot keeps the element inside the algebra; the plain declaration adds what has no slot.

Type resizes with data-size and the padding around it does not. Cause: The component's --pad is written in rem, often as var(--space-*), instead of em. Fix: Use em for a component's own padding. Keep --space-* for the rhythm between things.

--scale inherits and a component multiplies its --font-size by it. Padding in em is relative to that font-size and follows it: 0.5em 1em goes from 7px 14px at sm to 10px 20px at xl. In rem it is 8px 16px at every size. The page looks deliberate and has lost an axis. Reaching for the spacing scale is the obvious move, which is why largen verify reports this rather than leaving it to the eye.

data-variant has no effect, but data-tone and data-size still work. Cause: The component is declared outside @layer largen.components. Fix: Wrap the component rule in @layer largen.components { … }.

This is the worst failure mode in the system because it looks like it works. An unlayered component outranks largen.modifiers, so the variant rules lose. Tone and size act through inheriting custom properties rather than by overriding slots, so they are unaffected. One dead axis and two live ones reads as "variant is buggy" rather than "my component is in the wrong layer".

A component ignores the data-tone on its container. Cause: It reaches past --tone* to a raw semantic token, e.g. --bg: var(--danger). Fix: Use var(--tone), var(--tone-soft), var(--tone-ink) or var(--tone-line).

Raw semantic tokens are absolute; the --tone* family is relative to whatever tone is in scope. Naming the absolute one opts the component out of the axis entirely, which is invisible until someone sets a tone on an ancestor.

Every element on the page loses its browser defaults. Lists unindent and headings shrink. Cause: A slot was given an initial-value, or paint.css was moved out of its layer. Fix: Remove the initial-value; keep the universal rule inside @layer largen.paint.

revert-layer is only meaningful from inside a layer, and only reached when the slot is guaranteed-invalid. Break either condition and the one universal rule stops being inert and starts being destructive.

Your own CSS is inside a cascade layer, and largen still wins. Or your "base" layer beats largen when you meant it to lose. Cause: A layer's position is fixed the first time it is mentioned, and a sublayer inherits its parent's position. Layers largen named first keep theirs; yours are appended after them, whatever order your @layer statement lists. Fix: Declare every layer in one statement, before largen loads, with flat names on both sides of it: @layer app-base, largen.fallback, largen.reset, largen.tokens, largen.paint, largen.tone, largen.elements, largen.components, largen.modifiers, app-overrides;

Writing @layer app.base, largen.components, app.overrides; reads as "app.base lowest, app.overrides highest" and does not do that. largen.components already exists and keeps its position, while the new app parent is appended after everything. So app.base outranks largen, and app.overrides can never reach past it, because both are children of one parent that has one position. Flat names avoid this because app-base and app-overrides are independent, so they can sit on either side.

This is also where a preflight goes when largen runs alongside another framework. Put its base layer in the statement ahead of largen, or it sorts last and flattens everything largen styled. Layer order beats specificity, so no amount of selector weight recovers it.

List ALL EIGHT largen sublayers, largen.fallback first. A sublayer the statement omits is created when largen loads and appended after the ones listed. For largen.fallback that puts its per-element resets above largen.components in exactly the engines the fallback exists for (Firefox 113–127, Safari 16.2–16.3), while looking correct in every engine you are likely to test in.

largen verify cannot catch this. Layer position is a property of the whole document at load time, of which files were seen and in what order, and a linter reading one stylesheet has no way to know. The browser is the only place the answer exists.

A fill is the right colour and the text on it is unreadable. It shows the old tone's contrast colour on the new tone's background. Cause: The component set --tone and read var(--tone-contrast) without setting --tone-contrast alongside it. Fix: Set the pair together: --tone: var(--danger); --tone-contrast: var(--danger-on).

--tone-soft, --tone-ink and --tone-line are formulas, and largen recomputes them on every element, so setting --tone anywhere is enough for those. --tone-contrast is not a formula. It is the paired token (--danger pairs with --danger-on), and nothing can derive one from the other, so it keeps whatever value was already in scope. The three that follow --tone automatically are exactly the three that can be computed from it. largen verify and check_component_css both flag this.

largen is painting something you want it to leave alone: a third-party widget, a chart, markup another framework owns. Cause: The universal paint rule claims a property as soon as any rule sets its slot, and nothing "unsets" a custom property by writing an empty value. Fix: Set the slot to initial: --bg: initial; --pad: initial.

A slot is registered with universal syntax and no initial-value, so initial returns it to the guaranteed-invalid value, the state it has when nothing has set it. var(--bg, revert-layer) then fires and hands the property back to the user-agent stylesheet untouched. Measured: a claimed element computes rgb(244,245,247) and 16px of padding; the same element with the slots set to initial computes rgba(0,0,0,0) and 0px.

This is the mechanism that makes incremental adoption possible. It is how largen can run beside another framework for as long as a migration takes, releasing whatever the other one still owns, rather than requiring a cutover. Treat it as a first-class part of the contract, not a trick.

A link is tinted with the tone when it should take the colour of the text around it. Cause: largen.elements sets --fg: var(--tone-ink) on every a, which is right for prose links and wrong for links that are navigation or UI. Fix: Set --fg: currentColor on the link component. color: inherit is equivalent.

The spelling that looks right is --fg: inherit, and it does something else. inherit takes the parent's computed --fg, and because the slot does not inherit and the parent never set it, that value is guaranteed-invalid. So var(--fg, revert-layer) fires and reverts to the user-agent stylesheet, which colours links blue. Measured against a parent at rgb(3,4,5): inherit gives rgb(0,0,238), currentColor gives rgb(3,4,5).

The general lesson is worth more than the recipe. Returning a slot to guaranteed-invalid gets you the user-agent value, never the ambient one. If you want what the surroundings have, name it with currentColor rather than trying to get there by removal.

A component looks right in one theme and wrong in the other. Cause: A colour literal, or a value that does not resolve against --canvas/--ink. Fix: Route the colour through a token or a --tone* derivation.

Theme swapping works by changing what the tokens resolve to. Anything that bypasses the tokens is simply not part of that mechanism.

Check what you wrote

Locally, npx largen verify. Over MCP, check_component_css. Both run the same rules from the same module, so they cannot disagree.

Both are static. They have passed clean on visibly broken components before. Render the result in a browser, in both themes.

The MCP server

12 tools over Streamable HTTP, with no key, no account and no configuration. Everything served is public documentation or a pure function over what you send.

claude mcp add largen --transport http https://largen.dev/api/mcp

Orientation, before you write:

Checks, after you write:

Diagnostics, when something computes wrong:

Your components, not ours

Every tool that reasons about components takes an optional components manifest, produced by npx largen manifest src/components.css. Supply it and the tools answer in your vocabulary; omit it and they fall back to the reference set. A malformed manifest is an error, never a silent fallback.

There is no generate_ui

This is a position, not a gap. You are already a capable model and you know the application being built; a model on this server would know none of that and add an API key, a cost and a latency budget to every call. The server equips you and then checks your work. You do the generating.

Without MCP

Fetch /llms-compact.txt: the whole contract inline, roughly 6,100 tokens, enough to author a correct component without another request.