Malevich

AI-Things / Skills / Skill/

Malevich new component

Create a new component inside Malevich — layer classification, file structure, naming, CSS layer, tokens, docs.md, and the discipline required to fit the new piece into the existing system without weakening it.

authoring · advanced · updated 2026-05-21

Before you write any code

Three things must be true before authoring a new component:

  1. 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 — see malevich-modifiers.

  2. 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.

  3. The name is correct. Lowercase kebab-case in folders and files (empty-state, not EmptyState). There is no custom-element tag — components are standard HTML elements with a BEM block class (.empty-state). Never reach for <m-empty-state> or customElements.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:

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:

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:

  1. Behavior modifiers (sticky, dismissible, collapsible, copyable) are discovered and wired automatically by the runtime's init() — you don't write a per-behavior initializer. You declare the behavior in the applicability row and the runtime registry does the rest.
  2. 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:

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:

  1. (Title + one-paragraph description) — the intent, not the visual.
  2. When to use — one paragraph, including when NOT to.
  3. Variants — a table, one row per .-variant.
  4. States — table, or N/A — reason if static.
  5. Sizes / rhythm — the supported data-rhythm values or -s/-l variants, or N/A — single size.
  6. Modifiers — the categories and values this component accepts (mirrors its applicability row).
  7. Anatomy — annotated HTML showing the element classes.
  8. Tokens used — the semantic tokens, then the tier-3 tokens.
  9. Accessibility — what the component guarantees, what the application must provide.
  10. Edge cases — short FAQ entries.
  11. Do / Don't — concrete recommendations and anti-patterns.
  12. 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:

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:

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