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:
- 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. - No discoverability of applicability. Whether
t-glow-hoverapplied to Card but not Button lived only in documentation. There was no machine-readable matrix, so AGENTS.md drift was inevitable. - Variable-namespace collisions. Two tweaks both writing
--button-bgwould 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:
- Variants — what the component is. BEM short modifiers:
.button.-primary,.alert.-danger. Stable identity. - Modifiers — how it appears or behaves. Data-attributes:
data-surface="elevated",data-effect="glow",data-behavior-sticky="true". Cross-cutting concerns.
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:
surface→--{component}-surface-*effect→--{component}-effect-*background→--{component}-background-*- (etc.)
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
rhythmandmotioninherit via CSS custom property cascade (environmental — set on a section, apply to all descendants).surface,effect,background,shader,behaviordo not inherit (component-specific — opting one card into glow must not affect nested cards).
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:
- TypeScript types for IDE autocomplete
- AGENTS.md sections per component
- Validation logic for lint rules (
modifier-applicability-check)
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:
- Code (inline) —
data-copyable="true"default - Code block —
data-copyable="true"default - Kbd —
data-copyable="false"default (opt-in)
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
tweak→modifier(formal vocabulary)treatment(proposed in earlier drafts) →effectt-class prefix →data-{category}="value"attribute<name>.tweak.ymlmanifest → entry inmodifiers/applicability.json
The t- lint rule that blocks legacy patterns remains active during
the migration window and is removed after v0.2.0 ships.
Consequences
Positive:
- Authors and AI agents can predict, from category alone, whether a modifier is CSS-only or runtime, whether it inherits, whether it conflicts.
- Single source of truth (
applicability.json) eliminates documentation drift. - Modifier stacking is conflict-free by construction (namespace separation).
- Runtime behaviors share one init pipeline; no per-component sticky/ dismissible boilerplate.
- AGENTS.md generation becomes automatic.
Negative / tradeoffs:
- v0.1.0 codebase carries
t-classes and the old tweak vocabulary; full migration touches every component CSS file. - Authors must learn seven category names. We mitigate with IDE
autocomplete generated from
applicability.json. - Some legitimate one-off styles ("just give me a 2px red border on this one card") have no built-in modifier and must use a custom data-attribute. We document this as the extension pattern, not a limitation.
Alternatives considered
- Keep
t-classes, add categories as conventions. Rejected: conventions without enforcement drift. The class-namespace did not scope variables, so collisions remained possible. - Use CSS classes for all modifiers, not data-attributes. Rejected: the variant-vs-modifier syntactic split provides real cognitive load reduction. Mixing both signals "this is presentation, not identity" in markup.
- Plugin system for modifiers. Rejected: a plugin API is heavier than data-attribute + CSS namespace and doesn't add capability for the use cases we have. We can introduce a plugin layer in v2.0 if shader/behavior complexity warrants it.
- Six categories (no
shader). Rejected: shaders are conceptually backgrounds, but their runtime/performance/feature-flag profile warrants a distinct category so applicability and lint can treat them separately.
References
docs-internal/architecture/v1-master-diff-merge.md§4 — full modifier system spec.docs-internal/architecture/v1-principles.md— principles P-4.x and P-7.x.llm-wiki/decisions/0006-component-classification-by-functional-role.md— establishes the layer system this modifier system spans.