← code

lambda reducer

An untyped lambda calculus interpreter — step through beta-reductions on a syntax tree, watching Church encodings and combinators unfold.

@λxxa

(λx.x) a

state runningstep 0
strategy
presets

The identity combinator, I = λx.x, applied to a free variable a. One beta-reduction: substitute a for x in the body x, giving a.

The whole language is three rules: a variable, an abstraction λx.body (a function of one argument), and an application of one term to another. That's it — no numbers, no booleans, no loops. Alongside the Turing machine on this site, this was the other model of computation proposed in the 1930s, and the two turned out to be exactly as powerful as each other. Every node in the tree above is one of those three forms; the whole point of this demo is watching that tree actually change shape as it computes.

Computing happens by beta reduction: an application whose function side is an abstraction — (λx.body) arg, called a redex, highlighted in green — collapses by substituting the argument for every free occurrence of x in the body. The one classic way to get this wrong is capturing a variable: substituting y for x inside λy.x can't just produce λy.y, since that silently turns a reference to some outer y into the function's own argument. The fix is to rename the bound y to something fresh first — the interpreter does this automatically, and it was checked against exactly this bug, and a nested version of it, before being wired in here.

Normal order always reduces the leftmost, outermost redex first, which guarantees it reaches a normal form if one exists. Applicative order insists on reducing a redex's argument down to a value before substituting it in — closer to how most real programming languages evaluate function calls. The normal vs applicative preset is built to expose the gap between them: (λx.y) Ω, where Ω is a term that reduces to itself forever, finishes in one step under normal order — x is never used, so Ω is never touched — and never finishes under applicative order, which insists on evaluating Ω first regardless.

None of the other presets use a single built-in number or boolean. TRUE and FALSE are just the two functions that pick their first or second argument; a Church numeral n is "apply a function n times"; addition is composing two of those application-counts together. The S K K preset goes a step further and drops variables from the definitions entirely — S and K alone are enough to build anything a lambda expression can, including the identity function, which is exactly what that preset demonstrates.

view source
import { useEffect, useMemo, useRef, useState } from 'react';

// ---------- AST ----------
type Term =
  | { tag: 'var'; name: string }
  | { tag: 'abs'; param: string; body: Term }
  | { tag: 'app'; func: Term; arg: Term };

const Var = (name: string): Term => ({ tag: 'var', name });
const Abs = (param: string, body: Term): Term => ({ tag: 'abs', param, body });
const App = (func: Term, arg: Term): Term => ({ tag: 'app', func, arg });

// ---------- Parser ----------
// expr := abs | app
// abs  := ('\' | 'λ') ident+ '.' expr        (multi-param sugar: \x y.e = \x.\y.e)
// app  := atom+                               (left-associative)
// atom := ident | '(' expr ')'
type Token =
  { t: 'lambda' | 'dot' | 'lparen' | 'rparen' } | { t: 'ident'; v: string };

function tokenize(src: string): Token[] {
  const tokens: Token[] = [];
  let i = 0;
  while (i < src.length) {
    const c = src[i];
    if (/\s/.test(c)) {
      i++;
      continue;
    }
    if (c === '\\' || c === 'λ') {
      tokens.push({ t: 'lambda' });
      i++;
      continue;
    }
    if (c === '.') {
      tokens.push({ t: 'dot' });
      i++;
      continue;
    }
    if (c === '(') {
      tokens.push({ t: 'lparen' });
      i++;
      continue;
    }
    if (c === ')') {
      tokens.push({ t: 'rparen' });
      i++;
      continue;
    }
    if (/[A-Za-z_]/.test(c)) {
      let j = i + 1;
      while (j < src.length && /[A-Za-z0-9_']/.test(src[j])) j++;
      tokens.push({ t: 'ident', v: src.slice(i, j) });
      i = j;
      continue;
    }
    throw new Error(`unexpected character '${c}'`);
  }
  return tokens;
}

function parse(src: string): Term {
  const tokens = tokenize(src);
  let pos = 0;
  const peek = () => tokens[pos];
  const eof = () => pos >= tokens.length;

  function parseExpr(): Term {
    if (!eof() && peek().t === 'lambda') return parseAbs();
    return parseApp();
  }

  function parseAbs(): Term {
    pos++;
    const params: string[] = [];
    while (!eof() && peek().t === 'ident') {
      params.push((peek() as { t: 'ident'; v: string }).v);
      pos++;
    }
    if (params.length === 0)
      throw new Error('expected at least one parameter after λ');
    if (eof() || peek().t !== 'dot')
      throw new Error("expected '.' after lambda parameters");
    pos++;
    const body = parseExpr();
    return params.reduceRight((acc, p) => Abs(p, acc), body);
  }

  function parseApp(): Term {
    let head = parseAtom();
    while (
      !eof() &&
      (peek().t === 'ident' || peek().t === 'lparen' || peek().t === 'lambda')
    ) {
      const arg = peek().t === 'lambda' ? parseAbs() : parseAtom();
      head = App(head, arg);
    }
    return head;
  }

  function parseAtom(): Term {
    if (eof()) throw new Error('unexpected end of input');
    if (peek().t === 'ident') {
      const v = (peek() as { t: 'ident'; v: string }).v;
      pos++;
      return Var(v);
    }
    if (peek().t === 'lparen') {
      pos++;
      const e = parseExpr();
      if (eof() || peek().t !== 'rparen') throw new Error("expected ')'");
      pos++;
      return e;
    }
    throw new Error(`unexpected token`);
  }

  const result = parseExpr();
  if (!eof()) throw new Error('unexpected trailing input');
  return result;
}

// ---------- Free variables ----------
function freeVars(t: Term, acc: Set<string> = new Set()): Set<string> {
  if (t.tag === 'var') {
    acc.add(t.name);
    return acc;
  }
  if (t.tag === 'abs') {
    freeVars(t.body, acc);
    acc.delete(t.param);
    return acc;
  }
  freeVars(t.func, acc);
  freeVars(t.arg, acc);
  return acc;
}

function freshName(base: string, avoid: Set<string>): string {
  let candidate = base;
  while (avoid.has(candidate)) candidate = candidate + "'";
  return candidate;
}

// Every substitution site gets a fresh clone of `repl` -- without this, terms
// substituted into several positions would alias the same object by
// reference, which silently breaks anything that identifies a specific tree
// node by identity (here: redex highlighting). Verified via a standalone
// aliasing sweep across every intermediate term of several full reduction
// traces before this was ported in.
function cloneTerm(t: Term): Term {
  if (t.tag === 'var') return Var(t.name);
  if (t.tag === 'app') return App(cloneTerm(t.func), cloneTerm(t.arg));
  return Abs(t.param, cloneTerm(t.body));
}

// Capture-avoiding substitution: t[name := repl]. Verified against the
// canonical capture bug ((λy.x)[x:=y] must alpha-rename the bound y rather
// than let it capture the substituted-in free y) plus a nested-binder
// variant, standalone, before being ported here.
function substitute(t: Term, name: string, repl: Term): Term {
  if (t.tag === 'var') return t.name === name ? cloneTerm(repl) : t;
  if (t.tag === 'app')
    return App(substitute(t.func, name, repl), substitute(t.arg, name, repl));
  if (t.param === name) return t;
  const replFree = freeVars(repl);
  if (!replFree.has(t.param))
    return Abs(t.param, substitute(t.body, name, repl));
  const avoid = new Set([...replFree, ...freeVars(t.body), name]);
  const freshParam = freshName(t.param, avoid);
  const renamedBody = substitute(t.body, t.param, Var(freshParam));
  return Abs(freshParam, substitute(renamedBody, name, repl));
}

// Normal order (leftmost-outermost): guaranteed to reach a normal form if
// one exists, since it never reduces an argument the function discards.
function stepNormal(t: Term): Term | null {
  if (t.tag === 'var') return null;
  if (t.tag === 'app') {
    if (t.func.tag === 'abs')
      return substitute(t.func.body, t.func.param, t.arg);
    const f2 = stepNormal(t.func);
    if (f2) return App(f2, t.arg);
    const a2 = stepNormal(t.arg);
    if (a2) return App(t.func, a2);
    return null;
  }
  const b2 = stepNormal(t.body);
  return b2 ? Abs(t.param, b2) : null;
}

// Applicative order: reduce a redex's function and argument to normal form
// before applying. Can diverge on terms normal order handles fine, e.g.
// (λx.y) Ω -- normal order never touches Ω since x isn't used; applicative
// order insists on reducing Ω first and never finishes.
function stepApplicative(t: Term): Term | null {
  if (t.tag === 'var') return null;
  if (t.tag === 'app') {
    const f2 = stepApplicative(t.func);
    if (f2) return App(f2, t.arg);
    const a2 = stepApplicative(t.arg);
    if (a2) return App(t.func, a2);
    if (t.func.tag === 'abs')
      return substitute(t.func.body, t.func.param, t.arg);
    return null;
  }
  const b2 = stepApplicative(t.body);
  return b2 ? Abs(t.param, b2) : null;
}

// Locates (without reducing) the node the matching stepper would reduce
// next, purely for highlighting -- verified in lockstep with stepNormal /
// stepApplicative across full reduction traces before being ported here.
function findRedexNormal(t: Term): Term | null {
  if (t.tag === 'var') return null;
  if (t.tag === 'app') {
    if (t.func.tag === 'abs') return t;
    return findRedexNormal(t.func) ?? findRedexNormal(t.arg);
  }
  return findRedexNormal(t.body);
}
function findRedexApplicative(t: Term): Term | null {
  if (t.tag === 'var') return null;
  if (t.tag === 'app') {
    return (
      findRedexApplicative(t.func) ??
      findRedexApplicative(t.arg) ??
      (t.func.tag === 'abs' ? t : null)
    );
  }
  return findRedexApplicative(t.body);
}

// ---------- Pretty printer (minimal parens, multi-param sugar) ----------
function print(t: Term, ctx: 'top' | 'app-func' | 'app-arg' = 'top'): string {
  if (t.tag === 'var') return t.name;
  if (t.tag === 'abs') {
    const params = [t.param];
    let body = t.body;
    while (body.tag === 'abs') {
      params.push(body.param);
      body = body.body;
    }
    const s = `λ${params.join(' ')}.${print(body, 'top')}`;
    return ctx === 'top' ? s : `(${s})`;
  }
  const f = print(t.func, 'app-func');
  const a = print(t.arg, 'app-arg');
  const s = `${f} ${a}`;
  return ctx === 'app-arg' ? `(${s})` : s;
}

// ---------- Named combinators (macro-expanded before parsing) ----------
const DEFS: [string, string][] = [
  ['TRUE', '\\x y.x'],
  ['FALSE', '\\x y.y'],
  ['AND', '\\p q.p q p'],
  ['OR', '\\p q.p p q'],
  ['NOT', '\\p a b.p b a'],
  ['ZERO', '\\f x.x'],
  ['ONE', '\\f x.f x'],
  ['TWO', '\\f x.f (f x)'],
  ['THREE', '\\f x.f (f (f x))'],
  ['SUCC', '\\n f x.f (n f x)'],
  ['PLUS', '\\m n f x.m f (n f x)'],
  ['MULT', '\\m n f.m (n f)'],
  ['Y', '\\f.(\\x.f (x x)) (\\x.f (x x))'],
  ['OMEGA', '(\\x.x x) (\\x.x x)'],
  ['I', '\\x.x'],
  ['K', '\\x y.x'],
  ['S', '\\x y z.x z (y z)'],
];

function expand(src: string): string {
  let out = src;
  let changed = true;
  let guard = 0;
  while (changed && guard++ < 50) {
    changed = false;
    for (const [name, def] of DEFS) {
      const re = new RegExp(`\\b${name}\\b`, 'g');
      if (re.test(out)) {
        out = out.replace(re, `(${def})`);
        changed = true;
      }
    }
  }
  return out;
}

function parseNamed(src: string): Term {
  return parse(expand(src));
}

// ---------- Tree layout ----------
interface LNode {
  term: Term;
  label: string;
  x: number;
  y: number;
  w: number;
  children: LNode[];
}

const CHAR_W = 8;
const NODE_H = 26;
const NODE_PAD = 16;
const X_GAP = 16;
const Y_GAP = 52;
const V_PAD = 8;
// The root node's y-center sits at depth 0; its box extends NODE_H / 2 above
// that center. Without an offset, that puts the root's top edge above y=0 --
// outside the SVG's default viewBox, which starts at (0,0) -- so the root
// node's top would be silently clipped. Shifting every node down by this
// much keeps the root's top edge (plus a little breathing room) on-canvas.
const TOP_PAD = NODE_H / 2 + V_PAD;

function nodeWidth(label: string): number {
  return Math.max(30, label.length * CHAR_W + NODE_PAD);
}

function buildLayout(t: Term): { root: LNode; width: number; height: number } {
  let cursorX = 0;
  let maxDepth = 0;

  function place(term: Term, depth: number): LNode {
    maxDepth = Math.max(maxDepth, depth);
    if (term.tag === 'var') {
      const w = nodeWidth(term.name);
      const node: LNode = {
        term,
        label: term.name,
        x: cursorX + w / 2,
        y: depth * Y_GAP + TOP_PAD,
        w,
        children: [],
      };
      cursorX += w + X_GAP;
      return node;
    }
    if (term.tag === 'abs') {
      const params = [term.param];
      let body = term.body;
      while (body.tag === 'abs') {
        params.push(body.param);
        body = body.body;
      }
      const label = `λ${params.join(' ')}`;
      const child = place(body, depth + 1);
      const w = nodeWidth(label);
      return {
        term,
        label,
        x: child.x,
        y: depth * Y_GAP + TOP_PAD,
        w,
        children: [child],
      };
    }
    const left = place(term.func, depth + 1);
    const right = place(term.arg, depth + 1);
    const w = nodeWidth('@');
    return {
      term,
      label: '@',
      x: (left.x + right.x) / 2,
      y: depth * Y_GAP + TOP_PAD,
      w,
      children: [left, right],
    };
  }

  const root = place(t, 0);
  return {
    root,
    width: cursorX + 10,
    height: maxDepth * Y_GAP + TOP_PAD + NODE_H / 2 + V_PAD,
  };
}

function flatten(root: LNode): { nodes: LNode[]; edges: [LNode, LNode][] } {
  const nodes: LNode[] = [];
  const edges: [LNode, LNode][] = [];
  (function walk(n: LNode) {
    nodes.push(n);
    for (const c of n.children) {
      edges.push([n, c]);
      walk(c);
    }
  })(root);
  return { nodes, edges };
}

// ---------- Presets ----------
type Strategy = 'normal' | 'applicative';

interface Preset {
  id: string;
  label: string;
  expr: string;
  strategy: Strategy;
  blurb: string;
}

const PRESETS: Preset[] = [
  {
    id: 'identity',
    label: 'identity',
    expr: 'I a',
    strategy: 'normal',
    blurb:
      'The identity combinator, I = λx.x, applied to a free variable a. One beta-reduction: substitute a for x in the body x, giving a.',
  },
  {
    id: 'skk',
    label: 'S K K',
    expr: 'S K K z',
    strategy: 'normal',
    blurb:
      'S and K alone (no I) are enough to express anything: S K K behaves exactly like I. Watch it reduce down to plain z, the same as I z would.',
  },
  {
    id: 'plus',
    label: 'church: 2 + 3',
    expr: 'PLUS TWO THREE',
    strategy: 'normal',
    blurb:
      'Church numeral n is "apply f to x, n times." PLUS composes that: m f (n f x) applies f a total of m+n times. Step through to watch it fully unfold into five f-applications.',
  },
  {
    id: 'mult',
    label: 'church: 2 × 3',
    expr: 'MULT TWO THREE',
    strategy: 'normal',
    blurb:
      'MULT m n = λf. m (n f) composes n-f-applications, m times over — multiplication as function composition, not repeated addition.',
  },
  {
    id: 'bool',
    label: 'booleans: AND',
    expr: 'AND TRUE FALSE',
    strategy: 'normal',
    blurb:
      'TRUE and FALSE are just the two-argument selector functions λx y.x and λx y.y. AND p q = p q p: if p is TRUE it returns q, if p is FALSE it returns FALSE directly — no if-statement required.',
  },
  {
    id: 'y',
    label: 'Y combinator',
    expr: 'Y g',
    strategy: 'normal',
    blurb:
      'Y has no normal form — it unfolds forever. Step through a few times and watch Y g turn into g applied to a self-application that, if you kept going, would unfold into g (g (g (...))) — a fixed point built from nothing but substitution.',
  },
  {
    id: 'order',
    label: 'normal vs applicative',
    expr: '(\\x.y) OMEGA',
    strategy: 'normal',
    blurb:
      'Ω = (λx.x x)(λx.x x) reduces to itself forever. Under normal order this term still finishes in one step, because x never appears in y — the argument is simply never touched. Switch the strategy to applicative order and run it: it never terminates, because applicative order insists on reducing the argument before applying the function.',
  },
];

const MAX_STEPS = 400;

export default function LambdaReducer() {
  const [presetId, setPresetId] = useState(PRESETS[0].id);
  const [inputText, setInputText] = useState(PRESETS[0].expr);
  const [term, setTerm] = useState<Term>(() => parseNamed(PRESETS[0].expr));
  const [strategy, setStrategy] = useState<Strategy>(PRESETS[0].strategy);
  const [stepCount, setStepCount] = useState(0);
  const [playing, setPlaying] = useState(false);
  const [speed, setSpeed] = useState(500);
  const [parseError, setParseError] = useState<string | null>(null);

  const timerRef = useRef<number | null>(null);

  const stepper = strategy === 'normal' ? stepNormal : stepApplicative;
  const finder = strategy === 'normal' ? findRedexNormal : findRedexApplicative;

  const redex = useMemo(() => finder(term), [term, finder]);
  const status: 'running' | 'normal-form' | 'step-limit' = redex
    ? stepCount >= MAX_STEPS
      ? 'step-limit'
      : 'running'
    : 'normal-form';

  const layout = useMemo(() => buildLayout(term), [term]);
  const { nodes, edges } = useMemo(() => flatten(layout.root), [layout]);
  const printed = useMemo(() => print(term), [term]);

  useEffect(() => {
    if (!playing) return;
    if (status !== 'running') {
      setPlaying(false);
      return;
    }
    timerRef.current = window.setTimeout(() => {
      setTerm((t) => stepper(t) ?? t);
      setStepCount((c) => c + 1);
    }, speed);
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, [playing, term, speed, status, stepper]);

  const loadPreset = (preset: Preset) => {
    setPresetId(preset.id);
    setInputText(preset.expr);
    setStrategy(preset.strategy);
    setTerm(parseNamed(preset.expr));
    setStepCount(0);
    setPlaying(false);
    setParseError(null);
  };

  const loadInput = () => {
    setPlaying(false);
    try {
      const t = parseNamed(inputText);
      setTerm(t);
      setStepCount(0);
      setParseError(null);
      setPresetId('');
    } catch (e) {
      setParseError(e instanceof Error ? e.message : String(e));
    }
  };

  const stepOnce = () => {
    if (status !== 'running') return;
    setTerm((t) => stepper(t) ?? t);
    setStepCount((c) => c + 1);
  };

  const reset = () => {
    setPlaying(false);
    const src = presetId
      ? (PRESETS.find((p) => p.id === presetId)?.expr ?? inputText)
      : inputText;
    try {
      setTerm(parseNamed(src));
      setStepCount(0);
      setParseError(null);
    } catch (e) {
      setParseError(e instanceof Error ? e.message : String(e));
    }
  };

  const setStrategyAndReset = (s: Strategy) => {
    setStrategy(s);
    setPlaying(false);
  };

  const activePreset = PRESETS.find((p) => p.id === presetId);
  const statusLabel =
    status === 'running'
      ? 'running'
      : status === 'normal-form'
        ? 'normal form'
        : `step limit (${MAX_STEPS})`;

  return (
    <div className="lambda-reducer">
      <div className="tree-scroll">
        <svg
          width={layout.width}
          height={layout.height}
          role="img"
          aria-label="lambda expression syntax tree"
        >
          {edges.map(([p, c], i) => (
            <line
              key={i}
              x1={p.x}
              y1={p.y + NODE_H / 2}
              x2={c.x}
              y2={c.y - NODE_H / 2}
              stroke="#2a2d31"
              strokeWidth={1.5}
            />
          ))}
          {nodes.map((n, i) => {
            const isRedexApp = redex !== null && n.term === redex;
            const isRedexAbs =
              redex !== null && redex.tag === 'app' && n.term === redex.func;
            const highlighted = isRedexApp || isRedexAbs;
            return (
              <g key={i}>
                <rect
                  x={n.x - n.w / 2}
                  y={n.y - NODE_H / 2}
                  width={n.w}
                  height={NODE_H}
                  rx={5}
                  className={highlighted ? 'node redex' : 'node'}
                />
                <text
                  x={n.x}
                  y={n.y + 4}
                  textAnchor="middle"
                  className={highlighted ? 'label redex' : 'label'}
                >
                  {n.label}
                </text>
              </g>
            );
          })}
        </svg>
      </div>

      <p className="printed">{printed}</p>

      <div className="status-line">
        <span>
          state <strong>{statusLabel}</strong>
        </span>
        <span>
          step <strong>{stepCount}</strong>
        </span>
      </div>

      <div className="controls">
        <div className="group">
          <span className="group-label">strategy</span>
          <button
            type="button"
            className={strategy === 'normal' ? 'active' : ''}
            onClick={() => setStrategyAndReset('normal')}
          >
            normal order
          </button>
          <button
            type="button"
            className={strategy === 'applicative' ? 'active' : ''}
            onClick={() => setStrategyAndReset('applicative')}
          >
            applicative order
          </button>
        </div>
        <button
          type="button"
          onClick={stepOnce}
          disabled={status !== 'running'}
        >
          step
        </button>
        <button
          type="button"
          onClick={() => setPlaying((p) => !p)}
          disabled={status !== 'running' && !playing}
        >
          {playing ? 'pause' : 'play'}
        </button>
        <button type="button" onClick={reset}>
          reset
        </button>
      </div>

      <div className="field">
        <label htmlFor="lambda-input">expression</label>
        <input
          id="lambda-input"
          type="text"
          value={inputText}
          onChange={(e) => setInputText(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter') loadInput();
          }}
          spellCheck={false}
        />
        <button type="button" onClick={loadInput}>
          load
        </button>
        <label className="speed">
          speed
          <input
            type="range"
            min={80}
            max={1000}
            step={20}
            value={1080 - speed}
            onChange={(e) => setSpeed(1080 - Number(e.target.value))}
          />
        </label>
      </div>
      {parseError && <p className="error">{parseError}</p>}

      <div className="presets">
        <span className="group-label">presets</span>
        {PRESETS.map((preset) => (
          <button
            key={preset.id}
            type="button"
            className={presetId === preset.id ? 'active' : ''}
            onClick={() => loadPreset(preset)}
          >
            {preset.label}
          </button>
        ))}
      </div>

      <p className="blurb">
        {activePreset
          ? activePreset.blurb
          : 'Custom expression — use \\ or λ for lambda, and named combinators like TRUE, PLUS, Y as shorthand.'}
      </p>

      <style>{`
        .lambda-reducer {
          border: 1px solid #2a2d31;
          background: #0e1113;
          padding: 1rem;
        }
        .lambda-reducer .tree-scroll {
          overflow: auto;
          max-height: 24rem;
          border: 1px solid #2a2d31;
          background: #0b0d0f;
        }
        .lambda-reducer .node {
          fill: rgba(0, 0, 0, 0.4);
          stroke: #2a2d31;
        }
        .lambda-reducer .node.redex {
          fill: rgba(124, 242, 156, 0.12);
          stroke: var(--accent);
          stroke-width: 1.5;
        }
        .lambda-reducer .label {
          font-family: var(--mono);
          font-size: 12px;
          fill: #b7bcc4;
        }
        .lambda-reducer .label.redex {
          fill: var(--accent);
        }
        .lambda-reducer .printed {
          font-size: 0.85rem;
          margin: 0.75rem 0 0;
          word-break: break-word;
          max-height: 6rem;
          overflow-y: auto;
        }
        .lambda-reducer .status-line {
          display: flex;
          gap: 1.5rem;
          color: var(--muted);
          font-size: 0.85rem;
          margin: 0.5rem 0 0;
        }
        .lambda-reducer .status-line strong {
          color: var(--fg);
          font-weight: normal;
        }
        .lambda-reducer .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-top: 1rem;
          padding-top: 0.75rem;
          border-top: 1px solid #2a2d31;
        }
        .lambda-reducer .group {
          display: flex;
          align-items: center;
          gap: 0.4rem;
          flex-wrap: wrap;
        }
        .lambda-reducer .group-label {
          color: var(--muted);
          font-size: 0.75rem;
          text-transform: uppercase;
          letter-spacing: 0.04em;
          margin-right: 0.15rem;
        }
        .lambda-reducer button {
          font-family: inherit;
          font-size: 0.75rem;
          color: #8a8f98;
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.25rem 0.6rem;
          cursor: pointer;
        }
        .lambda-reducer button:hover:not(:disabled) {
          color: #e6e6e6;
        }
        .lambda-reducer button:disabled {
          opacity: 0.4;
          cursor: default;
        }
        .lambda-reducer button.active {
          color: #0b0d0f;
          background: var(--accent);
          border-color: var(--accent);
        }
        .lambda-reducer .field {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.6rem;
          margin-top: 0.75rem;
        }
        .lambda-reducer .field label {
          color: var(--muted);
          font-size: 0.75rem;
        }
        .lambda-reducer .field input[type='text'] {
          flex: 1;
          min-width: 12rem;
          font-family: var(--mono);
          font-size: 0.85rem;
          color: var(--fg);
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.3rem 0.5rem;
        }
        .lambda-reducer .speed {
          display: flex;
          align-items: center;
          gap: 0.5rem;
        }
        .lambda-reducer .error {
          color: #d98a8a;
          font-size: 0.8rem;
          margin: 0.5rem 0 0;
        }
        .lambda-reducer .presets {
          display: flex;
          align-items: center;
          gap: 0.4rem;
          flex-wrap: wrap;
          margin-top: 0.75rem;
        }
        .lambda-reducer .blurb {
          color: var(--muted);
          font-size: 0.8rem;
          margin: 0.75rem 0 0;
          max-width: 60ch;
        }
      `}</style>
    </div>
  );
}