Before you write any code
Three things must be true before authoring a new component:
The need is real. No existing primitive, element, block, section, or overlay covers this use case, and no combination of them does either. If you can build it from existing components, that's the answer. If you can express it as a
data-{category}modifier on an existing component, that's the answer too — seemalevich-modifiers.The layer is decided. Run the functional test from ADR-0006:
- Primitive — no slots, no logic, called from many components, no own semantics (Avatar, Spinner, Icon).
- Element — fixed structure, takes props, doesn't accept
arbitrary content (Button, Input, Badge). Sub-grouped into
interactive/form/display. - Block — has slots for elements; a structural brick for sections (Card, Accordion).
- Section — has slots for blocks; organizes a full-width page region (Hero, CTA, layouts).
- Overlay — same identity as a block, but portal-rendered with backdrop, focus-trap, escape handling (Dialog, Sheet, Toast).
The test is what the component does, not how complex it looks.
The name is correct. Lowercase kebab-case in folders and files (
empty-state, notEmptyState). There is no custom-element tag — components are standard HTML elements with a BEM block class (.empty-state). Never reach for<m-empty-state>orcustomElements.define(ADR-0004).
If any of the three are uncertain, stop and draft an ADR in
llm-wiki/decisions/. Get the human's agreement before implementing.
Use the Button as your reference
Every component follows the internal structure of
packages/components/src/elements/interactive/button/. Read it before
starting and match its conventions exactly — file naming, BEM patterns,
CSS layer, init signature, docs.md frontmatter.
packages/components/src/<layer-path>/<name>/
├── <name>.css # Styles using semantic + component-specific tokens
├── <name>.ts # Optional: init function(s) + behavior
├── <name>.docs.md # Authoritative human + AI documentation
├── <name>.agents.md # Auto-generated AI summary — do NOT hand-edit
├── <name>.stories.ts # Optional, for gallery rendering
├── _visual-reference.html # Every variant × state, real CSS
└── index.ts # Public exports (+ registration if runtime)
<layer-path> is foundations/<name>, elements/<group>/<name>,
blocks/<name>, sections/<name>, or overlays/<name>. If the
component has no runtime (pure CSS — Badge and Divider are examples),
<name>.ts is omitted and index.ts is just export {};.
<name>.agents.md is auto-generated by merging docs.md with the
component's row from the modifier applicability matrix. Never hand-edit
it — author docs.md and let the generator produce the agent summary.
Token discipline — the hardest part
Components consume tier-3 tokens (component-specific) where they
exist, otherwise tier-2 tokens (semantic). Components never read
tier-1 (foundations) directly — malevich/no-foundation-direct-reference
(plus malevich/no-raw-values) enforces this.
The hard question: does this component need tier-3 tokens, or do existing tier-2 tokens suffice?
A new tier-3 token is justified when:
- The relationship between two values is component-specific in a way no
semantic token captures (a Tab's active background, encoded as
--tab-background-active). - The component's variants have color/spacing relationships not expressible as a one-shot semantic.
A new tier-3 token is not justified when you just need "the raised
surface" (var(--color-surface-raised)) or want a shorter name. Err on
the side of fewer tier-3 tokens — adding them is cheap, removing them
later is expensive. New tier-3 tokens go in
packages/core/tokens/component-specific.json and must reference tier-2.
CSS — the cascade layer pattern
All component CSS lives inside the malevich.components cascade layer,
and every value is a token. Font tokens expand into five custom
properties (family / size / weight / line-height / letter-spacing) — set
them individually, as the Button CSS does:
@layer malevich.components {
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-gap-elements-m);
padding: var(--space-inset-block-l);
background: var(--color-surface-raised);
border-radius: var(--radius-block);
}
.empty-state__icon {
inline-size: var(--size-control-l);
block-size: var(--size-control-l);
color: var(--color-ink-subtle);
}
.empty-state__title {
font-family: var(--font-heading-title-family);
font-size: var(--font-heading-title-size);
font-weight: var(--font-heading-title-weight);
line-height: var(--font-heading-title-line-height);
letter-spacing: var(--font-heading-title-letter-spacing);
color: var(--color-ink-strong);
}
.empty-state__body {
font-family: var(--font-body-regular-family);
font-size: var(--font-body-regular-size);
line-height: var(--font-body-regular-line-height);
color: var(--color-ink-subtle);
max-inline-size: 48ch;
text-align: center;
}
}
Notes:
- Element separators are
__; variant modifiers are.-compact, never.--compact. - The layer wraps the whole file — never split a component across two layers.
- If the component opts into modifier categories, compose the final
value from the category namespaces (
var(--empty-state-surface-shadow, none), etc.) so modifiers stack without conflict (ADR-0007).
Declare the modifier row
A new component must declare which modifier categories and values it
accepts by adding its row to
packages/components/modifiers/applicability.json. This single file
feeds the lint rule, the generated types, the docs portal table, the MCP
server, and the agents.md generator. A component that accepts nothing
(like Divider) still gets a row, with empty arrays. Decide deliberately
— it's easier to widen a row later than to narrow one.
The init function — when JS is needed
If the component needs JS, there are two paths:
- Behavior modifiers (
sticky,dismissible,collapsible,copyable) are discovered and wired automatically by the runtime'sinit()— you don't write a per-behavior initializer. You declare the behavior in the applicability row and the runtime registry does the rest. - Component-specific interactive concerns (focus trap for a Dialog, keyboard nav for Tabs, positioning for a Tooltip) are implemented as an idempotent init function, mirroring Button — not a class, not a custom element:
// empty-state.ts
const READY_ATTR = "data-malevich-ready";
export interface EmptyStateInitOptions {
// component-specific options
}
/** Idempotent — safe to call repeatedly on the same element. */
export function initEmptyState(
el: HTMLElement,
options: EmptyStateInitOptions = {},
): void {
if (el.getAttribute(READY_ATTR) === "true") return;
// ...attach behavior...
el.setAttribute(READY_ATTR, "true");
}
/** Enhance every matching element within a root. */
export function initEmptyStates(
root: ParentNode = document,
options?: EmptyStateInitOptions,
): void {
root
.querySelectorAll<HTMLElement>(".empty-state")
.forEach((el) => initEmptyState(el, options));
}
Rules:
- Idempotent: check
data-malevich-ready === "true"and exit if set. Re-runninginit()after a route change must be safe. - Pure JS, no framework. No
class … extends HTMLElement, nocustomElements.define. The runtime drives plain elements.
Register the component
A runtime component registers its initializer against a selector in its
own index.ts, at module load — exactly as Button does. Importing
@malevich/components runs the registration; init() then discovers
and enhances matching elements.
// index.ts
import { registerComponent } from "../../runtime/registry.js";
import { initEmptyState, initEmptyStates } from "./empty-state.js";
import type { ComponentInitFn } from "../../runtime/types.js";
registerComponent(".empty-state", initEmptyState as unknown as ComponentInitFn);
export { initEmptyState, initEmptyStates };
export type { EmptyStateInitOptions } from "./empty-state.js";
Then add the re-export to the package entry
packages/components/src/index.ts. Pure-CSS components skip
registration entirely — their index.ts is export {};.
docs.md — the source of truth
The <name>.docs.md is the most important file in the folder. The
website renders from it, the MCP server returns it, and agents.md is
generated from it. Match the frontmatter and section order of the
existing component docs (see button.docs.md):
---
component: empty-state
layer: blocks
status: stable
tier-3-tokens: 2
last-updated: "2026-05-21"
---
Body sections, in this order:
- (Title + one-paragraph description) — the intent, not the visual.
- When to use — one paragraph, including when NOT to.
- Variants — a table, one row per
.-variant. - States — table, or
N/A — reasonif static. - Sizes / rhythm — the supported
data-rhythmvalues or-s/-lvariants, orN/A — single size. - Modifiers — the categories and values this component accepts (mirrors its applicability row).
- Anatomy — annotated HTML showing the element classes.
- Tokens used — the semantic tokens, then the tier-3 tokens.
- Accessibility — what the component guarantees, what the application must provide.
- Edge cases — short FAQ entries.
- Do / Don't — concrete recommendations and anti-patterns.
- Related — links to nearby components.
Follow the existing files' habit of writing a one-line N/A — reason
for inapplicable sections rather than dropping the heading.
Test minimum
Every component ships with at least:
- A
<name>.test.ts(vitest) covering idempotency of init, attribute reflection, and any state transitions. For pure-CSS components, a minimal render assertion. - A
_visual-reference.htmlshowing every variant × state rendered with real Malevich CSS — used for dev-time visual checks and consumed by the docs portal preview.
Visual regression coverage comes later via apps/gallery; it is not
part of the per-component PR.
ADR — when to write one
Write an ADR (llm-wiki/decisions/NNNN-empty-state.md) when:
- The component introduces a pattern new to the system (first use of a particular ARIA pattern, first need for CSS Anchor Positioning, etc.).
- Its behavior diverges from the closest existing component in a way future contributors might mistake for inconsistency.
- A layer-classification or scope decision required judgment worth recording.
A new Button-like element does not need an ADR. A component that introduces, say, deferred rendering for performance does.
Checklist before opening the PR
- Layer decided via the ADR-0006 functional test; file lives under
the right
<layer-path> - Folder structure matches the Button reference
- CSS uses only
var(--token); all selectors inside@layer malevich.components; no tier-1 references - BEM with short modifiers; no double-dash, no standalone modifiers
- Modifier row added to
modifiers/applicability.json(even if empty) - Standard HTML element + block class — no custom element
- Init functions (if any) are idempotent and set
data-malevich-ready="true" - Component registers via
registerComponent()(runtime only); packagesrc/index.tsre-exports the init functions -
docs.mdfrontmatter + sections match the existing component docs;agents.mdleft to the generator - Tests cover idempotency and core behavior
-
_visual-reference.htmlrenders all variants × states - ADR written if needed
-
pnpm --filter @malevich/components lintpasses for the new CSS -
pnpm --filter @malevich/components testpasses