/*
 * Button — element
 *
 * Styles cover both pre-runtime markup:
 *   <button class="button">Save</button>
 *
 * and post-runtime markup (after initButton expansion):
 *   <button class="button" 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>
 */

@layer malevich.components {
  .button {
    /* Reset native button defaults */
    appearance: none;
    -webkit-appearance: none;
    margin: 0;
    border: var(--border-width-hairline) solid transparent;
    background: transparent;
    color: inherit;
    cursor: pointer;
    user-select: none;

    /* Layout */
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-gap-elements-s);

    /* Default size = medium */
    padding-block: var(--space-inset-element-s);
    padding-inline: var(--space-inset-element-m);
    min-height: var(--size-control-m);

    /* Typography — semantic font tokens (body-support default) */
    font-family: var(--font-body-support-family);
    font-size: var(--font-body-support-size);
    font-weight: var(--font-body-support-weight);
    line-height: var(--font-body-support-line-height);
    letter-spacing: var(--font-body-support-letter-spacing);
    text-decoration: none;
    white-space: nowrap;

    /* Surface */
    border-radius: var(--radius-button);

    /* Motion */
    transition:
      background-color var(--motion-fast) var(--motion-easing-default),
      border-color var(--motion-fast) var(--motion-easing-default),
      color var(--motion-fast) var(--motion-easing-default),
      box-shadow var(--motion-fast) var(--motion-easing-default);
  }

  .button:focus-visible {
    outline: var(--border-width-focus) solid var(--color-accent);
    outline-offset: var(--border-width-focus);
  }

  .button:disabled,
  .button[aria-disabled="true"] {
    cursor: not-allowed;
    background-color: var(--button-background-disabled);
    color: var(--button-text-disabled);
    border-color: var(--button-border-disabled);
  }

  /* Content wrapper sits above tweak layers */
  .button__content {
    position: relative;
    z-index: 1;
    display: inline-flex;
    align-items: center;
    gap: var(--space-gap-elements-s);
  }

  .button__label {
    display: inline-block;
  }

  /* Tweak layers — inert until a tweak unhides them */
  .button__bg,
  .button__glow,
  .button__fx {
    position: absolute;
    inset: 0;
    pointer-events: none;
    border-radius: inherit;
    z-index: 0;
  }

  .button__fx {
    z-index: 2;
  }

  /* ----- Variants -----
   *
   * v1.0 names: primary, secondary, ghost, danger, success.
   * v0.1.0 names: accent (= primary), neutral (= secondary) retained
   * as aliases via :is() until v0.2.0 codemod migration completes.
   */

  .button.-primary,
  .button.-accent {
    background-color: var(--color-accent);
    color: var(--color-ink-inverse);
    border-color: var(--color-accent);
  }
  .button.-primary:hover:not(:disabled):not([aria-disabled="true"]),
  .button.-accent:hover:not(:disabled):not([aria-disabled="true"]) {
    background-color: var(--color-accent-strong);
    border-color: var(--color-accent-strong);
  }
  .button.-primary:active:not(:disabled):not([aria-disabled="true"]),
  .button.-primary[data-active="true"]:not(:disabled):not([aria-disabled="true"]),
  .button.-accent:active:not(:disabled):not([aria-disabled="true"]),
  .button.-accent[data-active="true"]:not(:disabled):not([aria-disabled="true"]) {
    background-color: var(--button-background-accent-active);
    border-color: var(--button-background-accent-active);
  }

  .button.-secondary,
  .button.-neutral {
    background-color: transparent;
    color: var(--color-ink-strong);
    border-color: var(--color-border-strong);
  }
  .button.-secondary:hover:not(:disabled):not([aria-disabled="true"]),
  .button.-neutral:hover:not(:disabled):not([aria-disabled="true"]) {
    background-color: var(--color-ink-strong);
    color: var(--color-ink-inverse);
    border-color: var(--color-ink-strong);
  }
  .button.-secondary:active:not(:disabled):not([aria-disabled="true"]),
  .button.-secondary[data-active="true"]:not(:disabled):not([aria-disabled="true"]),
  .button.-neutral:active:not(:disabled):not([aria-disabled="true"]),
  .button.-neutral[data-active="true"]:not(:disabled):not([aria-disabled="true"]) {
    background-color: var(--color-ink-strong);
    color: var(--color-ink-inverse);
    border-color: var(--color-ink-strong);
    opacity: 0.85;
  }

  .button.-ghost {
    background-color: transparent;
    color: var(--color-ink-strong);
    border-color: transparent;
  }
  .button.-ghost:hover:not(:disabled):not([aria-disabled="true"]) {
    background-color: var(--button-background-ghost-hover);
  }
  .button.-ghost:active:not(:disabled):not([aria-disabled="true"]),
  .button.-ghost[data-active="true"]:not(:disabled):not([aria-disabled="true"]) {
    background-color: var(--button-background-ghost-hover);
    opacity: 0.85;
  }

  .button.-danger {
    background-color: var(--color-danger);
    color: var(--color-ink-inverse);
    border-color: var(--color-danger);
  }
  .button.-danger:hover:not(:disabled):not([aria-disabled="true"]) {
    opacity: 0.9;
  }
  .button.-danger:active:not(:disabled):not([aria-disabled="true"]),
  .button.-danger[data-active="true"]:not(:disabled):not([aria-disabled="true"]) {
    background-color: var(--button-background-danger-active);
    border-color: var(--button-background-danger-active);
  }

  .button.-success {
    background-color: var(--color-success);
    color: var(--color-ink-inverse);
    border-color: var(--color-success);
  }
  .button.-success:hover:not(:disabled):not([aria-disabled="true"]) {
    opacity: 0.9;
  }
  .button.-success:active:not(:disabled):not([aria-disabled="true"]),
  .button.-success[data-active="true"]:not(:disabled):not([aria-disabled="true"]) {
    opacity: 0.8;
  }

  /* ----- Sizes ----- */

  .button.-s {
    padding-block: var(--space-inset-element-s);
    padding-inline: var(--space-inset-element-s);
    min-height: var(--size-control-s);
    font-family: var(--font-caption-family);
    font-size: var(--font-caption-size);
    font-weight: var(--font-caption-weight);
    line-height: var(--font-caption-line-height);
    letter-spacing: var(--font-caption-letter-spacing);
  }

  .button.-l {
    padding-block: var(--space-inset-element-m);
    padding-inline: var(--space-inset-element-l);
    min-height: var(--size-control-l);
    font-family: var(--font-body-regular-family);
    font-size: var(--font-body-regular-size);
    font-weight: var(--font-body-regular-weight);
    line-height: var(--font-body-regular-line-height);
    letter-spacing: var(--font-body-regular-letter-spacing);
  }
}
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.