import { createTweakSlot } from "../../../runtime/tweak-slots.js";
export interface ButtonInitOptions {
/** Whether to inject tweak slots. Default: true. */
injectSlots?: boolean;
/** Whether to attach pressed-state handling for keyboard. Default: true. */
enableKeyboard?: boolean;
}
const READY_ATTR = "data-malevich-ready";
const ACTIVE_ATTR = "data-active";
/**
* Initialize button enhancement on an element.
* Idempotent — safe to call multiple times on the same element.
*/
export function initButton(
el: HTMLButtonElement,
options: ButtonInitOptions = {},
): void {
if (el.getAttribute(READY_ATTR) === "true") return;
const { injectSlots = true, enableKeyboard = true } = options;
const doc = el.ownerDocument;
if (injectSlots) {
const originalNodes = Array.from(el.childNodes);
const bg = createTweakSlot(doc, "button__bg", "background");
const glow = createTweakSlot(doc, "button__glow", "glow");
const fx = createTweakSlot(doc, "button__fx", "effects");
const content = doc.createElement("span");
content.className = "button__content";
const allText = originalNodes.every((n) => n.nodeType === Node.TEXT_NODE);
if (allText) {
const label = doc.createElement("span");
label.className = "button__label";
for (const node of originalNodes) label.appendChild(node);
content.appendChild(label);
} else {
for (const node of originalNodes) content.appendChild(node);
}
el.replaceChildren(bg, glow, content, fx);
}
if (enableKeyboard && !el.disabled) {
const setActive = () => el.setAttribute(ACTIVE_ATTR, "true");
const clearActive = () => el.removeAttribute(ACTIVE_ATTR);
el.addEventListener("keydown", (event) => {
if (event.key === " " || event.key === "Enter") setActive();
});
el.addEventListener("keyup", clearActive);
el.addEventListener("blur", clearActive);
}
el.setAttribute(READY_ATTR, "true");
if (!el.textContent?.trim() && !el.getAttribute("aria-label")) {
// eslint-disable-next-line no-console
console.warn(
"[malevich] Button has no visible label and no aria-label. " +
"Icon-only buttons must set aria-label for accessibility.",
el,
);
}
}
/**
* Initialize all buttons matching `.button` within a root.
*/
export function initButtons(
root: ParentNode = document,
options?: ButtonInitOptions,
): void {
const buttons = root.querySelectorAll<HTMLButtonElement>("button.button");
for (const button of buttons) initButton(button, options);
}
# Button — AGENTS.md
> Auto-generated from `button.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.
## Identity
- **Component:** `button`
- **Layer:** elements
- **Status:** stable
- **Last updated:** 2026-05-18
## Summary
Use a button when the action happens on the current page (submitting
## Variants
| Variant | Class | Use for |
|-----------|------------------------|--------------------------------------|
| Primary | `.button.-primary` | Primary CTA, hero conversions |
| Secondary | `.button.-secondary` | Secondary actions, "Cancel" |
| Ghost | `.button.-ghost` | Tertiary actions, low emphasis |
| Danger | `.button.-danger` | Destructive actions |
| Success | `.button.-success` | Positive confirmation actions |
> **v0.1.0 compatibility:** `.button.-accent` and `.button.-neutral`
> are retained as aliases for `.button.-primary` and `.button.-secondary`
> respectively. They will be removed during the v0.2.0 codemod
> migration. New code should use the v1.0 names.
## Modifier applicability
Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.
| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | `flat` |
| `effect` | `glow`, `ring` |
| `shader` | — |
| `rhythm` | `compact`, `regular` |
| `motion` | any value |
| `behavior` | — |
## Anatomy
The author writes:
```html
<button class="button -accent">Save</button>
```
After `initButton` runs, the DOM is:
```html
<button class="button -accent" data-malevich-ready="true">
<span class="button__bg" data-tweak-layer="background" hidden></span>
<span class="button__glow" data-tweak-layer="glow" hidden></span>
<span class="button__content">
<span class="button__label">Save</span>
</span>
<span class="button__fx" data-tweak-layer="effects" hidden></span>
</button>
```
Tweak layers are `hidden` by default and have zero paint cost. A tweak
(e.g. `t-glow-hover`) unhides the layer it needs.
## Tokens consumed
- `color.accent`, `color.accent-strong`, `color.ink-strong`,
`color.ink-inverse`, `color.danger`, `color.border-strong`
- `font.caption`, `font.body-support`, `font.body-regular`
- `radius.button`
- `space.inset-element-s`, `space.inset-element-m`, `space.inset-element-l`
- `space.gap-elements-s`
- `motion.fast`, `motion.easing-default`
Tier 3 (component-specific):
- `button.background-disabled`, `button.text-disabled`,
`button.border-disabled`
- `button.background-accent-active`, `button.background-danger-active`,
`button.background-ghost-hover`
## Accessibility contract
- Use `<button>` element, never `<div role="button">`.
- Provide visible text label OR `aria-label` for icon-only buttons.
`initButton` warns on the console if a button has neither.
- `disabled` attribute prevents focus and interaction.
- Focus visible via `:focus-visible` — 2px outline using
`color.accent` with 2px offset.
- Pressed state on keyboard activation (Enter, Space) handled by the
browser; visual feedback comes from `:active` and the runtime's
`[data-active="true"]` mirror.
## Guidance
### Do
- Use the variant that matches action priority — one accent button
per region.
- Keep button labels short (1–3 words ideal, 5 maximum).
- Use icon + label for clarity; avoid icon-only unless space-constrained.
### Don't
- Don't use multiple accent buttons next to each other.
- Don't style buttons with raw CSS values; use the variant system.
- Don't replace `<button>` with `<a>` for actions that don't navigate.
- Don't disable buttons silently; consider a tooltip explaining why.