Malevich

Architecture decision record

ADR-0007 · Modifier system

ADR 0007 — Modifier system as cross-cutting layer

Status

Accepted, 2026-05-18.

Source: v1.0 architectural quiz outcomes (docs-internal/architecture/v1-master-diff-merge.md §4).

Context

v0.1.0 shipped "tweaks" — CSS-class-based extensions attached via t-tweak-name. They worked for one-off visual flourishes, but as the component count grew the model showed three weaknesses:

  1. Categorical mixing. A single mechanism (t- classes) covered semantically unrelated concerns: visual treatments (t-glow-hover), layout density (t-compact), runtime behaviors (t-sticky), responsive density. Authors could not predict which tweak required JS, which inherited via the cascade, or which conflicted with each other.
  2. No discoverability of applicability. Whether t-glow-hover applied to Card but not Button lived only in documentation. There was no machine-readable matrix, so AGENTS.md drift was inevitable.
  3. Variable-namespace collisions. Two tweaks both writing --button-bg would silently overwrite each other.

The naming itself ("tweak") signaled "optional adjustment", but in practice these are first-class compositional primitives — sticky behavior is not a tweak, it is a behavior contract.

Decision

Replace the "tweak" model with a modifier system organized into seven categories, each owning a distinct concern:

Category Concern Layer Examples
background What's behind the surface CSS solid, gradient, image
surface How the surface sits CSS flat, elevated, float
effect Decorative overlays CSS grain, glow, noise, ring, blur
shader Interactive WebGL backgrounds Runtime (opt-in pkg) mesh, particle, aurora
rhythm Internal spacing density CSS (cascading) compact, regular, spacious
motion Animation character CSS (cascading) none, subtle, standard, expressive
behavior Runtime behaviors Runtime (init) sticky, dismissible, collapsible, copyable

Syntactic split: variants vs modifiers

Two attachment mechanisms, two meanings:

The split is intentional: readers and AI agents can scan a template and immediately tell identity (.-primary) from presentation (data-effect="glow").

Namespace separation rule

Each category owns a CSS custom property namespace:

Components compose final values from multiple namespaces:

.card {
  box-shadow:
    var(--card-surface-shadow, none),
    var(--card-effect-shadow, none);
}

Modifiers stack without conflict because they write into disjoint variable namespaces. A future surface=elevated + effect=glow + background=gradient combination requires no resolver — the cascade handles it.

Selective inheritance

Inheritance is enforced by where each modifier writes its custom properties (:root / section vs. component scope).

Applicability matrix

Single source of truth: modifiers/applicability.json. Per-component map of which categories and values are supported:

{
  "components": {
    "button": {
      "surface": ["flat"],
      "effect": ["glow", "ring"],
      "rhythm": ["compact", "regular"],
      "motion": "all"
    }
  }
}

Build pipeline consumes this matrix to generate:

Documentation, types, lint, and tooling never drift because they all read from one file.

Runtime: behavior auto-init

Behavior modifiers require JavaScript. The @malevich/components runtime exposes init() which discovers and wires up all data-{behavior}="true" elements automatically:

import { init } from '@malevich/components';
init();

No per-component init function for behaviors; the runtime registry handles discovery. Components that include init<Name> exports continue to handle component-specific concerns (focus traps for Dialog, keyboard nav for Tabs).

Built-in behaviors per component

Some components default behaviors on because the behavior is core to identity:

All other components are opt-in via explicit data-{behavior}="true".

Custom modifier extension

Public extension API documented in /playbook/extending-modifiers. No plugin system — just the data-attribute + CSS convention:

<article class="card" data-corporate="enterprise">
[data-corporate="enterprise"] {
  --card-border: 2px solid var(--color-corporate-accent);
  --card-radius: 0;
}

Custom modifiers do not need to register; they just write into the component's CSS custom property namespace.

Renaming summary

The t- lint rule that blocks legacy patterns remains active during the migration window and is removed after v0.2.0 ships.

Consequences

Positive:

Negative / tradeoffs:

Alternatives considered

References