← code

enigma

A working Wehrmacht Enigma I, with the signal traced through every rotor — and the flaw in it that Turing turned into a machine.

left (slow)notch Q
middlenotch E
right (fast)notch V
reflector6 leads
loada real intercept with its real settings — it decrypts to German
IA
IIB
IIIL
rotor windows after 37 keys

Set the same settings again and type the output back in — the machine undoes itself.

signal path for the last letter typed: T lights I

Tkey
Uplugboard
Arotor III
Irotor II
Vrotor I
Wreflector B
Nrotor I
Xrotor II
Frotor III
Iplugboard
Ilamp

The current goes out through the rotors, turns around at the reflector, and comes back through them on a different path. That return trip is what makes the machine self-reciprocal — and it is also why a letter can never come back as itself.

typed lit

With five rotors to choose three from, 263 start positions and ten plugboard leads, the settings alone number about 1.59 × 10^20 — and that is what made the machine look unbreakable to the people using it.

the machine

The Enigma machine is an electro-mechanical substitution cipher where the substitution changes on every single keypress. Current leaves the key, crosses the plugboard, passes through three rotors in series, hits a reflector, and returns through the same three rotors by a different route before lighting a lamp. Then the right-hand rotor advances one position, so the next letter takes an entirely different path. Press the same key twice and you generally get two different letters — the trace above shows exactly where the two journeys diverge.

real wirings

The rotor wirings here are the real ones issued with the Wehrmacht Enigma I, and so are the turnover notches: rotor I steps its neighbour when it leaves Q, rotor II at E, III at V, IV at J, V at Z. Bletchley Park remembered that list as Royal Flags Wave Kings Above, since the turnovers land on R, F, W, K and A. The odometer analogy is nearly right but not quite: when the middle rotor sits on its own notch it advances again on the very next keypress and drags the left rotor along with it, so it moves on two consecutive presses. That is the double-stepping anomaly, a genuine quirk of the pawl mechanism rather than a design intention, and the readout above flags it when it happens.

counting the settings

Counting the settings explains the German confidence. Three rotors chosen from five in order gives 5⋅4⋅3=605 \cdot 4 \cdot 3 = 60 arrangements, and each has 26326^3 start positions — but the plugboard dominates everything. Choosing pp unordered pairs from 26 letters is

26!(26−2p)!  p!  2p  =p=10  150,738,274,937,250  ≈  1.5×1014\frac{26!}{(26-2p)!\; p!\; 2^{p}} \;\overset{p=10}{=}\; 150{,}738{,}274{,}937{,}250 \;\approx\; 1.5 \times 10^{14}

where the factorial picks the twenty letters in order, p!p!removes the ordering of the pairs themselves, and 2p2^{p}removes the ordering within each pair. Multiplying the three factors together,

60⋅263⋅1.5×1014  ≈  1.59×102060 \cdot 26^{3} \cdot 1.5 \times 10^{14} \;\approx\; 1.59 \times 10^{20}

configurations — the figure quoted in the panel above, computed there rather than typed in. Brute force was never the answer, and it is not how Enigma fell.

how it broke

What sank it is visible in the trace above. Because the reflector pairs letters up and the current returns through the same rotors, the machine is its own inverse — the same settings that encipher a message decipher it, which is exactly what made it practical to use in the field. The price is that no letter can ever encrypt to itself. A reflector that mapped a letter to itself would send the current straight back the way it came, so it is wired as pairs with no fixed points, and that property leaks. Line up a guess at the plaintext against the ciphertext, and any position where the two agree is impossible — so that alignment can be discarded without testing a single rotor setting.

That guess is called a crib, and it was available because military traffic is repetitive: a daily weather report carrying WETTER in the same place, formulaic sign-offs, station identifiers. The Bombe that Alan Turing designed at Bletchley Park mechanised the consequence: it chained together the letter-to-letter implications of a crib and ran through rotor positions looking for a contradiction, rejecting settings wholesale rather than checking them one at a time. Gordon Welchman's diagonal board then let it solve the plugboard separately from the wheel settings, cutting a run from days to hours.

the polish head start

Turing did not start from nothing, and the part usually left out of the story came first. Marian Rejewski of the Polish Cipher Bureau reconstructed the internal wiring of the machine in December 1932 using the theory of permutation groups — deducing what was inside a device he had never seen from the structure of its output. Working with Jerzy Różycki and Henryk Zygalski he built the card catalogue, the cyclometer and an electromechanical bomba; in July 1939, weeks before the invasion, the Poles handed all of it to French and British intelligence. The British effort was built on that foundation.

related ciphers

This demo sits deliberately between two others. The vigenère cipher is the same idea one step back — a substitution that changes as you go — and it falls to letter statistics because its key repeats every few characters. Enigma's rotors return to their starting position only after 16,900 keypresses — 26 × 26 × 25 rather than 263, because double-stepping quietly eats one middle-rotor position every cycle — which is why counting letters gets you nowhere here and structural attacks were needed instead. In the other direction, the man who designed the Bombe also wrote down the Turing machine four years earlier, and the wartime problem and the abstract one are recognisably the same question about what a machine can be made to decide.

scope

One caveat worth stating plainly: everything above is the Wehrmacht Enigma I with three rotors. The Kriegsmarine's four-rotor M4, used on the U-boat network, is a harder machine and is not simulated here. And the lesson is not that Enigma was badly designed — it is that a system can be astronomically large and still fall to a structural property its designers never thought to hide, which is the same reason the hash collision demo cares about the birthday bound rather than the size of the search space.

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

const A = 'A'.charCodeAt(0);
const idx = (ch: string) => ch.charCodeAt(0) - A;
const chr = (i: number) => String.fromCharCode(A + (((i % 26) + 26) % 26));

// Wehrmacht Enigma I rotor wirings and turnover notches, from the published
// tables. The notch letter is the window letter showing when the *next*
// keypress carries the rotor to its left: Q, E, V, J, Z -- remembered at
// Bletchley Park as "Royal Flags Wave Kings Above", since the turnovers land
// on R, F, W, K and A.
const ROTORS = {
  I: { wiring: 'EKMFLGDQVZNTOWYHXUSPAIBRCJ', notch: 'Q' },
  II: { wiring: 'AJDKSIRUXBLHWTMCQGZNPYFVOE', notch: 'E' },
  III: { wiring: 'BDFHJLCPRTXVZNYEIWGAKMUSQO', notch: 'V' },
  IV: { wiring: 'ESOVPZJAYQUIRHXLNFTGKDCMWB', notch: 'J' },
  V: { wiring: 'VZBRGITYUPSDNHLXAWMJQOFECK', notch: 'Z' },
} as const;

type RotorName = keyof typeof ROTORS;
const ROTOR_NAMES = Object.keys(ROTORS) as RotorName[];

// UKW-A was the reflector in service through the early 1930s -- it is
// included so the 1930 instruction-manual message below can actually be
// decrypted here. B replaced it in 1937 and was standard for most of the
// war; C appeared later and saw limited use.
const REFLECTORS = {
  A: 'EJMZALYXVBWFCRQUONTSPIKHGD',
  B: 'YRUHQSLDPXNGOKMIEBFZCWVJAT',
  C: 'FVPJIAOYEDRZXWGCTKUQSBNMHL',
} as const;
type ReflectorName = keyof typeof REFLECTORS;

// The German keyboard/lampboard layout, not QWERTY.
const KEY_ROWS = ['QWERTZUIO', 'ASDFGHJK', 'PYXCVBNML'];

function invert(wiring: string): number[] {
  const out = new Array(26);
  for (let i = 0; i < 26; i++) out[idx(wiring[i])] = i;
  return out;
}

const FORWARD = Object.fromEntries(
  ROTOR_NAMES.map((n) => [n, ROTORS[n].wiring.split('').map(idx)]),
) as Record<RotorName, number[]>;
const BACKWARD = Object.fromEntries(
  ROTOR_NAMES.map((n) => [n, invert(ROTORS[n].wiring)]),
) as Record<RotorName, number[]>;

function parsePlugboard(text: string): {
  map: number[];
  pairs: string[];
  error: string | null;
} {
  const map = Array.from({ length: 26 }, (_, i) => i);
  const cleaned = text.toUpperCase().replace(/[^A-Z]/g, '');
  if (cleaned.length % 2 !== 0) {
    return { map, pairs: [], error: 'plugboard needs letters in pairs' };
  }
  const used = new Set<string>();
  const pairs: string[] = [];
  for (let i = 0; i < cleaned.length; i += 2) {
    const a = cleaned[i];
    const b = cleaned[i + 1];
    if (a === b) return { map, pairs, error: `${a} cannot connect to itself` };
    if (used.has(a) || used.has(b)) {
      return { map, pairs, error: `${used.has(a) ? a : b} is already plugged` };
    }
    used.add(a);
    used.add(b);
    pairs.push(a + b);
    map[idx(a)] = idx(b);
    map[idx(b)] = idx(a);
  }
  if (pairs.length > 13) return { map, pairs, error: 'at most 13 pairs' };
  return { map, pairs, error: null };
}

interface Config {
  order: RotorName[]; // left, middle, right
  rings: string;
  positions: string;
  reflector: ReflectorName;
  plugMap: number[];
}

interface Trace {
  input: string;
  output: string;
  stages: { label: string; letter: string }[];
  doubleStep: boolean;
}

interface RunResult {
  output: string;
  windows: string;
  trace: Trace | null;
  stepCount: number;
}

// Runs the whole message from the configured start position each time.
// Recomputing beats keeping mutable rotor state in sync with edits to the
// text or the settings, and 26 letters of arithmetic per character is
// nothing.
function run(text: string, cfg: Config): RunResult {
  const pos = cfg.positions.split('').map(idx);
  const ring = cfg.rings.split('').map(idx);
  const notch = cfg.order.map((n) => idx(ROTORS[n].notch));
  const refl = REFLECTORS[cfg.reflector].split('').map(idx);
  const plug = cfg.plugMap;
  const letters = text
    .toUpperCase()
    .replace(/[^A-Z]/g, '')
    .split('');

  let output = '';
  let trace: Trace | null = null;
  let stepCount = 0;

  const through = (r: number, c: number, back: boolean) => {
    const shift = pos[r] - ring[r];
    const inp = (((c + shift) % 26) + 26) % 26;
    const out = back ? BACKWARD[cfg.order[r]][inp] : FORWARD[cfg.order[r]][inp];
    return (((out - shift) % 26) + 26) % 26;
  };

  for (let i = 0; i < letters.length; i++) {
    // Stepping happens before the key makes contact. The double-step
    // anomaly is the first branch: a middle rotor sitting on its own notch
    // advances again and takes the left rotor with it, so it moves on two
    // consecutive keypresses.
    const doubleStep = pos[1] === notch[1];
    if (doubleStep) {
      pos[1] = (pos[1] + 1) % 26;
      pos[0] = (pos[0] + 1) % 26;
    } else if (pos[2] === notch[2]) {
      pos[1] = (pos[1] + 1) % 26;
    }
    pos[2] = (pos[2] + 1) % 26;
    stepCount++;

    const stages: { label: string; letter: string }[] = [];
    let c = idx(letters[i]);
    stages.push({ label: 'key', letter: chr(c) });
    c = plug[c];
    stages.push({ label: 'plugboard', letter: chr(c) });
    c = through(2, c, false);
    stages.push({ label: `rotor ${cfg.order[2]}`, letter: chr(c) });
    c = through(1, c, false);
    stages.push({ label: `rotor ${cfg.order[1]}`, letter: chr(c) });
    c = through(0, c, false);
    stages.push({ label: `rotor ${cfg.order[0]}`, letter: chr(c) });
    c = refl[c];
    stages.push({ label: `reflector ${cfg.reflector}`, letter: chr(c) });
    c = through(0, c, true);
    stages.push({ label: `rotor ${cfg.order[0]}`, letter: chr(c) });
    c = through(1, c, true);
    stages.push({ label: `rotor ${cfg.order[1]}`, letter: chr(c) });
    c = through(2, c, true);
    stages.push({ label: `rotor ${cfg.order[2]}`, letter: chr(c) });
    c = plug[c];
    stages.push({ label: 'plugboard', letter: chr(c) });
    stages.push({ label: 'lamp', letter: chr(c) });

    output += chr(c);
    if (i === letters.length - 1) {
      trace = { input: letters[i], output: chr(c), stages, doubleStep };
    }
  }

  return { output, windows: pos.map(chr).join(''), trace, stepCount };
}

const DEFAULT_TEXT = 'FEINDLICHEINFANTERIEKOLONNEBEOBACHTET';

// The test message printed in the 1930 Enigma instruction manuals, a copy of
// which was handed to French intelligence by Hans-Thilo Schmidt in 1931.
// Loading this reproduces a real decryption rather than a made-up one.
const HISTORIC = {
  order: ['II', 'I', 'III'] as RotorName[],
  rings: 'XMV', // Ringstellung 24 13 22
  positions: 'ABL', // the message key
  reflector: 'A' as ReflectorName,
  plugs: 'AM FI NV PS TU WZ',
  cipher:
    'GCDSE AHUGW TQGRK VLFGX UCALX VYMIG MMNMF DXTGN VHVRM MEVOU YFZSL ' +
    'RHDRR XFJWC FHUHM UNZEF RDISI KBGPM YVXUZ',
};

// 26^3 start positions x 60 wheel orders from a set of five x the plugboard
// combinations for ten leads. Computed rather than quoted.
function plugboardCombinations(pairs: number): number {
  // 26! / ((26-2p)! * p! * 2^p)
  let n = 1;
  for (let i = 0; i < 2 * pairs; i++) n *= 26 - i;
  let fact = 1;
  for (let i = 2; i <= pairs; i++) fact *= i;
  return n / (fact * 2 ** pairs);
}

export default function Enigma() {
  const [order, setOrder] = useState<RotorName[]>(['I', 'II', 'III']);
  const [rings, setRings] = useState('AAA');
  const [positions, setPositions] = useState('AAA');
  const [reflector, setReflector] = useState<ReflectorName>('B');
  const [plugText, setPlugText] = useState('AM FI NV PS TU WZ');
  const [text, setText] = useState(DEFAULT_TEXT);

  const plug = useMemo(() => parsePlugboard(plugText), [plugText]);

  const cfg: Config = useMemo(
    () => ({ order, rings, positions, reflector, plugMap: plug.map }),
    [order, rings, positions, reflector, plug.map],
  );

  const result = useMemo(() => run(text, cfg), [text, cfg]);

  const duplicateRotor = new Set(order).size !== 3;

  const setRotor = (slot: number, name: RotorName) => {
    const next = [...order];
    next[slot] = name;
    setOrder(next);
  };
  const setLetter = (
    value: string,
    setter: (s: string) => void,
    current: string,
    slot: number,
  ) => {
    const v = value.toUpperCase().replace(/[^A-Z]/g, '');
    if (!v) return;
    const next = current.split('');
    next[slot] = v[v.length - 1];
    setter(next.join(''));
  };

  const keySpace = useMemo(() => {
    const orders = 5 * 4 * 3;
    const starts = 26 ** 3;
    const plugs = plugboardCombinations(10);
    return orders * starts * plugs;
  }, []);

  const slotLabels = ['left (slow)', 'middle', 'right (fast)'];

  return (
    <div className="enigma">
      <div className="settings">
        {[0, 1, 2].map((slot) => (
          <div className="rotor-slot" key={slot}>
            <span className="slot-label">{slotLabels[slot]}</span>
            <select
              value={order[slot]}
              onChange={(e) => setRotor(slot, e.target.value as RotorName)}
              aria-label={`${slotLabels[slot]} rotor`}
            >
              {ROTOR_NAMES.map((n) => (
                <option key={n} value={n}>
                  {n}
                </option>
              ))}
            </select>
            <label>
              ring
              <input
                type="text"
                value={rings[slot]}
                onChange={(e) =>
                  setLetter(e.target.value, setRings, rings, slot)
                }
                maxLength={2}
                aria-label={`${slotLabels[slot]} ring setting`}
              />
            </label>
            <label>
              start
              <input
                type="text"
                value={positions[slot]}
                onChange={(e) =>
                  setLetter(e.target.value, setPositions, positions, slot)
                }
                maxLength={2}
                aria-label={`${slotLabels[slot]} start position`}
              />
            </label>
            <span className="notch-hint">
              notch {ROTORS[order[slot]].notch}
            </span>
          </div>
        ))}

        <div className="misc-row">
          <span className="slot-label">reflector</span>
          {(Object.keys(REFLECTORS) as ReflectorName[]).map((r) => (
            <button
              key={r}
              type="button"
              className={r === reflector ? 'active' : ''}
              onClick={() => setReflector(r)}
            >
              {r}
            </button>
          ))}
          <label className="plug-field">
            plugboard
            <input
              type="text"
              value={plugText}
              onChange={(e) => setPlugText(e.target.value)}
              spellCheck={false}
              aria-label="plugboard pairs"
            />
          </label>
          <span className="plug-count">
            {plug.error ? (
              <span className="err">{plug.error}</span>
            ) : (
              `${plug.pairs.length} lead${plug.pairs.length === 1 ? '' : 's'}`
            )}
          </span>
        </div>

        <div className="misc-row">
          <span className="slot-label">load</span>
          <button
            type="button"
            onClick={() => {
              setOrder(HISTORIC.order);
              setRings(HISTORIC.rings);
              setPositions(HISTORIC.positions);
              setReflector(HISTORIC.reflector);
              setPlugText(HISTORIC.plugs);
              setText(HISTORIC.cipher);
            }}
          >
            the 1930 manual message
          </button>
          <span className="load-hint">
            a real intercept with its real settings — it decrypts to German
          </span>
        </div>
        {duplicateRotor && (
          <p className="err">
            the same rotor is in two slots — a real machine only had one of each
          </p>
        )}
      </div>

      <div className="windows">
        {[0, 1, 2].map((slot) => (
          <div className="window" key={slot}>
            <span className="window-rotor">{order[slot]}</span>
            <span className="window-letter">{result.windows[slot]}</span>
          </div>
        ))}
        <span className="window-note">
          rotor windows after {result.stepCount} key
          {result.stepCount === 1 ? '' : 's'}
          {result.trace?.doubleStep && (
            <span className="double-step"> · double step</span>
          )}
        </span>
      </div>

      <div className="io">
        <div className="field">
          <label htmlFor="enigma-in">input</label>
          <textarea
            id="enigma-in"
            value={text}
            onChange={(e) => setText(e.target.value)}
            rows={3}
            spellCheck={false}
          />
        </div>
        <div className="field">
          <label htmlFor="enigma-out">output</label>
          <textarea id="enigma-out" value={result.output} readOnly rows={3} />
        </div>
      </div>
      <p className="hint">
        Set the same settings again and type the output back in — the machine
        undoes itself.
      </p>

      {result.trace && (
        <div className="trace">
          <h3>
            signal path for the last letter typed:{' '}
            <strong>{result.trace.input}</strong> lights{' '}
            <strong>{result.trace.output}</strong>
          </h3>
          <div className="trace-flow">
            {result.trace.stages.map((s, i) => (
              <div
                className={
                  i === 5
                    ? 'stage reflector-stage'
                    : i > 5
                      ? 'stage back'
                      : 'stage'
                }
                key={i}
              >
                <span className="stage-letter">{s.letter}</span>
                <span className="stage-label">{s.label}</span>
              </div>
            ))}
          </div>
          <p className="trace-note">
            The current goes out through the rotors, turns around at the
            reflector, and comes back through them on a different path. That
            return trip is what makes the machine self-reciprocal — and it is
            also why a letter can never come back as itself.
          </p>
        </div>
      )}

      <div className="lampboard">
        {KEY_ROWS.map((row, ri) => (
          <div className="key-row" key={ri}>
            {row.split('').map((k) => {
              const lit = result.trace?.output === k;
              const pressed = result.trace?.input === k;
              return (
                <button
                  key={k}
                  type="button"
                  className={lit ? 'key lit' : pressed ? 'key pressed' : 'key'}
                  onClick={() => setText(text + k)}
                  aria-label={`type ${k}`}
                >
                  {k}
                </button>
              );
            })}
          </div>
        ))}
        <div className="lamp-legend">
          <span>
            <span className="swatch pressed" /> typed
          </span>
          <span>
            <span className="swatch lit" /> lit
          </span>
          <button type="button" onClick={() => setText('')}>
            clear
          </button>
        </div>
      </div>

      <p className="keyspace">
        With five rotors to choose three from, 26<sup>3</sup> start positions
        and ten plugboard leads, the settings alone number about{' '}
        <strong>{keySpace.toExponential(2).replace('e+', ' × 10^')}</strong> —
        and that is what made the machine look unbreakable to the people using
        it.
      </p>

      <style>{`
        .enigma {
          border: 1px solid #2a2d31;
          background: #0e1113;
          padding: 1.25rem 1rem;
        }
        .enigma .settings {
          display: flex;
          flex-direction: column;
          gap: 0.6rem;
          padding-bottom: 1rem;
          border-bottom: 1px solid #2a2d31;
        }
        .enigma .rotor-slot,
        .enigma .misc-row {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.5rem;
          font-size: 0.75rem;
          color: var(--muted);
        }
        .enigma .slot-label {
          text-transform: uppercase;
          letter-spacing: 0.04em;
          font-size: 0.7rem;
          min-width: 6.5rem;
        }
        .enigma .notch-hint {
          color: #5f656d;
        }
        .enigma select,
        .enigma input[type='text'],
        .enigma textarea {
          font-family: var(--mono);
          font-size: 0.8rem;
          color: var(--fg);
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.2rem 0.35rem;
        }
        .enigma .rotor-slot input[type='text'] {
          width: 2.2rem;
          text-align: center;
        }
        .enigma .rotor-slot label,
        .enigma .misc-row label {
          display: flex;
          align-items: center;
          gap: 0.3rem;
        }
        .enigma .plug-field input {
          width: 12rem;
        }
        .enigma .err {
          color: #d98a8a;
        }
        .enigma button {
          font-family: inherit;
          font-size: 0.75rem;
          color: #8a8f98;
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.2rem 0.5rem;
          cursor: pointer;
        }
        .enigma button:hover {
          color: #e6e6e6;
        }
        .enigma button.active {
          color: #0b0d0f;
          background: var(--accent);
          border-color: var(--accent);
        }
        .enigma .windows {
          display: flex;
          align-items: center;
          gap: 0.6rem;
          margin: 1rem 0;
          flex-wrap: wrap;
        }
        .enigma .window {
          display: flex;
          flex-direction: column;
          align-items: center;
          border: 1px solid #2a2d31;
          background: #0b0d0f;
          padding: 0.3rem 0.7rem;
          min-width: 3rem;
        }
        .enigma .window-rotor {
          color: var(--muted);
          font-size: 0.65rem;
        }
        .enigma .window-letter {
          color: var(--accent);
          font-size: 1.4rem;
          line-height: 1.3;
        }
        .enigma .window-note {
          color: var(--muted);
          font-size: 0.75rem;
        }
        .enigma .double-step {
          color: var(--accent);
        }
        .enigma .io {
          display: grid;
          grid-template-columns: 1fr 1fr;
          gap: 0.9rem;
        }
        .enigma .field {
          display: flex;
          flex-direction: column;
          gap: 0.25rem;
        }
        .enigma .field label {
          color: var(--muted);
          font-size: 0.7rem;
          text-transform: uppercase;
          letter-spacing: 0.04em;
        }
        .enigma textarea {
          width: 100%;
          resize: vertical;
          box-sizing: border-box;
          line-height: 1.5;
        }
        .enigma .hint {
          color: var(--muted);
          font-size: 0.75rem;
          margin: 0.5rem 0 0;
        }
        .enigma .trace {
          margin-top: 1.25rem;
          padding-top: 1rem;
          border-top: 1px solid #2a2d31;
        }
        .enigma .trace h3 {
          font-size: 0.8rem;
          font-weight: normal;
          color: var(--muted);
          margin: 0 0 0.7rem;
        }
        .enigma .trace h3 strong {
          color: var(--accent);
          font-weight: normal;
        }
        .enigma .trace-flow {
          display: flex;
          flex-wrap: wrap;
          gap: 0.3rem;
        }
        .enigma .stage {
          display: flex;
          flex-direction: column;
          align-items: center;
          border: 1px solid #2a2d31;
          padding: 0.25rem 0.35rem;
          min-width: 3.4rem;
          flex: 0 0 auto;
        }
        .enigma .stage.back {
          border-color: #23262a;
          background: rgba(0, 0, 0, 0.25);
        }
        .enigma .stage.reflector-stage {
          border-color: var(--accent);
        }
        .enigma .stage-letter {
          font-size: 0.95rem;
          color: var(--fg);
        }
        .enigma .stage.back .stage-letter {
          color: #9aa0a8;
        }
        .enigma .reflector-stage .stage-letter {
          color: var(--accent);
        }
        .enigma .stage-label {
          font-size: 0.6rem;
          color: var(--muted);
          white-space: nowrap;
        }
        .enigma .trace-note {
          color: var(--muted);
          font-size: 0.78rem;
          margin: 0.8rem 0 0;
        }
        .enigma .lampboard {
          margin-top: 1.25rem;
          padding-top: 1rem;
          border-top: 1px solid #2a2d31;
          display: flex;
          flex-direction: column;
          gap: 0.3rem;
          align-items: center;
        }
        .enigma .key-row {
          display: flex;
          gap: 0.25rem;
        }
        .enigma .key {
          width: 1.75rem;
          height: 1.75rem;
          padding: 0;
          border-radius: 50%;
          font-size: 0.7rem;
        }
        .enigma .key.lit {
          color: #0b0d0f;
          background: var(--accent);
          border-color: var(--accent);
        }
        .enigma .key.pressed {
          color: #e6e6e6;
          border-color: #6f757d;
        }
        .enigma .lamp-legend {
          display: flex;
          align-items: center;
          gap: 0.9rem;
          margin-top: 0.5rem;
          color: var(--muted);
          font-size: 0.7rem;
        }
        .enigma .swatch {
          display: inline-block;
          width: 0.6rem;
          height: 0.6rem;
          border-radius: 50%;
          margin-right: 0.25rem;
          vertical-align: middle;
        }
        .enigma .swatch.lit {
          background: var(--accent);
        }
        .enigma .swatch.pressed {
          border: 1px solid #6f757d;
        }
        .enigma .keyspace {
          color: var(--muted);
          font-size: 0.8rem;
          margin: 1.25rem 0 0;
          padding-top: 1rem;
          border-top: 1px solid #2a2d31;
        }
        .enigma .keyspace strong {
          color: var(--fg);
          font-weight: normal;
        }
        @media (max-width: 34rem) {
          .enigma .io {
            grid-template-columns: 1fr;
          }
          .enigma .slot-label {
            min-width: 0;
          }
          .enigma .key {
            width: 1.5rem;
            height: 1.5rem;
            font-size: 0.62rem;
          }
        }
      `}</style>
    </div>
  );
}