0004 — Component delivery model: CSS-first with progressive JavaScript enhancement
Status
Accepted, 2026-05-16
Context
Malevich is built for AI-agentic workflows. Designers and developers working with Claude Code, Cursor, Antigravity, and similar tools need a component model that AI agents can generate, modify, and reason about with minimal indirection.
The initial architectural assumption was Web Components — custom
elements with the m- prefix (e.g. <m-button>), Shadow DOM
encapsulation, and property-based APIs. This assumption persisted
through Phase 1 documentation in llm-wiki/architecture.md and
several references in CLAUDE.md.
Reviewing the assumption during Phase 2 architecture session revealed that Web Components add cognitive load without delivering value for this specific project:
AI agents generate plain HTML and CSS more reliably than custom element invocations with property setters. A token-economy gain comes from removing the indirection.
Standard HTML elements (
<button>,<input>,<dialog>) carry built-in accessibility, semantics, and form integration that custom elements need to reimplement.Style isolation through Shadow DOM creates friction with our tweak system (which depends on CSS class composition) and with user override patterns (which depend on standard CSS specificity).
Most modern component libraries that started Web-Component-first (Shoelace, FAST, Adobe Spectrum) have either added Light DOM modes or moved entirely to CSS-class-based architectures.
The reframing: Malevich is a CSS framework with progressive JavaScript enhancement, not a Web Components library.
Decision
Components are delivered as three layers, each functional independently:
Layer 1 — Semantic HTML (required, written by author)
Authors use standard HTML elements. The base block class is added, plus any modifiers and tweaks.
<button class="button -accent -m">Save</button>
<input class="input" type="email" />
<dialog class="dialog" id="confirm">...</dialog>
Standard HTML elements are used wherever they exist with appropriate semantics:
<button>for buttons, never<div role="button"><input>for text inputs, never custom div constructions<dialog>for modal dialogs, leveraging native modal behavior<details>and<summary>for disclosure widgets<ul>/<ol>for lists, never<div>collections<nav>,<main>,<article>,<section>for page structure<figure>/<figcaption>for visual content with captions<dl>/<dt>/<dd>for term-definition pairs
For higher-order compositions (FAQ sections, product cards, pricing blocks, hero sections), authors compose from semantic primitives:
<section class="block-faq">
<h2 class="block-faq__title">Frequently asked questions</h2>
<dl class="block-faq__list">
<details class="faq-item">
<summary class="faq-item__question">How does it work?</summary>
<div class="faq-item__answer">...</div>
</details>
<!-- more items -->
</dl>
</section>
The base CSS layer styles these semantic patterns. Authors don't need to know the implementation — they pick the right element for the meaning, apply the block class, and the design system handles the rest.
Layer 2 — CSS styles (required, loaded by author)
A single CSS import provides all component styles. Components are
written using only semantic tokens; raw values are forbidden (enforced
by @malevich/lint).
@import "@malevich/core/tokens.css";
@import "@malevich/components/styles.css";
Style isolation uses CSS Cascade Layers, established at the package level:
@layer malevich.reset, malevich.tokens, malevich.base,
malevich.components, malevich.tweaks, user;
@layer malevich.components {
.button { /* component styles */ }
}
User CSS in @layer user has higher precedence by layer order,
allowing overrides without !important. The reset layer applies a
minimal scoped reset to component selectors only — it does not affect
the host page's default styles.
Layer 3 — JavaScript enhancement (optional, opt-in)
For components that benefit from JavaScript behavior — tabs, dialogs, tooltips, dropdowns, accordions — a runtime bootstrap library attaches behavior to elements matching component selectors.
import '@malevich/components'; // auto-init everything
import { initButtons } from '@malevich/components/button'; // selective
The runtime performs three jobs:
Inject tweak slots. Components that opt into tweak layers get their DOM augmented with reserved slot elements (
.button__bg,.button__glow,.button__fx, etc.) automatically. Authors write<button class="button">Save</button>, runtime expands it to the full tweak-ready structure.Attach interactive behavior. Tabs become focusable and keyboard-navigable. Dialogs become trappable. Tooltips become positioned. All progressive — without the JS layer, components still look correct, just without interactive enhancement.
Wire up tweak system. When a tweak class is applied, the runtime activates the corresponding tweak module against the target's tweak slots.
JavaScript is never required for visual correctness. A page loading only the CSS gets fully-styled, semantically-correct components. The JS adds interactivity and effects.
Implications
Cascading consequences
No
<m-*>custom elements. Them-prefix is retired from HTML usage. CSS class prefixes still use the project's BEM conventions without anym-prefix needed (since class names are already scoped by uniqueness).No Shadow DOM. Component internals are Light DOM, fully inspectable, fully styleable from outside via CSS layers.
No observedAttributes or property API. Components don't have TypeScript classes that authors instantiate. They are HTML + CSS + optional behavior.
AI-agent prompts simpler. "Generate a card with a title and two action buttons" maps directly to HTML output. No "instantiate m-card, set heading prop, add slotted action buttons."
Documentation simpler. Each component's
.docs.mdshows HTML examples that work as written. No "import this, register that, set this property" boilerplate.Lint rules unchanged.
@malevich/lintalready validates BEM class structure, raw value usage, and custom element prefix (the last rule effectively becomes dormant since no custom elements are used — but keeping it active prevents accidental drift).
Tweak system implementation
Tweak slots are injected by the runtime when it encounters a component with active tweaks:
<!-- Author writes -->
<button class="button -accent t-glow-hover">Save</button>
<!-- Runtime expands to -->
<button class="button -accent t-glow-hover" data-malevich-ready>
<span class="button__bg" data-tweak-layer="background" hidden></span>
<span class="button__glow" data-tweak-layer="glow" hidden></span>
<span class="button__content">Save</span>
<span class="button__fx" data-tweak-layer="effects" hidden></span>
</button>
Without runtime, the button renders correctly visually — just without the glow effect. Progressive enhancement.
Higher-order components
For composite blocks like FAQs, product cards, pricing blocks, etc., Malevich provides:
- Semantic patterns — recommended HTML structures using native elements
- Base styling — CSS rules that style these patterns by default
- Block-level classes — to opt into Malevich's styling
AI agents are explicitly instructed (in CLAUDE.md and component
docs) to use semantic HTML patterns when composing higher-order
blocks. The agent's output is then both Malevich-styled AND
semantically correct for screen readers, SEO, and progressive
enhancement.
This is encoded in the playbook/semantic-html.md document, which
becomes part of every agent's context.
Consequences
Good
- Lower cognitive load for authors and AI agents
- Standard HTML elements provide built-in accessibility
- Tweak system works through CSS class composition naturally
- User CSS overrides work through standard cascade
- Components are debuggable in DevTools without Shadow DOM
- Bundle size smaller (no Web Components polyfill, no class definitions, no constructable stylesheets)
- AI agent prompts produce more reliable output
- Search engines see semantic content; assistive technologies see proper roles and labels by default
Bad
- No automatic encapsulation from hostile host CSS — relies on CSS Layers for protection (well-supported but newer)
- Components can be styled "incorrectly" by users who don't understand the system (mitigated by docs and lint)
- The "Layer 3 is optional" framing requires authors to know which components need JS and load accordingly (mitigated by clear docs per component)
Migration impact
This decision changes what was documented as Web Components architecture in:
CLAUDE.md— sections about<m-button>and Custom Elementsllm-wiki/architecture.md— DOM flow diagrams, examplesllm-wiki/glossary.md— definitions of "component", "custom element prefix"- Public-facing
README-public.md— any references to Web Components
These will be updated in a follow-up commit that aligns documentation with this ADR.
Alternatives considered
Alternative 1: Web Components with Light DOM
Use customElements.define('m-button', ...) but render content in
Light DOM (no Shadow DOM). Rejected because:
- AI agents still need to learn the custom element API
- The
m-prefix in tags adds nothing once Light DOM is used - TypeScript class definitions add weight without commensurate value
Alternative 2: Tailwind-style utility classes
Reject component classes entirely in favor of atomic utilities. Rejected because:
- Loses semantic naming (the whole point of Malevich)
- Tweaks system has no clean integration point
- AI agents already generate Tailwind well — no need to compete
Alternative 3: CSS-in-JS components (e.g. Lit + emotion)
Components ship as JavaScript modules with CSS in template literals. Rejected because:
- Requires JS to render at all
- Bundle size grows quickly
- AI agents reason poorly about template-literal CSS
References
llm-wiki/decisions/0001-brand-seeds-vs-algorithmic-stops.mdllm-wiki/decisions/0002-status-color-symmetry.mdllm-wiki/decisions/0003-patterns-over-tokens.mdMANIFESTO.md— original positioning of agentic-first design- Pico CSS architecture — similar CSS-first model
docs-internal/strategy.md— v1.0 component scope (10 components)