Code-block

No highlighter registered in this preview — plain text + chrome only.

With language label

pnpm install
pnpm dev

TypeScript example

export function add(a: number, b: number): number {
  return a + b;
}

With line numbers

function greet(name) {
  return `Hello, ${name}!`;
}
console.log(greet("Malevich"));

Flat variant

.button {
  background: var(--color-accent);
  color: var(--color-ink-inverse);
}

Inverse variant

# Hello

A paragraph with *emphasis* and **bold**.

Opt-out of copy

Just plain text, no copy button.

  
/*
 * Code-block — block
 *
 * Multi-line code surface with optional syntax highlighting (via
 * registerHighlighter), language label, line numbers, and copy
 * button (via the copyable behavior modifier, default-on per the
 * v1.0 master diff §4.8).
 *
 * Per the answered design question:
 *   highlight(code, lang) → string HTML  (adapter pattern, like Icon)
 *
 *   <pre class="code-block" data-lang="ts" data-copyable="true">
 *     <code>const x = 1;</code>
 *   </pre>
 *
 *   <pre class="code-block" data-lang="bash" data-line-numbers="true">
 *     <code>pnpm install
 *     pnpm dev</code>
 *   </pre>
 */

@layer malevich.components {
  .code-block {
    --code-block-counter: 1;

    position: relative;
    display: block;
    margin: 0;

    padding-block: var(--space-inset-block-m);
    padding-inline: var(--space-inset-block-m);
    /* Reserve room for the copy button (per .malevich-copyable__button position) */
    padding-inline-end: calc(var(--space-inset-block-m) + var(--size-control-s));

    background-color: var(--color-surface-canvas);
    color: var(--color-ink-strong);

    border: var(--border-width-hairline) solid var(--color-border-muted);
    border-radius: var(--radius-card);

    overflow-x: auto;
  }

  /* The <code> child gets the mono typography */
  .code-block > code,
  .code-block code {
    display: block;
    font-family: var(--font-mono-m-family);
    font-size: var(--font-mono-m-size);
    font-weight: var(--font-mono-m-weight);
    line-height: var(--font-mono-m-line-height);
    letter-spacing: var(--font-mono-m-letter-spacing);
    color: inherit;
    background: transparent;
    padding: 0;
    border: 0;
    white-space: pre;
  }

  /* ---- Language label ---- */
  .code-block[data-lang]::before {
    content: attr(data-lang);
    position: absolute;
    inset-block-start: var(--space-inset-element-s);
    inset-inline-start: var(--space-inset-element-s);

    color: var(--color-ink-subtle);
    font-family: var(--font-overline-family);
    font-size: var(--font-overline-size);
    font-weight: var(--font-overline-weight);
    line-height: var(--font-overline-line-height);
    letter-spacing: var(--font-overline-letter-spacing);
    text-transform: uppercase;
    user-select: none;
    pointer-events: none;
  }

  /* Push code body down to make room for the language label */
  .code-block[data-lang] {
    padding-block-start: calc(var(--space-inset-block-m) + var(--space-inset-element-m));
  }

  /* ---- Line numbers via CSS counter ----
   *
   * Authors break code into one <span class="code-block__line"> per
   * line OR rely on the runtime to split textContent. Without the
   * runtime, line-numbers fall back to a single counter on the
   * <code> element (the whole block reads as line 1).
   */

  .code-block[data-line-numbers="true"] > code,
  .code-block[data-line-numbers="true"] code {
    counter-reset: code-block-line;
  }

  .code-block[data-line-numbers="true"] .code-block__line {
    display: block;
    counter-increment: code-block-line;
    padding-inline-start: var(--space-inset-section-s);
    position: relative;
  }

  .code-block[data-line-numbers="true"] .code-block__line::before {
    content: counter(code-block-line);
    position: absolute;
    inset-inline-start: 0;
    color: var(--color-ink-subtle);
    user-select: none;
    text-align: end;
    min-inline-size: var(--space-inset-element-l);
    font-variant-numeric: tabular-nums;
  }

  /* ---- Variants ---- */

  .code-block.-flat {
    border: 0;
    background-color: var(--color-surface-raised);
  }

  .code-block.-on-inverse {
    background-color: var(--color-ink-strong);
    color: var(--color-ink-inverse);
    border-color: var(--color-ink-strong);
  }
}
// Code-block — adapter + runtime.
//
// Per the answered design question:
//   highlight(code, lang) => string  (adapter pattern, like Icon)
//
// Consumers register a highlighter:
//
//   import { registerHighlighter } from "@malevich/components";
//   import { highlight } from "@malevich/code-block-shiki"; // example
//   registerHighlighter(highlight);
//
// The runtime walks `.code-block[data-lang]` elements, calls the
// registered highlighter with the text content + language, and swaps
// the <code> innerHTML. If no highlighter is registered, the block
// renders plain text. If data-line-numbers="true" is set, the runtime
// wraps each line in a `<span class="code-block__line">`.

const READY_ATTR = "data-malevich-ready";

export type Highlighter = (code: string, lang: string) => string;

let registeredHighlighter: Highlighter | null = null;

/**
 * Register a syntax highlighter. The function receives raw code and
 * a language identifier (the value of `data-lang`), and must return
 * inner HTML to be placed inside the host's <code> element. Return
 * an empty string or the original code to opt out for a specific
 * call.
 */
export function registerHighlighter(highlighter: Highlighter): void {
  registeredHighlighter = highlighter;
}

export function unregisterHighlighter(): void {
  registeredHighlighter = null;
}

function wrapLines(code: HTMLElement): void {
  // Split innerHTML on \n into spans. Preserves any inner tags by
  // splitting at top-level only.
  const html = code.innerHTML;
  if (!html.includes("\n")) {
    code.innerHTML = `<span class="code-block__line">${html}</span>`;
    return;
  }
  const lines = html.split("\n");
  code.innerHTML = lines
    .map((l) => `<span class="code-block__line">${l}</span>`)
    .join("");
}

export function initCodeBlock(host: HTMLElement): void {
  if (host.getAttribute(READY_ATTR) === "true") return;

  const code = host.querySelector<HTMLElement>("code");
  if (!code) {
    host.setAttribute(READY_ATTR, "true");
    return;
  }

  const lang = host.getAttribute("data-lang") ?? "";
  const wantsLineNumbers = host.getAttribute("data-line-numbers") === "true";

  // Run the highlighter, if any.
  if (registeredHighlighter && lang) {
    const raw = code.textContent ?? "";
    try {
      const highlighted = registeredHighlighter(raw, lang);
      if (highlighted && highlighted.length > 0) {
        code.innerHTML = highlighted;
      }
    } catch {
      // Silent fallback to plain text.
    }
  }

  if (wantsLineNumbers) wrapLines(code);

  host.setAttribute(READY_ATTR, "true");
}

export function initCodeBlocks(root: ParentNode = document): void {
  for (const host of root.querySelectorAll<HTMLElement>(".code-block")) {
    initCodeBlock(host);
  }
}

/**
 * Re-run highlighting + line-wrapping on every existing code-block.
 * Useful after registering a highlighter at runtime.
 */
export function refreshCodeBlocks(root: ParentNode = document): void {
  for (const host of root.querySelectorAll<HTMLElement>(".code-block")) {
    host.removeAttribute(READY_ATTR);
    initCodeBlock(host);
  }
}
# Code-Block — AGENTS.md

> Auto-generated from `code-block.docs.md` + the modifier applicability matrix.
> Edit those source files; this file regenerates on build.

## Identity

- **Component:** `code-block`
- **Layer:** blocks
- **Status:** stable
- **Last updated:** 2026-05-19

## Summary

- Documentation blocks longer than a single line.

## Variants

| Variant | Class | Effect |
|---|---|---|
| Default | `.code-block` | Bordered card on canvas |
| Flat | `.code-block.-flat` | No border, raised background |
| Inverse | `.code-block.-on-inverse` | Dark surface |

## Modifier applicability

Per ADR-0007. Modifiers attach via `data-{category}="value"` attributes.

| Category | Accepts |
|---|---|
| `background` | — |
| `surface` | `flat`, `elevated` |
| `effect` | `ring` |
| `shader` | — |
| `rhythm` | `compact`, `regular` |
| `motion` | — |
| `behavior` | `copyable` |

## Anatomy

```html
<!-- Plain block, no highlighter registered: renders as plain text + copy -->
<pre class="code-block" data-lang="ts">
<code>const x = 1;
const y = 2;
const z = x + y;</code>
</pre>

<!-- With language label, line numbers, copy on hover -->
<pre class="code-block" data-lang="bash" data-line-numbers="true">
<code>pnpm install
pnpm dev</code>
</pre>

<!-- Variants -->
<pre class="code-block -flat" data-lang="css">…</pre>
<pre class="code-block -on-inverse" data-lang="md">…</pre>
```

## Tokens consumed

- `--color-surface-canvas` / `--color-surface-raised` / `--color-ink-strong`
- `--color-ink-subtle` — language label + line-number color
- `--color-ink-inverse` — inverse text
- `--color-border-muted` — border
- `--space-inset-block-m`, `--space-inset-element-{s,m,l}`,
  `--space-inset-section-s`
- `--size-control-s` — reserved space for copy button
- `--radius-card`
- `--border-width-hairline`
- `--font-mono-m-*`, `--font-overline-*`

## Accessibility contract

- Use `<pre>` as the host and a child `<code>` for the content. Both
  carry implicit semantics.
- The language label is a decorative `::before` pseudo-element —
  excluded from the accessibility tree.
- Line numbers are decorative `::before` counters — excluded.
- The copy button has `aria-label="Copy to clipboard"` (from the
  copyable behavior).

## Guidance

### Do

- Use `<pre><code>` markup.
- Set `data-lang` so the language label renders even without a
  highlighter.
- Reach for `refreshCodeBlocks()` after registering or swapping a
  highlighter at runtime.

### Don't

- Don't put inline tokens inside the host `<pre>` — keep code inside
  the inner `<code>`.
- Don't write highlighting HTML manually for static blocks. Register
  a highlighter or omit highlighting.