import React, { useCallback, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import "./react.css";

type Status = "pending" | "approved";
type NavigationLevel = "card" | "task" | "block" | "phase" | "step" | "snapshot";
type NavigationPosition = {
  task_index: number;
  block_index: number;
  phase_index: number;
  step_index: number;
  global_step_index: number;
  snapshot_index: number | null;
  global_snapshot_index: number | null;
};
type ApiNavigation = {
  revision: number;
  actor?: string;
  focus_level: NavigationLevel;
  task: { id: string; label: string; index: number };
  block: { id: string; label: string; index: number };
  phase: { id: string; label: string; index: number };
  step: { id: string; label: string; number: number; index: number; global_index: number };
  snapshot_ref: string | null;
  position: NavigationPosition;
  sync_requested: boolean;
};
type ApiViewerState = {
  revision: number;
  actor?: string | null;
  scale: number;
  scroll: {
    x: number;
    y: number;
    page_index: number;
    sheet_scroll_top?: number;
  };
  viewport: { width: number; height: number };
  allocator?: { visible: boolean };
  nvim: {
    visible: boolean;
    sync: boolean;
    control: boolean;
    expanded: boolean;
    fit: boolean;
    connection: string;
    screen_cursor: { row: number; column: number } | null;
    grid: { columns: number; rows: number } | null;
  };
};
type InteractiveArea = "compile" | "bss" | "stack" | "registers" | "text";
type InteractiveSymbol = {
  id: string;
  area: InteractiveArea;
  declaration: string;
  description: string;
};
type UmlStage = {
  id: string;
  label: string;
  description: string;
  code_ref?: string;
  progress_id?: string;
  svg_label?: string;
};
type UmlSnapshotRegister = { name: string; value: string; note?: string };
type UmlSnapshotFrameField = {
  name: string;
  address: string;
  value: string;
  size_bytes?: number;
  state: "initialised" | "uninitialised" | "saved";
  note?: string;
};
type NvimAnnotation = {
  id: string;
  anchor_ref?: string;
  label?: string;
  tone?: "danger" | "info" | "success" | "warning";
  shape?: "outline" | "underline";
  anchor: {
    kind: "screen-text" | "grid-range";
    text?: string;
    occurrence?: number;
    row?: number;
    column?: number;
    rows?: number;
    columns?: number;
    padding_cells?: number;
  };
};
type NvimStepView = {
  connection_ref?: string;
  layout?: string;
  recorded_grid_ref?: string;
  reveal?: {
    anchor_ref?: string;
    align?: "top" | "center" | "bottom";
  };
  annotations?: NvimAnnotation[];
};
type UmlSnapshot = {
  target?: string;
  captured_at?: string;
  captured_with?: string;
  elf?: string;
  pc: string;
  registers: UmlSnapshotRegister[];
  frame: {
    name: string;
    sp: string;
    fp: string;
    stack_top?: string;
    size_bytes: number;
    fields: UmlSnapshotFrameField[];
  };
  memory: {
    items: { name: string; address: string; value: string; note?: string }[];
    arena?: {
      base: string;
      size_bytes: number;
      cursor: string;
      offset: number | null;
      bytes: string;
    };
  };
  code?: {
    source: string;
    source_ref?: string;
    assembly: string;
    note?: string;
  };
  description: string;
};
type UmlSequenceStep = {
  id: string;
  number: number;
  label: string;
  description: string;
  code_ref?: string;
  progress_id?: string;
  svg_label?: string;
  snapshot_ref?: string;
  view?: NvimStepView;
  snapshot: UmlSnapshot;
};
type UmlSequencePhase = {
  id: string;
  label: string;
  description?: string;
  svg_label?: string;
  steps: UmlSequenceStep[];
};
type InteractiveAsset = {
  kind: "allocator-memory-flow" | "uml-sequence";
  storage_key?: string;
  title?: string;
  task?: { id: string; label: string };
  block?: { id: string; label: string; description?: string };
  snapshot_context?: {
    target: string;
    captured_at: string;
    captured_with?: string;
    elf?: string;
    stack_top?: string;
  };
  terminal_view?: {
    path?: string;
    href?: string;
    title?: string;
    alt: string;
    caption?: string;
  };
  arena_size?: number;
  allocations?: number[];
  symbols?: InteractiveSymbol[];
  stages?: UmlStage[];
  phases?: UmlSequencePhase[];
};
type Asset = {
  label: string;
  caption: string;
  alt: string;
  href: string;
  width: number;
  kind: string;
  source_href?: string;
  interactive?: InteractiveAsset | null;
};
type FlowStep = { id: string; title: string; content_html: string };
type FlowBlock = {
  id: string;
  kind: "block" | "exercise";
  title: string;
  content_html: string;
  steps: FlowStep[];
};
type Task = {
  ref: string;
  title: string;
  uuid?: string;
  criterion: string;
  conclusion_html: string;
  prompt_html?: string;
  flow: FlowBlock[];
};
type Section = {
  id: string;
  index: number;
  title: string;
  show_heading: boolean;
  header_html: string;
  left_margin_html: string;
  right_margin_html: string;
  content_html: string;
  tasks: Task[];
  assets: Asset[];
  steps: { id: string; title: string }[];
};
type Progress = {
  items: Record<string, { status: Status }>;
  summary: { total: number; counts: Record<Status, number> };
};
type SetProgressStatus = (id: string, status: Status) => Promise<void>;
type CardModel = {
  schema: string;
  card: Record<string, string>;
  total_pages: number;
  front: any;
  sections: Section[];
  dictionary: { header_html: string; content_html: string } | null;
  toc: { id: string; label: string }[];
};
type NvimPanelSummary = {
  state:
    | "hidden"
    | "connecting"
    | "connected"
    | "replaying"
    | "verifying"
    | "ready"
    | "failed"
    | "offline"
    | "unselected";
  message?: string;
  instance?: string;
  detail?: string;
  syncMode: boolean;
  syncReady: boolean;
  controlMode: boolean;
  controlEnabled: boolean;
  screenCursor?: { row: number; column: number };
  grid?: { columns: number; rows: number };
};

const nvimStatusLabel: Record<NvimPanelSummary["state"], string> = {
  hidden: "UKRYTY",
  connecting: "ŁĄCZENIE",
  connected: "ONLINE · LIVE",
  replaying: "ONLINE · REPLAY",
  verifying: "ONLINE · WERYFIKACJA",
  ready: "ONLINE · GOTOWY",
  failed: "ONLINE · BŁĄD STANU",
  offline: "OFFLINE · ZAPIS",
  unselected: "OFFLINE · BRAK CELU",
};

const UML_SCROLLOFF_RATIO = 0.25;
const UML_SCROLLOFF_EPSILON_PX = 1;
const UML_SCROLLOFF_MAX_PASSES = 4;

type VerticalViewport = { top: number; bottom: number };

function clipsVertically(element: HTMLElement) {
  const overflow = window.getComputedStyle(element).overflowY;
  return overflow === "auto" || overflow === "scroll" || overflow === "hidden"
    || overflow === "clip";
}

function elementClientViewport(element: HTMLElement): VerticalViewport {
  const bounds = element.getBoundingClientRect();
  const layoutHeight = element.offsetHeight || element.clientHeight;
  const scale = layoutHeight > 0 ? bounds.height / layoutHeight : 1;
  const top = bounds.top + element.clientTop * scale;
  return {
    top,
    bottom: top + element.clientHeight * scale,
  };
}

function chromeAwareWindowViewport(target: Element): VerticalViewport | null {
  const chrome = document.querySelector<HTMLElement>(".viewer-chrome");
  if (chrome?.classList.contains("is-nvim-fit")) return null;

  let top = 0;
  let bottom = window.innerHeight;
  if (chrome) {
    const chromeBounds = chrome.getBoundingClientRect();
    const targetBounds = target.getBoundingClientRect();
    const overlapsHorizontally = chromeBounds.left < targetBounds.right
      && chromeBounds.right > targetBounds.left;
    if (
      overlapsHorizontally
      && chromeBounds.bottom > 0
      && chromeBounds.top < window.innerHeight
    ) {
      top = Math.max(top, chromeBounds.bottom);
    }
  }
  return bottom - top > UML_SCROLLOFF_EPSILON_PX ? { top, bottom } : null;
}

function visibleVerticalViewport(target: Element): VerticalViewport | null {
  const windowViewport = chromeAwareWindowViewport(target);
  if (!windowViewport) return null;

  let { top, bottom } = windowViewport;
  for (
    let ancestor = target.parentElement;
    ancestor && ancestor !== document.body && ancestor !== document.documentElement;
    ancestor = ancestor.parentElement
  ) {
    if (!clipsVertically(ancestor)) continue;
    const client = elementClientViewport(ancestor);
    top = Math.max(top, client.top);
    bottom = Math.min(bottom, client.bottom);
  }
  return bottom - top > UML_SCROLLOFF_EPSILON_PX ? { top, bottom } : null;
}

function documentScroller() {
  return (document.scrollingElement as HTMLElement | null)
    ?? document.documentElement;
}

function verticalScrollChain(target: Element) {
  const chain: HTMLElement[] = [];
  for (
    let ancestor = target.parentElement;
    ancestor && ancestor !== document.body && ancestor !== document.documentElement;
    ancestor = ancestor.parentElement
  ) {
    const overflow = window.getComputedStyle(ancestor).overflowY;
    const acceptsScroll = overflow === "auto" || overflow === "scroll";
    if (acceptsScroll && ancestor.scrollHeight - ancestor.clientHeight > 1) {
      chain.push(ancestor);
    }
  }
  const root = documentScroller();
  if (!chain.includes(root)) chain.push(root);
  return chain;
}

function visualScrollScale(scroller: HTMLElement) {
  if (scroller === documentScroller()) return 1;
  const bounds = scroller.getBoundingClientRect();
  const layoutHeight = scroller.offsetHeight || scroller.clientHeight;
  const scale = layoutHeight > 0 ? bounds.height / layoutHeight : 1;
  return Number.isFinite(scale) && scale > 0 ? scale : 1;
}

function setScrollPositionInstant(
  scroller: HTMLElement,
  position: { top?: number; left?: number },
) {
  const previousBehavior = scroller.style.scrollBehavior;
  scroller.style.scrollBehavior = "auto";
  if (position.left != null) scroller.scrollLeft = position.left;
  if (position.top != null) scroller.scrollTop = position.top;
  scroller.style.scrollBehavior = previousBehavior;
}

function bringTargetSheetIntoWindow(target: Element, viewport: VerticalViewport) {
  const anchor = target.closest<HTMLElement>(".sheet-content");
  if (!anchor) return 0;
  const bounds = elementClientViewport(anchor);
  let delta = 0;
  if (bounds.bottom <= viewport.top) {
    delta = bounds.bottom - viewport.bottom;
  } else if (bounds.top >= viewport.bottom) {
    delta = bounds.top - viewport.top;
  }
  if (Math.abs(delta) <= UML_SCROLLOFF_EPSILON_PX) return 0;

  const root = documentScroller();
  const previous = root.scrollTop;
  const maximum = Math.max(0, root.scrollHeight - root.clientHeight);
  setScrollPositionInstant(root, {
    top: Math.max(0, Math.min(maximum, previous + delta)),
  });
  return root.scrollTop - previous;
}

function applyVerticalScrollDelta(target: Element, delta: number) {
  let remaining = delta;
  for (const scroller of verticalScrollChain(target)) {
    if (Math.abs(remaining) <= UML_SCROLLOFF_EPSILON_PX) break;
    const scale = visualScrollScale(scroller);
    const maximum = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
    const previous = scroller.scrollTop;
    const next = Math.max(0, Math.min(maximum, previous + remaining / scale));
    setScrollPositionInstant(scroller, { top: next });
    remaining -= (scroller.scrollTop - previous) * scale;
  }
  return delta - remaining;
}

function umlScrolloffDelta(target: Element, viewport: VerticalViewport) {
  const bounds = target.getBoundingClientRect();
  const cursor = (bounds.top + bounds.bottom) / 2;
  const height = viewport.bottom - viewport.top;
  const upper = viewport.top + height * UML_SCROLLOFF_RATIO;
  const lower = viewport.bottom - height * UML_SCROLLOFF_RATIO;
  if (cursor < upper) return cursor - upper;
  if (cursor > lower) return cursor - lower;
  return 0;
}

const Html = ({ html, className }: { html: string; className?: string }) => (
  <div className={className} dangerouslySetInnerHTML={{ __html: html }} />
);

function Header({ html }: { html: string }) {
  return <Html html={html} />;
}

function Margin({ side, html }: { side: "left" | "right"; html: string }) {
  return (
    <aside
      className={`pdf-margin ${side}`}
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

function Sheet({
  header,
  left = "",
  right = "",
  children,
  className = "",
}: React.PropsWithChildren<any>) {
  return (
    <article className={`sheet ${className}`}>
      <div className="sheet-content">
        <Header html={header} />
        <Margin side="left" html={left} />
        <section className="pdf-main card-section">{children}</section>
        <Margin side="right" html={right} />
      </div>
    </article>
  );
}

function Topbar({
  toc,
  scale,
  setScale,
  nvimVisible,
  setNvimVisible,
  nvimSyncMode,
  setNvimSyncMode,
  nvimControlMode,
  setNvimControlMode,
  nvimExpanded,
  setNvimExpanded,
  nvimFit,
  setNvimFit,
  allocatorVisible,
  setAllocatorVisible,
  nvimSummary,
}: {
  toc: CardModel["toc"];
  scale: number;
  setScale: (value: number) => void;
  nvimVisible: boolean;
  setNvimVisible: (value: boolean) => void;
  nvimSyncMode: boolean;
  setNvimSyncMode: (value: boolean) => void;
  nvimControlMode: boolean;
  setNvimControlMode: (value: boolean) => void;
  nvimExpanded: boolean;
  setNvimExpanded: (value: boolean) => void;
  nvimFit: boolean;
  setNvimFit: (value: boolean) => void;
  allocatorVisible: boolean;
  setAllocatorVisible: (value: boolean) => void;
  nvimSummary: NvimPanelSummary;
}) {
  return (
    <nav className="topbar">
      <div className="topbar-inner">
        <details>
          <summary>Spis treści</summary>
          <div className="toc-links">
            {toc.map((item) => (
              <a key={item.id} href={`#${item.id}`}>
                {item.label}
              </a>
            ))}
          </div>
        </details>
        <div className="topbar-nvim-section" aria-label="Panel Neovima">
          <span className="nvim-toolbar-boundary" aria-hidden="true">|</span>
          <span className="nvim-toolbar-context">NVIM</span>
          <div className="nvim-toolbar-actions" aria-label="Narzędzia okna Neovima">
            <button
              className="viewer-nvim-toggle nvim-toolbar-button"
              aria-label={nvimVisible ? "Alt+1: ukryj Neovim" : "Alt+1: pokaż Neovim"}
              title={nvimVisible ? "Alt+1 · Ukryj Neovim" : "Alt+1 · Pokaż Neovim"}
              aria-pressed={nvimVisible}
              onClick={() => setNvimVisible(!nvimVisible)}
            >
              <kbd className="nvim-toolbar-key">A1</kbd><span className="nvim-toolbar-label">PANEL</span>
            </button>
            <button
              type="button"
              className={`nvim-control-indicator nvim-toolbar-button${nvimSummary.syncMode ? " is-active" : ""}${nvimSummary.syncReady ? " is-engaged" : ""}`}
              aria-label={`Alt+2: synchronizacja ${nvimSyncMode ? "włączona" : "wyłączona"}`}
              title={`Alt+2 · SYNC ${nvimSyncMode ? "ON" : "OFF"}`}
              aria-pressed={nvimSyncMode}
              onClick={() => setNvimSyncMode(!nvimSyncMode)}
            >
              <kbd className="nvim-toolbar-key">A2</kbd><span className="nvim-toolbar-label">SYNC</span>
            </button>
            <button
              type="button"
              className={`nvim-work-indicator nvim-toolbar-button${nvimControlMode ? " is-active" : ""}${nvimSummary.controlEnabled ? " is-engaged" : ""}`}
              aria-label={`Alt+3: sterowanie ${!nvimSyncMode ? "niedostępne" : nvimControlMode ? "włączone" : "wyłączone"}`}
              title={!nvimSyncMode ? "Alt+3 · Sterowanie wymaga SYNC ON" : `Alt+3 · Sterowanie ${nvimControlMode ? "ON" : "OFF"}`}
              aria-pressed={nvimControlMode}
              disabled={!nvimSyncMode}
              onClick={() => setNvimControlMode(!nvimControlMode)}
            >
              <kbd className="nvim-toolbar-key">A3</kbd><span className="nvim-toolbar-label">CTRL</span>
            </button>
            <button
              type="button"
              className={`nvim-height-toggle nvim-toolbar-button${nvimExpanded ? " is-active" : ""}`}
              aria-label={`Alt+4: ${nvimExpanded ? "przywróć normalną wysokość" : "ustaw wysoki panel"}`}
              title={nvimExpanded ? "Alt+4 · Normalna wysokość" : "Alt+4 · Wysoki panel 90%"}
              aria-pressed={nvimExpanded}
              onClick={() => setNvimExpanded(!nvimExpanded)}
            >
              <kbd className="nvim-toolbar-key">A4</kbd><span className="nvim-toolbar-label">HIGH</span>
            </button>
            <button
              type="button"
              className={`nvim-height-toggle nvim-toolbar-button${nvimFit ? " is-active" : ""}`}
              aria-label={`Alt+5: automatyczne dopasowanie ${nvimFit ? "włączone" : "wyłączone"}`}
              title={nvimFit ? "Alt+5 · Wyłącz auto-fit" : "Alt+5 · Pokaż cały Neovim"}
              aria-pressed={nvimFit}
              onClick={() => setNvimFit(!nvimFit)}
            >
              <kbd className="nvim-toolbar-key">A5</kbd><span className="nvim-toolbar-label">FIT</span>
            </button>
            <button
              type="button"
              className={`nvim-height-toggle nvim-toolbar-button${allocatorVisible ? " is-active" : ""}`}
              aria-label={`Alt+6: model alokatora ${allocatorVisible ? "przypięty" : "ukryty"}`}
              title={allocatorVisible ? "Alt+6 · Ukryj model alokatora" : "Alt+6 · Przypnij model alokatora"}
              aria-pressed={allocatorVisible}
              onClick={() => setAllocatorVisible(!allocatorVisible)}
            >
              <kbd className="nvim-toolbar-key">A6</kbd><span className="nvim-toolbar-label">MODEL</span>
            </button>
          </div>
          <span className="nvim-toolbar-boundary" aria-hidden="true">|</span>
          {nvimVisible && (
            <>
              <span className={`nvim-status nvim-status--${nvimSummary.state}`}>
                {nvimStatusLabel[nvimSummary.state]}
              </span>
              <span className="topbar-nvim-meta" title={nvimSummary.message}>
                <strong>{nvimSummary.instance ?? "Neovim MCP"}</strong>
                {nvimSummary.detail && <small>{nvimSummary.detail}</small>}
              </span>
              <span className="nvim-shortcut-strip" aria-label="Skróty panelu Neovima">
                <kbd>F1</kbd> reset · <kbd>F2</kbd> sync · <kbd>Alt+W</kbd> okna · <kbd>F12</kbd> skróty
              </span>
            </>
          )}
        </div>
        <div className="viewer-zoom-controls">
          <output className="viewer-zoom-status">
            Płótno {Math.round(scale * 100)}% · Treść 100%
          </output>
          <button className="viewer-zoom-reset" onClick={() => setScale(2.18)}>
            Reset zoom
          </button>
        </div>
      </div>
    </nav>
  );
}

type NvimCell = { text: string; highlight: number };
type NvimHighlight = {
  foreground?: number;
  background?: number;
  special?: number;
  reverse?: boolean;
  bold?: boolean;
  italic?: boolean;
  underline?: boolean;
  undercurl?: boolean;
  strikethrough?: boolean;
};
type NvimGridModel = {
  width: number;
  height: number;
  cells: NvimCell[][];
  highlights: Map<number, NvimHighlight>;
  foreground: number;
  background: number;
  cursor: { row: number; column: number };
};
type ActiveNvimStep = {
  task: { id: string; label: string } | null;
  block: { id?: string; label?: string } | null;
  phase: { id: string; label: string };
  step: UmlSequenceStep;
  focusLevel?: NavigationLevel;
  activate?: boolean;
};

function allocatorStepFromModel(
  model: CardModel,
  navigation?: ApiNavigation | null,
): ActiveNvimStep | null {
  for (const section of model.sections) {
    for (const asset of section.assets) {
      const interactive = asset.interactive;
      if (interactive?.kind !== "uml-sequence" || !interactive.phases?.length) continue;
      if (navigation?.task.id && interactive.task?.id !== navigation.task.id) continue;
      if (navigation?.block.id && interactive.block?.id !== navigation.block.id) continue;
      for (const phase of interactive.phases) {
        if (navigation?.phase.id && phase.id !== navigation.phase.id) continue;
        const step = navigation?.step.id
          ? phase.steps.find(item => item.id === navigation.step.id)
          : phase.steps[0];
        if (!step) continue;
        return {
          task: interactive.task ?? null,
          block: interactive.block ?? null,
          phase: { id: phase.id, label: phase.label },
          step,
          focusLevel: navigation?.focus_level ?? "step",
          activate: false,
        };
      }
    }
  }
  return null;
}

function AllocatorPanel({ visible, model }: { visible: boolean; model: CardModel }) {
  const [active, setActive] = useState<ActiveNvimStep | null>(null);

  useEffect(() => {
    const selected = (event: Event) => {
      setActive((event as CustomEvent<ActiveNvimStep>).detail ?? null);
    };
    window.addEventListener("stem:nvim-step", selected);
    setActive(allocatorStepFromModel(model));
    void fetch("/api/navigation/current", { cache: "no-store" })
      .then(response => response.ok ? response.json() : Promise.reject())
      .then(payload => {
        const resolved = allocatorStepFromModel(model, payload.current ?? null);
        if (resolved) setActive(resolved);
      })
      .catch(() => undefined);
    return () => window.removeEventListener("stem:nvim-step", selected);
  }, [model]);

  if (!visible) return null;
  const focusLevel = active?.focusLevel ?? "card";
  const arena = active?.step.snapshot.memory.arena;
  const hasUmlCursor = Boolean(active && focusLevel !== "card" && focusLevel !== "task");
  const size = Math.max(1, Number(arena?.size_bytes ?? 64));
  const offset = arena?.offset == null
    ? null
    : Math.max(0, Math.min(size, Number(arena.offset)));
  const byteValues = arena?.bytes.trim().split(/\s+/).filter(Boolean) ?? [];
  const cellCount = Math.min(size, 64);
  const facts = active
    ? [
        ...active.step.snapshot.memory.items.map(item => ({
          name: item.name,
          value: item.value,
        })),
        ...active.step.snapshot.frame.fields.map(field => ({
          name: field.name,
          value: field.value,
        })),
      ].filter((fact, index, all) =>
        all.findIndex(candidate => candidate.name === fact.name) === index,
      ).slice(0, 8)
    : [];

  return (
    <section className="allocator-panel" aria-label="Przypięty model alokatora">
      <header>
        <span>ALLOCATOR · PINNED</span>
        <strong>
          {active
            ? `${active.phase.label} · ${String(active.step.number).padStart(2, "0")} ${active.step.label}`
            : "Oczekiwanie na diagram UML"}
        </strong>
        <small>
          {hasUmlCursor
            ? active!.step.snapshot.description
            : "Wybierz co najmniej BLOCK. F2 wybierze jego pierwszy krok i wykona synchronizację."}
        </small>
      </header>
      <div className="allocator-panel-body">
        <dl className="allocator-metrics">
          <div><dt>FOCUS</dt><dd>{focusLevel.toUpperCase()}</dd></div>
          <div><dt>BASE</dt><dd>{arena?.base ?? "—"}</dd></div>
          <div><dt>ALLOCP</dt><dd>{arena?.cursor ?? "—"}</dd></div>
          <div><dt>USED / FREE</dt><dd>{offset == null ? "—" : `${offset} / ${size - offset} B`}</dd></div>
        </dl>
        <div className={`allocator-arena-view${hasUmlCursor ? "" : " is-inactive"}`}>
          <div className="allocator-arena-labels">
            <span>allocbuf[0]</span>
            <strong>{offset == null ? "allocp jeszcze nieustawiony" : `offset ${offset}`}</strong>
            <span>allocbuf[{size - 1}]</span>
          </div>
          <div
            className="allocator-pinned-arena"
            style={{ "--allocator-size": cellCount } as React.CSSProperties}
          >
            {Array.from({ length: cellCount }, (_, index) => (
              <i
                key={index}
                className={offset != null && index < offset ? "is-allocated" : ""}
                title={`allocbuf[${index}] = ${byteValues[index] ?? "??"}`}
              />
            ))}
            {offset != null && (
              <b
                className="allocator-cursor"
                style={{ left: `${offset / size * 100}%` }}
                title={`allocp = ${arena?.cursor}`}
              />
            )}
          </div>
        </div>
        <div className="allocator-facts" aria-label="Wartości bieżącego kroku">
          {facts.length ? facts.map(fact => (
            <div key={fact.name}><span>{fact.name}</span><code>{fact.value}</code></div>
          )) : <p>Brak snapshotu.</p>}
        </div>
      </div>
    </section>
  );
}
type NvimConnectionStatus = {
  state: "hidden" | "connecting" | "connected" | "offline" | "unselected";
  message?: string;
  epoch?: string;
  target?: {
    instance?: string;
    profile?: string;
    target?: string;
    container_name?: string;
    container_id?: string;
  };
};
type CheckpointStatus = {
  state: "idle" | "replaying" | "verifying" | "ready" | "failed";
  phase?: string;
  message?: string;
  generation?: number;
  snapshot_ref?: string;
};

type SavedNvimGrid = {
  schema: "stem-nvim-grid.v1";
  snapshot_ref: string;
  saved_at: string;
  width: number;
  height: number;
  cells: Array<Array<[string, number]>>;
  highlights: Array<[number, NvimHighlight]>;
  foreground: number;
  background: number;
  cursor: { row: number; column: number };
};

const blankNvimCell = (): NvimCell => ({ text: " ", highlight: 0 });

function createNvimGrid(width = 1, height = 1): NvimGridModel {
  return {
    width,
    height,
    cells: Array.from({ length: height }, () =>
      Array.from({ length: width }, blankNvimCell),
    ),
    highlights: new Map(),
    foreground: 0xe8e8e8,
    background: 0x101315,
    cursor: { row: 0, column: 0 },
  };
}

function resizeNvimGrid(model: NvimGridModel, width: number, height: number) {
  if (width <= 0 || height <= 0) return;
  const previous = model.cells;
  model.width = width;
  model.height = height;
  model.cells = Array.from({ length: height }, (_, row) =>
    Array.from({ length: width }, (_, column) =>
      previous[row]?.[column] ?? blankNvimCell(),
    ),
  );
}

function nvimSnapshotStorageKey(snapshotRef: string) {
  return `stem-card:nvim-grid:${snapshotRef}`;
}

function saveNvimGrid(snapshotRef: string, model: NvimGridModel) {
  if (model.width <= 1 || model.height <= 1) return;
  const saved: SavedNvimGrid = {
    schema: "stem-nvim-grid.v1",
    snapshot_ref: snapshotRef,
    saved_at: new Date().toISOString(),
    width: model.width,
    height: model.height,
    cells: model.cells.map(row => row.map(cell => [cell.text, cell.highlight])),
    highlights: Array.from(model.highlights.entries()),
    foreground: model.foreground,
    background: model.background,
    cursor: { ...model.cursor },
  };
  try {
    localStorage.setItem(nvimSnapshotStorageKey(snapshotRef), JSON.stringify(saved));
  } catch {
    // The live view remains usable even when browser storage is full/disabled.
  }
}

function parseNvimGrid(saved: SavedNvimGrid | null, snapshotRef: string): NvimGridModel | null {
  try {
    if (
      saved?.schema !== "stem-nvim-grid.v1"
      || saved.snapshot_ref !== snapshotRef
      || !Number.isInteger(saved.width)
      || !Number.isInteger(saved.height)
      || saved.width < 2
      || saved.height < 2
      || saved.width > 400
      || saved.height > 200
      || saved.cells.length !== saved.height
    ) return null;
    return {
      width: saved.width,
      height: saved.height,
      cells: saved.cells.map(row => row.map(([text, highlight]) => ({
        text: typeof text === "string" ? text : " ",
        highlight: Number.isInteger(highlight) ? highlight : 0,
      }))),
      highlights: new Map(saved.highlights),
      foreground: saved.foreground,
      background: saved.background,
      cursor: saved.cursor,
    };
  } catch {
    return null;
  }
}

function loadNvimGrid(snapshotRef: string): NvimGridModel | null {
  try {
    return parseNvimGrid(
      JSON.parse(
        localStorage.getItem(nvimSnapshotStorageKey(snapshotRef)) ?? "null",
      ) as SavedNvimGrid | null,
      snapshotRef,
    );
  } catch {
    return null;
  }
}

async function loadRecordedNvimGrid(step: UmlSequenceStep): Promise<NvimGridModel | null> {
  const snapshotRef = step.snapshot_ref;
  if (!snapshotRef) return null;
  const local = loadNvimGrid(snapshotRef);
  if (local) return local;
  const recordedRef = step.view?.recorded_grid_ref;
  if (!recordedRef || /^(?:[a-z]+:|\/\/)/i.test(recordedRef)) return null;
  try {
    const response = await fetch(recordedRef, { cache: "no-store" });
    if (!response.ok) return null;
    return parseNvimGrid(await response.json() as SavedNvimGrid, snapshotRef);
  } catch {
    return null;
  }
}

// The container runs Neovim in cterm mode, so the UI protocol sends palette
// indexes rather than terminal-specific RGB values.  Mirror the GNOME
// Console palette used by the tmux session; the classic xterm palette makes
// blue/green syntax groups too dark (and ANSI black disappears completely).
const xterm16 = [
  0x1c1c1f, 0xc01c28, 0x2ec27e, 0xf5c211,
  0x1e78e4, 0x9841bb, 0x0ab9dc, 0xc0bfbc,
  0x5e5c64, 0xed333b, 0x57e389, 0xf8e45c,
  0x51a1ff, 0xc061cb, 0x4fd2fd, 0xf6f5f4,
];

function xterm256(index: unknown) {
  const value = Number(index);
  if (!Number.isInteger(value) || value < 0 || value > 255) return undefined;
  if (value < 16) return xterm16[value];
  if (value < 232) {
    const cube = value - 16;
    const levels = [0, 95, 135, 175, 215, 255];
    const red = levels[Math.floor(cube / 36)];
    const green = levels[Math.floor((cube % 36) / 6)];
    const blue = levels[cube % 6];
    return (red << 16) | (green << 8) | blue;
  }
  const gray = 8 + (value - 232) * 10;
  return (gray << 16) | (gray << 8) | gray;
}

function ctermHighlight(attributes: unknown): NvimHighlight | null {
  if (!attributes || typeof attributes !== "object") return null;
  const source = attributes as NvimHighlight;
  if (!Object.keys(source).length) return null;
  return {
    ...source,
    foreground: xterm256(source.foreground),
    background: xterm256(source.background),
    special: xterm256(source.special),
  };
}

function rgbHighlight(attributes: unknown): NvimHighlight | null {
  if (!attributes || typeof attributes !== "object") return null;
  const source = attributes as NvimHighlight;
  return Object.keys(source).length ? { ...source } : null;
}

function applyNvimRedraw(model: NvimGridModel, events: unknown[]) {
  for (const rawEvent of events) {
    if (!Array.isArray(rawEvent) || typeof rawEvent[0] !== "string") continue;
    const name = rawEvent[0];
    const updates = rawEvent.slice(1);
    for (const update of updates) {
      if (!Array.isArray(update)) continue;
      if (name === "grid_resize") {
        const [grid, width, height] = update.map(Number);
        if (grid === 1) resizeNvimGrid(model, width, height);
      } else if (name === "default_colors_set") {
        const rgbForeground = Number(update[0]);
        const rgbBackground = Number(update[1]);
        const foreground = rgbForeground >= 0
          ? rgbForeground
          : xterm256(update[3]);
        const background = rgbBackground >= 0
          ? rgbBackground
          : xterm256(update[4]);
        if (typeof foreground === "number" && Number.isFinite(foreground) && foreground >= 0) {
          model.foreground = foreground;
        }
        if (typeof background === "number" && Number.isFinite(background) && background >= 0) {
          model.background = background;
        }
      } else if (name === "hl_attr_define") {
        const id = Number(update[0]);
        // The selected terminal runs with 'notermguicolors', so its visible
        // palette comes from cterm attributes. RGB remains a fallback for a
        // future true-colour session.
        const attributes = ctermHighlight(update[2]) ?? rgbHighlight(update[1]);
        if (Number.isInteger(id) && attributes && typeof attributes === "object") {
          model.highlights.set(id, attributes as NvimHighlight);
        }
      } else if (name === "grid_clear" && Number(update[0]) === 1) {
        model.cells = Array.from({ length: model.height }, () =>
          Array.from({ length: model.width }, blankNvimCell),
        );
      } else if (name === "grid_cursor_goto" && Number(update[0]) === 1) {
        model.cursor = { row: Number(update[1]), column: Number(update[2]) };
      } else if (name === "grid_line" && Number(update[0]) === 1) {
        const row = Number(update[1]);
        let column = Number(update[2]);
        const cells = update[3];
        if (!model.cells[row] || !Array.isArray(cells)) continue;
        let highlight = 0;
        for (const encodedCell of cells) {
          if (!Array.isArray(encodedCell)) continue;
          const text = typeof encodedCell[0] === "string" ? encodedCell[0] : " ";
          if (Number.isInteger(encodedCell[1])) highlight = Number(encodedCell[1]);
          const repeat = Number.isInteger(encodedCell[2]) ? Number(encodedCell[2]) : 1;
          for (let index = 0; index < repeat && column < model.width; index += 1) {
            if (column >= 0) model.cells[row][column] = { text, highlight };
            column += 1;
          }
        }
      } else if (name === "grid_scroll" && Number(update[0]) === 1) {
        const top = Number(update[1]);
        const bottom = Number(update[2]);
        const left = Number(update[3]);
        const right = Number(update[4]);
        const rows = Number(update[5]);
        const columns = Number(update[6]);
        const copy = model.cells.map(row => row.map(cell => ({ ...cell })));
        for (let row = top; row < bottom; row += 1) {
          for (let column = left; column < right; column += 1) {
            const sourceRow = row + rows;
            const sourceColumn = column + columns;
            model.cells[row][column] =
              sourceRow >= top && sourceRow < bottom &&
              sourceColumn >= left && sourceColumn < right
                ? copy[sourceRow][sourceColumn]
                : blankNvimCell();
          }
        }
      }
    }
  }
}

function nvimColor(value: number | undefined, fallback: string) {
  if (!Number.isFinite(value) || Number(value) < 0) return fallback;
  return `#${Number(value).toString(16).padStart(6, "0").slice(-6)}`;
}

function matchingTextColumns(line: NvimCell[], text: string) {
  const content = line.map(cell => cell.text || " ").join("");
  const matches: number[] = [];
  let from = 0;
  while (from < content.length) {
    const found = content.indexOf(text, from);
    if (found < 0) break;
    matches.push(found);
    from = found + Math.max(1, text.length);
  }
  return matches;
}

function paintNvimRows(
  canvas: HTMLCanvasElement,
  model: NvimGridModel,
  startRow: number,
  rowCount: number,
) {
  const visibleRows = Math.max(1, Math.min(rowCount, model.height - startRow));
  // The terminal grid is first composed on an unscaled A4 canvas. The same
  // zoom as the paper is applied later by CSS to the completed frame. This
  // keeps all 190 columns stable instead of recalculating glyph metrics from
  // the already-zoomed browser viewport.
  const baseWidth = canvas.parentElement?.clientWidth ?? (210 * 96) / 25.4;
  const cellWidth = baseWidth / model.width;
  const cellHeight = cellWidth * 1.9;
  const fontSize = cellWidth * 1.62;
  const viewerScale = Number.parseFloat(
    getComputedStyle(document.documentElement)
      .getPropertyValue("--viewer-canvas-scale"),
  ) || 1;
  const ratio = Math.max(1, window.devicePixelRatio || 1) * viewerScale;
  const width = model.width * cellWidth;
  const height = visibleRows * cellHeight;
  if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) {
    canvas.width = Math.round(width * ratio);
    canvas.height = Math.round(height * ratio);
    canvas.style.width = `${width}px`;
    canvas.style.height = `${height}px`;
  }
  canvas.dataset.columns = String(model.width);
  canvas.dataset.rows = String(visibleRows);
  const context = canvas.getContext("2d");
  if (!context) return null;
  context.setTransform(ratio, 0, 0, ratio, 0, 0);
  context.imageSmoothingEnabled = false;
  const defaultForeground = nvimColor(model.foreground, "#e8e8e8");
  const defaultBackground = nvimColor(model.background, "#101315");
  context.fillStyle = defaultBackground;
  context.fillRect(0, 0, width, height);
  context.textBaseline = "alphabetic";

  for (let visibleRow = 0; visibleRow < visibleRows; visibleRow += 1) {
    const row = startRow + visibleRow;
    for (let column = 0; column < model.width; column += 1) {
      const cell = model.cells[row][column];
      const highlight = model.highlights.get(cell.highlight) ?? {};
      let foreground = nvimColor(highlight.foreground, defaultForeground);
      let background = nvimColor(highlight.background, defaultBackground);
      if (highlight.reverse) [foreground, background] = [background, foreground];
      if (background !== defaultBackground) {
        context.fillStyle = background;
        context.fillRect(column * cellWidth, visibleRow * cellHeight, cellWidth, cellHeight);
      }
      if (cell.text && cell.text !== " ") {
        context.fillStyle = foreground;
        context.font = `${highlight.italic ? "italic " : ""}${highlight.bold ? "700" : "500"} ${fontSize}px/1 ui-monospace, SFMono-Regular, Consolas, monospace`;
        context.fillText(cell.text, column * cellWidth, visibleRow * cellHeight + cellHeight * 0.76);
      }
      if (highlight.underline || highlight.undercurl || highlight.strikethrough) {
        context.strokeStyle = nvimColor(highlight.special, foreground);
        context.lineWidth = 1;
        const y = visibleRow * cellHeight + (highlight.strikethrough ? cellHeight * 0.5 : cellHeight - 2);
        context.beginPath();
        context.moveTo(column * cellWidth, y);
        context.lineTo((column + 1) * cellWidth, y);
        context.stroke();
      }
    }
  }

  if (model.cursor.row >= startRow && model.cursor.row < startRow + visibleRows) {
    context.strokeStyle = "rgba(255,255,255,.72)";
    context.lineWidth = 1;
    context.strokeRect(
      model.cursor.column * cellWidth + 0.5,
      (model.cursor.row - startRow) * cellHeight + 0.5,
      cellWidth - 1,
      cellHeight - 1,
    );
  }
  return { context, cellWidth, cellHeight };
}

function drawNvimGrid(
  canvas: HTMLCanvasElement,
  model: NvimGridModel,
  annotations: NvimAnnotation[],
) {
  const painted = paintNvimRows(canvas, model, 0, model.height);
  if (!painted) return;
  const { context, cellWidth, cellHeight } = painted;

  const toneColors = {
    danger: "#ff3b30",
    info: "#35b9ef",
    success: "#2ac56f",
    warning: "#ffbd2e",
  };
  for (const annotation of annotations) {
    const anchor = annotation.anchor;
    let row = Number(anchor.row ?? -1);
    let column = Number(anchor.column ?? -1);
    let rows = Math.max(1, Number(anchor.rows ?? 1));
    let columns = Math.max(1, Number(anchor.columns ?? 1));
    if (anchor.kind === "screen-text" && anchor.text) {
      const matches: Array<{ row: number; column: number }> = [];
      for (let candidate = 0; candidate < model.height; candidate += 1) {
        for (const match of matchingTextColumns(model.cells[candidate], anchor.text)) {
          matches.push({ row: candidate, column: match });
        }
      }
      const semanticArea = annotation.anchor_ref?.split(".")[0];
      if (semanticArea === "src") {
        matches.sort((left, right) => right.column - left.column || left.row - right.row);
      } else if (["asm", "reg", "memory"].includes(semanticArea ?? "")) {
        matches.sort((left, right) => left.column - right.column || left.row - right.row);
      }
      const selected = matches[Math.max(0, Number(anchor.occurrence ?? 1) - 1)];
      row = selected?.row ?? -1;
      column = selected?.column ?? -1;
      columns = anchor.text.length;
    }
    if (row < 0 || column < 0 || row >= model.height || column >= model.width) continue;
    const padding = Math.max(0, Number(anchor.padding_cells ?? 1));
    const x = Math.max(0, column - padding) * cellWidth;
    const y = Math.max(0, row - padding) * cellHeight;
    const boxWidth = Math.min(model.width - column + padding, columns + padding * 2) * cellWidth;
    const boxHeight = Math.min(model.height - row + padding, rows + padding * 2) * cellHeight;
    const color = toneColors[annotation.tone ?? "danger"];
    context.strokeStyle = color;
    context.lineWidth = 2;
    if (annotation.shape === "underline") {
      context.beginPath();
      context.moveTo(x, y + boxHeight - 2);
      context.lineTo(x + boxWidth, y + boxHeight - 2);
      context.stroke();
    } else {
      context.strokeRect(x + 1, y + 1, Math.max(1, boxWidth - 2), Math.max(1, boxHeight - 2));
    }
    if (annotation.label) {
      context.font = "700 10px system-ui, sans-serif";
      const labelWidth = context.measureText(annotation.label).width + 8;
      context.fillStyle = color;
      context.fillRect(x + 1, Math.max(0, y - 15), labelWidth, 14);
      context.fillStyle = "#fff";
      context.fillText(annotation.label, x + 5, Math.max(10, y - 4));
    }
  }
}

function keyForNvim(event: React.KeyboardEvent<HTMLElement>) {
  if (event.nativeEvent.isComposing || event.metaKey) return null;
  const special: Record<string, string> = {
    Enter: "CR",
    Escape: "Esc",
    Backspace: "BS",
    Delete: "Del",
    Tab: "Tab",
    ArrowLeft: "Left",
    ArrowRight: "Right",
    ArrowUp: "Up",
    ArrowDown: "Down",
    Home: "Home",
    End: "End",
    PageUp: "PageUp",
    PageDown: "PageDown",
    Insert: "Insert",
  };
  const base = special[event.key] ?? (/^F\d{1,2}$/.test(event.key) ? event.key : null);
  const modifiers = [
    event.ctrlKey ? "C" : "",
    event.altKey ? "M" : "",
    event.shiftKey && base ? "S" : "",
  ].filter(Boolean).join("-");
  if (base) return `<${modifiers ? `${modifiers}-` : ""}${base}>`;
  if (event.key.length !== 1) return null;
  if (event.ctrlKey || event.altKey) {
    return `<${modifiers}-${event.key === "<" ? "lt" : event.key}>`;
  }
  return event.key === "<" ? "<lt>" : event.key;
}

function NeovimPanel({
  visible,
  syncMode,
  controlMode,
  expanded,
  fit,
  scale,
  onSummaryChange,
}: {
  visible: boolean;
  syncMode: boolean;
  controlMode: boolean;
  expanded: boolean;
  fit: boolean;
  scale: number;
  onSummaryChange: (summary: NvimPanelSummary) => void;
}) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const screenRef = useRef<HTMLDivElement>(null);
  const websocketRef = useRef<WebSocket | null>(null);
  const redrawFrameRef = useRef(0);
  const gridFlushRef = useRef(0);
  const gridReportTimerRef = useRef(0);
  const gridRef = useRef<NvimGridModel>(createNvimGrid());
  const targetEpochRef = useRef<string | null>(null);
  const activeStepRef = useRef<ActiveNvimStep | null>(null);
  const presentedSnapshotRef = useRef<string | null>(null);
  const checkpointGenerationRef = useRef(0);
  const checkpointRequestRef = useRef<AbortController | null>(null);
  const resizeStartRef = useRef<{ y: number; heightVh: number } | null>(null);
  const [activeStep, setActiveStep] = useState<ActiveNvimStep | null>(null);
  const [hasGrid, setHasGrid] = useState(false);
  const [gridRevision, setGridRevision] = useState(0);
  const [pointerInside, setPointerInside] = useState(false);
  const [panelHeightVh, setPanelHeightVh] = useState(() => {
    const saved = Number(localStorage.getItem("stem-card-nvim-height-vh"));
    return Number.isFinite(saved) && saved >= 25 && saved <= 90 ? saved : 75;
  });
  const [status, setStatus] = useState<NvimConnectionStatus>({ state: "hidden" });
  const [checkpoint, setCheckpoint] = useState<CheckpointStatus>({ state: "idle" });
  const checkpointBusy = checkpoint.state === "replaying" || checkpoint.state === "verifying";
  const syncReady = syncMode && status.state === "connected" && !checkpointBusy;
  const controlEnabled = controlMode && pointerInside && status.state === "connected" && !checkpointBusy;

  const redraw = () => {
    if (canvasRef.current) {
      drawNvimGrid(
        canvasRef.current,
        gridRef.current,
        presentedSnapshotRef.current === activeStepRef.current?.step.snapshot_ref
          ? activeStepRef.current?.step.view?.annotations ?? []
          : [],
      );
    }
  };
  const scheduleRedraw = () => {
    if (redrawFrameRef.current) return;
    redrawFrameRef.current = window.requestAnimationFrame(() => {
      redrawFrameRef.current = 0;
      redraw();
    });
  };

  useEffect(() => {
    const selected = (event: Event) => {
      const detail = (event as CustomEvent<ActiveNvimStep>).detail;
      activeStepRef.current = detail;
      setActiveStep(detail);
      presentedSnapshotRef.current = null;
      const snapshotRef = detail.step.snapshot_ref;
      if (status.state !== "connected" && snapshotRef) {
        void loadRecordedNvimGrid(detail.step).then(saved => {
          if (!saved || activeStepRef.current?.step.snapshot_ref !== snapshotRef) return;
          gridRef.current = saved;
          setHasGrid(true);
          presentedSnapshotRef.current = snapshotRef;
          setCheckpoint({
            state: "idle",
            snapshot_ref: snapshotRef,
            message: "Pokazano ostatni zapisany, zweryfikowany ekran.",
          });
          redraw();
        });
      }
      redraw();

      if (!detail.activate || !snapshotRef) return;
      if (!visible || status.state !== "connected") {
        setCheckpoint({
          state: "failed",
          snapshot_ref: snapshotRef,
          message: "Cel jest offline; pozostaje zapisany wynik tego kroku.",
        });
        return;
      }

      const generation = Math.max(checkpointGenerationRef.current + 1, Date.now());
      checkpointGenerationRef.current = generation;
      checkpointRequestRef.current?.abort();
      const controller = new AbortController();
      checkpointRequestRef.current = controller;
      setCheckpoint({
        state: "replaying",
        phase: "dispatch",
        generation,
        snapshot_ref: snapshotRef,
        message: "Resetuję cel i odtwarzam wykonanie do wybranego kroku.",
      });

      let pollTimer = 0;
      const poll = async () => {
        try {
          const response = await fetch("/api/checkpoint/status", {
            cache: "no-store",
            signal: controller.signal,
          });
          if (!response.ok) return;
          const payload = await response.json();
          const current = payload.active?.generation === generation
            ? payload.active
            : null;
          if (!current || checkpointGenerationRef.current !== generation) return;
          setCheckpoint({
            state: current.phase === "verify" ? "verifying" : "replaying",
            phase: current.phase,
            generation,
            snapshot_ref: snapshotRef,
            message: current.message,
          });
        } catch {
          // POST below owns the final error; polling is only progress feedback.
        }
      };
      pollTimer = window.setInterval(poll, 120);
      void poll();

      void fetch("/api/checkpoint/activate", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ snapshot_ref: snapshotRef, generation }),
        signal: controller.signal,
      })
        .then(async response => {
          const result = await response.json();
          if (checkpointGenerationRef.current !== generation) return;
          if (!response.ok || result.status !== "ready") {
            throw new Error(result.message ?? `Replay zakończył się kodem HTTP ${response.status}.`);
          }
          const socket = websocketRef.current;
          if (socket?.readyState !== WebSocket.OPEN) {
            throw new Error("Replay zakończony, ale most Neovima jest offline.");
          }
          const flushBeforeRefresh = gridFlushRef.current;
          socket.send(JSON.stringify({ type: "refresh" }));
          const redrawDeadline = Date.now() + 1800;
          while (
            gridFlushRef.current <= flushBeforeRefresh
            && Date.now() < redrawDeadline
            && checkpointGenerationRef.current === generation
          ) {
            await new Promise(resolve => window.setTimeout(resolve, 20));
          }
          if (gridFlushRef.current <= flushBeforeRefresh) {
            throw new Error("Neovim nie potwierdził pełnego redraw po replay.");
          }
          if (checkpointGenerationRef.current !== generation) return;
          presentedSnapshotRef.current = snapshotRef;
          setCheckpoint({
            state: "ready",
            phase: result.phase,
            generation,
            snapshot_ref: snapshotRef,
            message: result.message ?? "Stan maszyny został odtworzony i zweryfikowany.",
          });
          saveNvimGrid(snapshotRef, gridRef.current);
          redraw();
          await new Promise<void>(resolve => window.requestAnimationFrame(() => resolve()));
          const canvas = canvasRef.current;
          if (canvas && activeStepRef.current?.step.snapshot_ref === snapshotRef) {
            try {
              const dataUrl = canvas.toDataURL("image/png");
              void fetch("/api/evidence", {
                method: "POST",
                headers: { "content-type": "application/json" },
                body: JSON.stringify({
                  actor: document.documentElement.dataset.cardClientId ?? "browser",
                  snapshot_ref: snapshotRef,
                  caption: `${String(detail.step.number).padStart(2, "0")} ${detail.step.label}`,
                  data_url: dataUrl,
                }),
              }).catch(() => undefined);
            } catch {
              // Evidence is auxiliary; a failed screenshot must not invalidate replay.
            }
          }
        })
        .catch(error => {
          if (controller.signal.aborted || checkpointGenerationRef.current !== generation) return;
          void loadRecordedNvimGrid(detail.step).then(saved => {
            if (!saved || activeStepRef.current?.step.snapshot_ref !== snapshotRef) return;
            gridRef.current = saved;
            setHasGrid(true);
            presentedSnapshotRef.current = snapshotRef;
            redraw();
          });
          setCheckpoint({
            state: "failed",
            generation,
            snapshot_ref: snapshotRef,
            message: error instanceof Error ? error.message : String(error),
          });
        })
        .finally(() => {
          window.clearInterval(pollTimer);
          if (checkpointRequestRef.current === controller) checkpointRequestRef.current = null;
        });
    };
    window.addEventListener("stem:nvim-step", selected);
    return () => {
      window.removeEventListener("stem:nvim-step", selected);
      checkpointRequestRef.current?.abort();
    };
  }, [status.state, visible]);

  useEffect(() => {
    localStorage.setItem("stem-card-nvim-height-vh", panelHeightVh.toFixed(2));
  }, [panelHeightVh]);

  useEffect(() => {
    const frame = window.requestAnimationFrame(redraw);
    return () => window.cancelAnimationFrame(frame);
  }, [expanded, fit, scale]);

  useEffect(() => {
    const refresh = () => {
      const socket = websocketRef.current;
      if (socket?.readyState === WebSocket.OPEN) {
        socket.send(JSON.stringify({ type: "refresh" }));
      }
      redraw();
    };
    window.addEventListener("stem:nvim-refresh", refresh);
    return () => window.removeEventListener("stem:nvim-refresh", refresh);
  }, []);

  useEffect(() => {
    let summaryState: NvimPanelSummary["state"] = status.state;
    if (status.state === "connected") {
      summaryState = checkpoint.state === "idle" ? "connected" : checkpoint.state;
    }
    onSummaryChange({
      state: summaryState,
      message: checkpoint.message ?? status.message,
      instance: status.target?.instance ?? status.target?.container_name,
      detail: [
        status.target?.profile ?? activeStep?.step.view?.connection_ref,
        activeStep
          ? `${String(activeStep.step.number).padStart(2, "0")} ${activeStep.step.label}`
          : undefined,
        checkpoint.phase,
      ].filter(Boolean).join(" · "),
      syncMode,
      syncReady,
      controlMode,
      controlEnabled,
      screenCursor: { ...gridRef.current.cursor },
      grid: {
        columns: gridRef.current.width,
        rows: gridRef.current.height,
      },
    });
  }, [activeStep, checkpoint, controlEnabled, controlMode, gridRevision, onSummaryChange, status, syncMode, syncReady]);

  useEffect(() => () => window.clearTimeout(gridReportTimerRef.current), []);

  useEffect(() => {
    if (!controlEnabled) return;
    const protectBrowserShortcuts = (event: KeyboardEvent) => {
      const socket = websocketRef.current;
      if (socket?.readyState !== WebSocket.OPEN) return;
      if (event.altKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "w") {
        event.preventDefault();
        event.stopImmediatePropagation();
        if (!event.repeat) socket.send(JSON.stringify({ type: "input", keys: "<M-w>" }));
        return;
      }
      const directions: Record<string, string> = {
        ArrowLeft: "h",
        ArrowDown: "j",
        ArrowUp: "k",
        ArrowRight: "l",
      };
      if (event.altKey && !event.ctrlKey && !event.metaKey && directions[event.key]) {
        event.preventDefault();
        event.stopImmediatePropagation();
        if (!event.repeat) {
          socket.send(JSON.stringify({ type: "input", keys: `<M-w>${directions[event.key]}` }));
        }
      }
    };
    window.addEventListener("keydown", protectBrowserShortcuts, true);
    return () => window.removeEventListener("keydown", protectBrowserShortcuts, true);
  }, [controlEnabled]);

  useEffect(() => {
    if (!visible || !syncMode) {
      websocketRef.current?.close();
      websocketRef.current = null;
      setPointerInside(false);
      setStatus(visible
        ? { state: "offline", message: "SYNC OFF: pokazuję zapisany ekran bez połączenia z kontenerem." }
        : { state: "hidden" });
      return;
    }
    let cancelled = false;
    let reconnectTimer = 0;
    const connect = () => {
      if (cancelled) return;
      const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
      const socket = new WebSocket(`${protocol}//${window.location.host}/api/nvim-ui`);
      websocketRef.current = socket;
      setStatus({ state: "connecting" });
      socket.addEventListener("message", event => {
        try {
          const message = JSON.parse(String(event.data));
          if (message.type === "status") {
            if (
              typeof message.epoch === "string"
              && targetEpochRef.current
              && targetEpochRef.current !== message.epoch
            ) {
              gridRef.current = createNvimGrid();
              setHasGrid(false);
              presentedSnapshotRef.current = null;
            }
            if (typeof message.epoch === "string") targetEpochRef.current = message.epoch;
            setStatus(message as NvimConnectionStatus);
            return;
          }
          if (message.type === "redraw" && Array.isArray(message.events)) {
            applyNvimRedraw(gridRef.current, message.events);
            if (gridRef.current.width > 1 && gridRef.current.height > 1) setHasGrid(true);
            if (message.events.some(
              (event: unknown) => Array.isArray(event) && event[0] === "flush",
            )) {
              gridFlushRef.current += 1;
              scheduleRedraw();
              if (!gridReportTimerRef.current) {
                gridReportTimerRef.current = window.setTimeout(() => {
                  gridReportTimerRef.current = 0;
                  setGridRevision(current => current + 1);
                }, 120);
              }
            }
          }
        } catch {
          // Ignore malformed local bridge frames and keep the last good grid.
        }
      });
      socket.addEventListener("close", () => {
        if (websocketRef.current === socket) websocketRef.current = null;
        if (!cancelled) reconnectTimer = window.setTimeout(connect, 1200);
      });
      socket.addEventListener("error", () => {
        setStatus(current => ({
          ...current,
          state: "offline",
          message: "Most Neovima jest chwilowo niedostępny.",
        }));
      });
    };
    connect();
    return () => {
      cancelled = true;
      window.clearTimeout(reconnectTimer);
      window.cancelAnimationFrame(redrawFrameRef.current);
      redrawFrameRef.current = 0;
      websocketRef.current?.close();
      websocketRef.current = null;
    };
  }, [syncMode, visible]);

  if (!visible) return null;
  const send = (message: object) => {
    const socket = websocketRef.current;
    if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
  };
  const locateMouse = (event: React.MouseEvent<HTMLCanvasElement>) => {
    const bounds = event.currentTarget.getBoundingClientRect();
    return {
      row: Math.max(0, Math.min(
        gridRef.current.height - 1,
        Math.floor(((event.clientY - bounds.top) / bounds.height) * gridRef.current.height),
      )),
      column: Math.max(0, Math.min(
        gridRef.current.width - 1,
        Math.floor(((event.clientX - bounds.left) / bounds.width) * gridRef.current.width),
      )),
    };
  };
  const mouseModifier = (event: React.MouseEvent<HTMLCanvasElement>) => [
    event.shiftKey ? "S" : "",
    event.ctrlKey ? "C" : "",
    event.altKey ? "A" : "",
  ].filter(Boolean).join("-");
  return (
    <section
      className="nvim-panel"
      aria-label="Neovim połączony z kontenerem"
      onWheel={event => {
        event.stopPropagation();
        const target = event.target;
        if (!(target instanceof Element) || !target.closest(".nvim-screen-scroll")) {
          event.preventDefault();
        }
      }}
    >
      <div
        ref={screenRef}
        className="nvim-screen-scroll"
        style={fit ? undefined : { height: `${expanded ? 90 : panelHeightVh}vh` }}
        data-sync={syncMode ? "on" : "off"}
        data-control={controlEnabled ? "true" : "false"}
        tabIndex={0}
        onPointerEnter={event => {
          setPointerInside(true);
          event.currentTarget.focus({ preventScroll: true });
        }}
        onPointerLeave={event => {
          setPointerInside(false);
          event.currentTarget.blur();
        }}
        onMouseDown={event => event.currentTarget.focus({ preventScroll: true })}
        onKeyDown={event => {
          if (!controlEnabled) return;
          const keys = keyForNvim(event);
          if (!keys) return;
          event.preventDefault();
          event.stopPropagation();
          send({ type: "input", keys });
        }}
      >
        <div
          className="nvim-screen-stage"
          style={fit ? undefined : { height: `${(expanded ? 90 : panelHeightVh) / Math.max(scale, 0.25)}vh` }}
        >
          <div className="nvim-screen-surface">
            <div className="nvim-screen-frame">
              <div className="nvim-screen-content">
                <div className="nvim-screen-grid">
                  <canvas
                    ref={canvasRef}
                    className="nvim-screen"
                    tabIndex={-1}
                    aria-label={controlEnabled ? "Interaktywny ekran Neovima" : "Ekran Neovima"}
                    onMouseDown={event => {
                      if (!controlEnabled || status.state !== "connected") return;
                      event.preventDefault();
                      const point = locateMouse(event);
                      const button = event.button === 1 ? "middle" : event.button === 2 ? "right" : "left";
                      send({
                        type: "mouse",
                        button,
                        action: "press",
                        modifier: mouseModifier(event),
                        ...point,
                      });
                    }}
                    onMouseUp={event => {
                      if (!controlEnabled || status.state !== "connected") return;
                      event.preventDefault();
                      const point = locateMouse(event);
                      const button = event.button === 1 ? "middle" : event.button === 2 ? "right" : "left";
                      send({
                        type: "mouse",
                        button,
                        action: "release",
                        modifier: mouseModifier(event),
                        ...point,
                      });
                    }}
                    onWheel={event => {
                      if (!controlEnabled || status.state !== "connected") return;
                      event.preventDefault();
                      const point = locateMouse(event);
                      send({
                        type: "mouse",
                        button: "wheel",
                        action: event.deltaY < 0 ? "up" : "down",
                        modifier: mouseModifier(event),
                        ...point,
                      });
                    }}
                    onContextMenu={event => controlEnabled && event.preventDefault()}
                  />
                </div>
              </div>
            </div>
          </div>
        </div>
        {!hasGrid && (
          <div className="nvim-screen-empty">
            <strong>{nvimStatusLabel[status.state]}</strong>
            <span>{status.message ?? "Oczekiwanie na ekran Neovima lub zapisany stan kroku."}</span>
          </div>
        )}
      </div>
      <div
        className="nvim-resize-handle"
        role="separator"
        aria-label="Zmień wysokość panelu Neovima"
        aria-orientation="horizontal"
        aria-valuemin={25}
        aria-valuemax={90}
        aria-valuenow={Math.round(panelHeightVh)}
        tabIndex={0}
        title="Przeciągnij, aby zmienić wysokość. Dwuklik: 75%."
        onPointerDown={event => {
          event.preventDefault();
          resizeStartRef.current = { y: event.clientY, heightVh: panelHeightVh };
          event.currentTarget.setPointerCapture(event.pointerId);
        }}
        onPointerMove={event => {
          const start = resizeStartRef.current;
          if (!start) return;
          const deltaVh = ((event.clientY - start.y) / window.innerHeight) * 100;
          setPanelHeightVh(Math.max(25, Math.min(90, start.heightVh + deltaVh)));
        }}
        onPointerUp={event => {
          resizeStartRef.current = null;
          if (event.currentTarget.hasPointerCapture(event.pointerId)) {
            event.currentTarget.releasePointerCapture(event.pointerId);
          }
        }}
        onPointerCancel={() => { resizeStartRef.current = null; }}
        onDoubleClick={() => setPanelHeightVh(75)}
        onKeyDown={event => {
          if (event.key === "ArrowUp" || event.key === "ArrowDown") {
            event.preventDefault();
            const direction = event.key === "ArrowUp" ? -2 : 2;
            setPanelHeightVh(value => Math.max(25, Math.min(90, value + direction)));
          } else if (event.key === "Home") {
            event.preventDefault();
            setPanelHeightVh(75);
          }
        }}
      />
    </section>
  );
}

function ScopeTableCell({ column, row }: { column: string; row: any }) {
  if (column === "chapter" || column === "task" || column === "version") {
    return <td><code>{row[column]}</code></td>;
  }
  if (column === "idea") {
    return <td dangerouslySetInnerHTML={{ __html: row.idea_html }} />;
  }
  if (column === "status") {
    return (
      <td>
        <span className="scope-status" data-status={row.status} title={row.status}>
          {row.status_label}
        </span>
      </td>
    );
  }
  return <td>{row[column]}</td>;
}

function FrontPage({ front }: { front: any }) {
  const scopeColumns: string[] = front.scope_columns ?? ["chapter", "task", "idea", "priority"];
  return (
    <Sheet
      header={front.header_html}
      left={front.left_margin_html}
      right={front.right_margin_html}
      className="front-sheet"
    >
      <section className="front-scope front-goal">
        <h2>{front.goal_title}</h2>
        <Html html={front.goal_html} />
      </section>
      <section className="front-scope front-card-scope">
        <h2>{front.scope_title}</h2>
        {front.scope_html && <Html html={front.scope_html} />}
        <div className="scope-table-wrap">
          <table className="scope-table scope-table--task">
            <thead>
              <tr>
                {front.scope_headers.map((header: string) => (
                  <th key={header}>{header}</th>
                ))}
              </tr>
            </thead>
            <tbody>
              {front.scope_rows.map((row: any) => (
                <tr
                  key={`${row.chapter}-${row.task}`}
                  className={row.key ? "scope-row--key" : ""}
                >
                  {scopeColumns.map(column => (
                    <ScopeTableCell key={column} column={column} row={row} />
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </section>
    </Sheet>
  );
}

function TaskStep({ taskRef, step }: { taskRef: string; step: FlowStep }) {
  return (
    <li id={`${taskRef}-${step.id}`}>
      <strong>{step.title}</strong>
      <Html html={step.content_html} />
    </li>
  );
}

function TaskBlock({ taskRef, block }: { taskRef: string; block: FlowBlock }) {
  const label = block.kind === "exercise" ? "Ćwiczenie" : "Block";
  return (
    <section
      className={`task-block task-block--${block.kind}`}
      id={`${taskRef}-${block.id}`}
      data-stem-nav="block"
    >
      <header>
        <span>{label}</span>
        <h4>{block.title}</h4>
      </header>
      <Html html={block.content_html} />
      {block.steps.length > 0 && (
        <ol className="block-steps">
          {block.steps.map((step) => (
            <TaskStep key={step.id} taskRef={taskRef} step={step} />
          ))}
        </ol>
      )}
    </section>
  );
}

function TaskFlow({ task }: { task: Task }) {
  if (!task.flow.length)
    return (
      <article className="task-legacy" id={task.ref} data-stem-nav="task">
        <strong>{task.ref.toUpperCase()}</strong>
        <Html html={task.prompt_html ?? ""} />
        <footer>
          <strong>Task acceptance:</strong> {task.criterion}
        </footer>
      </article>
    );
  return (
    <article className="task-flow" id={task.ref} data-stem-nav="task">
      <header className="task-flow-header">
        <span>{task.ref.toUpperCase()}</span>
        <h3>{task.title}</h3>
        {task.uuid && <code>{task.uuid}</code>}
      </header>
      {task.flow.map((block) => (
        <TaskBlock key={block.id} taskRef={task.ref} block={block} />
      ))}
      <footer>
        <strong>Task acceptance:</strong> {task.criterion}
      </footer>
    </article>
  );
}

const areaLabels: Record<InteractiveArea, string> = {
  compile: "KOMPILACJA",
  bss: ".BSS",
  stack: "STOS",
  registers: "REJESTRY",
  text: ".TEXT",
};
const allocationColors = ["#9bcfe2", "#c4dfc0", "#f4c889", "#d2b6dc"];

function InteractiveMemoryFlow({
  config,
  title,
}: {
  config: InteractiveAsset;
  title: string;
}) {
  const arenaSize = config.arena_size ?? 64;
  const allocations = config.allocations ?? [5, 7];
  const symbols = config.symbols ?? [];
  const storageKey = `${config.storage_key ?? "allocator-memory-flow"}-stage`;
  const [stage, setStage] = useState(() =>
    Math.min(allocations.length, Number(localStorage.getItem(storageKey) ?? 0)),
  );
  const [focus, setFocus] = useState(symbols[0]?.id ?? "");
  useEffect(
    () => localStorage.setItem(storageKey, String(stage)),
    [stage, storageKey],
  );
  const used = allocations.slice(0, stage).reduce((sum, size) => sum + size, 0);
  const focused = symbols.find((symbol) => symbol.id === focus) ?? symbols[0];
  const cells = Array.from({ length: arenaSize }, (_, index) => index);
  const allocationIndex = (cell: number) => {
    let end = 0;
    for (let index = 0; index < stage; index += 1) {
      end += allocations[index];
      if (cell < end) return index;
    }
    return -1;
  };
  const stageText =
    stage === 0
      ? "allocator_reset(): allocp = allocbuf"
      : `alloc_local(${allocations[stage - 1]}): allocp = allocbuf + ${used}`;
  const areas = (Object.keys(areaLabels) as InteractiveArea[]).filter((area) =>
    symbols.some((symbol) => symbol.area === area),
  );
  return (
    <div className="memory-react" aria-label={`Interaktywny diagram: ${title}`}>
      <header>
        <div>
          <small>INTERAKTYWNY MODEL PAMIĘCI</small>
          <h3>{title}</h3>
        </div>
        <div className="memory-actions">
          <button aria-pressed={stage === 0} onClick={() => setStage(0)}>
            definicje / reset
          </button>
          {allocations.map((size, index) => (
            <button
              key={`${size}-${index}`}
              aria-pressed={stage === index + 1}
              onClick={() => setStage(index + 1)}
            >
              alloc({size})
            </button>
          ))}
        </div>
      </header>
      <div className="memory-lanes">
        {areas.map((area) => (
          <section className={`memory-lane ${area}`} key={area}>
            <b>{areaLabels[area]}</b>
            {symbols
              .filter((symbol) => symbol.area === area)
              .map((symbol) => (
                <button
                  key={symbol.id}
                  className={focus === symbol.id ? "active" : ""}
                  onClick={() => setFocus(symbol.id)}
                >
                  <code>{symbol.declaration}</code>
                </button>
              ))}
          </section>
        ))}
      </div>
      <div className="arena-wrap">
        <div className="arena-label">
          <code>allocbuf[{arenaSize}]</code>
          <span>
            użyte {used} B / wolne {arenaSize - used} B
          </span>
        </div>
        <div
          className="arena"
          style={
            {
              "--allocp": used,
              "--arena-size": arenaSize,
            } as React.CSSProperties
          }
        >
          {cells.map((cell) => {
            const segment = allocationIndex(cell);
            return (
              <i
                key={cell}
                style={
                  segment >= 0
                    ? {
                        background:
                          allocationColors[segment % allocationColors.length],
                      }
                    : undefined
                }
              />
            );
          })}
          <span className="allocp">allocp ↑ {used}</span>
        </div>
      </div>
      <p className="memory-stage">
        <strong>
          {stage + 1}/{allocations.length + 1}
        </strong>{" "}
        <code>{stageText}</code>
      </p>
      {focused && (
        <aside className="memory-inspector">
          <strong>{focused.id}</strong>
          <code>{focused.declaration}</code>
          <span>{focused.description}</span>
        </aside>
      )}
    </div>
  );
}

function LegacyInteractiveUmlStages({
  config,
  asset,
  progress,
  onSetStatus,
}: {
  config: InteractiveAsset;
  asset: Asset;
  progress: Progress | null;
  onSetStatus: SetProgressStatus;
}) {
  const stages = config.stages ?? [];
  const storageKey = `${config.storage_key ?? asset.label}-uml-stage`;
  const canvasRef = useRef<HTMLDivElement>(null);
  const [svgMarkup, setSvgMarkup] = useState("");
  const [svgFailed, setSvgFailed] = useState(false);
  const [approvalBusy, setApprovalBusy] = useState(false);
  const [stageIndex, setStageIndex] = useState(() =>
    Math.max(
      0,
      Math.min(
        stages.length - 1,
        Number(localStorage.getItem(storageKey) ?? 0),
      ),
    ),
  );
  useEffect(
    () => localStorage.setItem(storageKey, String(stageIndex)),
    [stageIndex, storageKey],
  );
  useEffect(() => {
    let current = true;
    setSvgFailed(false);
    fetch(asset.href, { cache: "no-store" })
      .then((response) => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.text();
      })
      .then((markup) => {
        const document = new DOMParser().parseFromString(
          markup,
          "image/svg+xml",
        );
        const svg = document.documentElement;
        if (svg.nodeName.toLowerCase() !== "svg" || document.querySelector("parsererror")) {
          throw new Error("Niepoprawny SVG");
        }
        document.querySelectorAll("script, foreignObject").forEach((node) => node.remove());
        svg.removeAttribute("width");
        svg.removeAttribute("height");
        svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
        if (current) setSvgMarkup(svg.outerHTML);
      })
      .catch(() => {
        if (current) setSvgFailed(true);
      });
    return () => {
      current = false;
    };
  }, [asset.href]);
  useEffect(() => {
    const svg = canvasRef.current?.querySelector("svg");
    if (!svg) return;

    svg.setAttribute("role", "img");
    svg.setAttribute("aria-label", asset.alt);
    svg
      .querySelectorAll<SVGRectElement>("[data-uml-stage]")
      .forEach((rect) => {
        rect.classList.remove(
          "uml-stage-region",
          "uml-stage-region-bg",
          "uml-stage-region-outline",
          "uml-stage-region-active",
          "uml-stage-region-completed",
        );
        rect.removeAttribute("data-uml-stage");
        rect.removeAttribute("data-uml-stage-label");
        rect.removeAttribute("role");
        rect.removeAttribute("tabindex");
        rect.removeAttribute("aria-label");
      });

    const texts = Array.from(svg.querySelectorAll<SVGTextElement>("text"));
    const rects = Array.from(svg.querySelectorAll<SVGRectElement>("rect"));
    stages.forEach((item, index) => {
      if (!item.svg_label) return;
      const label = texts.find((text) =>
        (text.textContent ?? "").trim().startsWith(item.svg_label!),
      );
      const labelY = Number(label?.getAttribute("y"));
      if (!label || !Number.isFinite(labelY)) return;

      const candidates = rects
        .map((rect) => {
          const y = Number(rect.getAttribute("y"));
          const height = Number(rect.getAttribute("height"));
          const width = Number(rect.getAttribute("width"));
          const style = rect.getAttribute("style") ?? "";
          const strokeWidth = Number(
            style.match(/stroke-width:\s*([\d.]+)/)?.[1] ??
              rect.getAttribute("stroke-width") ??
              0,
          );
          return { rect, y, height, width, strokeWidth, area: width * height };
        })
        .filter(
          ({ y, height, width, strokeWidth }) =>
            Number.isFinite(y) &&
            Number.isFinite(height) &&
            Number.isFinite(width) &&
            strokeWidth >= 2 &&
            y <= labelY &&
            y + height >= labelY,
        )
        .sort((left, right) => left.area - right.area);
      const region = candidates[0];
      if (!region) return;

      const sameRegion = candidates.filter(
        ({ y, height, width }) =>
          Math.abs(y - region.y) < 0.1 &&
          Math.abs(height - region.height) < 0.1 &&
          Math.abs(width - region.width) < 0.1,
      );
      const isCompleted = Boolean(
        item.progress_id &&
          progress?.items?.[item.progress_id]?.status === "approved",
      );
      sameRegion.forEach(({ rect }, duplicateIndex) => {
        const isBackground = rect.getAttribute("fill") !== "none";
        rect.dataset.umlStage = String(index);
        rect.dataset.umlStageLabel = item.label;
        rect.classList.add(
          "uml-stage-region",
          isBackground ? "uml-stage-region-bg" : "uml-stage-region-outline",
        );
        rect.classList.toggle("uml-stage-region-active", index === stageIndex);
        rect.classList.toggle("uml-stage-region-completed", isCompleted);
        if (duplicateIndex === 0) {
          rect.setAttribute("role", "button");
          rect.setAttribute("tabindex", "0");
          rect.setAttribute("aria-label", `Pokaż etap ${item.label}`);
        }
      });
    });
  }, [asset.alt, progress, stageIndex, stages, svgMarkup]);

  const selectStageAtPoint = (clientX: number, clientY: number) => {
    const svg = canvasRef.current?.querySelector("svg");
    if (!svg) return;
    const hit = Array.from(
      svg.querySelectorAll<SVGRectElement>(".uml-stage-region-bg[data-uml-stage]"),
    )
      .filter((rect) => {
        const bounds = rect.getBoundingClientRect();
        return (
          clientX >= bounds.left &&
          clientX <= bounds.right &&
          clientY >= bounds.top &&
          clientY <= bounds.bottom
        );
      })
      .sort((left, right) => {
        const a = left.getBoundingClientRect();
        const b = right.getBoundingClientRect();
        return a.width * a.height - b.width * b.height;
      })[0];
    const next = Number(hit?.dataset.umlStage);
    if (Number.isInteger(next)) setStageIndex(next);
  };

  const handleSvgKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.key !== "Enter" && event.key !== " ") return;
    const target = event.target as SVGRectElement;
    const next = Number(target.dataset?.umlStage);
    if (!Number.isInteger(next)) return;
    event.preventDefault();
    setStageIndex(next);
  };
  const stage = stages[stageIndex];
  return (
    <div className="uml-react">
      <header>
        <div>
          <small>KANONICZNY DIAGRAM UML · PLANTUML</small>
          <h3>Reset i dwa przydziały liniowe</h3>
        </div>
        <div className="uml-actions">
          {stages.map((item, index) => (
            <button
              key={item.id}
              aria-pressed={stageIndex === index}
              data-completed={
                item.progress_id &&
                progress?.items?.[item.progress_id]?.status === "approved"
                  ? "true"
                  : "false"
              }
              onClick={() => setStageIndex(index)}
            >
              {item.progress_id &&
                progress?.items?.[item.progress_id]?.status === "approved" && (
                  <span aria-hidden="true">✓ </span>
                )}
              {item.label}
            </button>
          ))}
        </div>
      </header>
      <div
        ref={canvasRef}
        className="uml-canvas"
        onClick={(event) => selectStageAtPoint(event.clientX, event.clientY)}
        onKeyDown={handleSvgKeyDown}
      >
        {svgMarkup ? (
          <div
            className="uml-svg-host"
            // The SVG is a generated, same-origin PlantUML asset; scripts and
            // foreignObject nodes are removed before it reaches the DOM.
            dangerouslySetInnerHTML={{ __html: svgMarkup }}
          />
        ) : (
          <img src={asset.href} alt={asset.alt} />
        )}
        {svgFailed && (
          <span className="uml-inline-warning">
            Widok statyczny — nie udało się wczytać interaktywnego SVG.
          </span>
        )}
      </div>
      {stage && (
        <aside className="uml-stage">
          <strong>{stage.label}</strong>
          <span>{stage.description}</span>
          {stage.code_ref && <code>{stage.code_ref}</code>}
        </aside>
      )}
      {stage?.progress_id && (
        <footer className="uml-approval">
          <span>
            {progress?.items?.[stage.progress_id]?.status === "approved"
              ? "Ten stan jest zatwierdzony i zapisany w JSON-ie zajęć."
              : "Kursor wybiera stan; zatwierdzenie zapisuje go w JSON-ie zajęć."}
          </span>
          <button
            disabled={!progress || approvalBusy}
            onClick={async () => {
              if (!stage.progress_id || !progress) return;
              const completed =
                progress.items?.[stage.progress_id]?.status === "approved";
              setApprovalBusy(true);
              try {
                await onSetStatus(
                  stage.progress_id,
                  completed ? "pending" : "approved",
                );
              } finally {
                setApprovalBusy(false);
              }
            }}
          >
            {approvalBusy
              ? "Zapisywanie…"
              : progress?.items?.[stage.progress_id]?.status === "approved"
                ? "Cofnij zatwierdzenie"
                : "Zatwierdź stan"}
          </button>
        </footer>
      )}
    </div>
  );
}

function SnapshotInteractiveUmlSequence({
  config,
  asset,
  progress,
  onSetStatus,
}: {
  config: InteractiveAsset;
  asset: Asset;
  progress: Progress | null;
  onSetStatus: SetProgressStatus;
}) {
  const phases = config.phases ?? [];
  const entries = phases.flatMap((phase) =>
    phase.steps.map((step) => ({ phase, step })),
  );
  const storageKey = `${config.storage_key ?? asset.label}-uml-step`;
  const focusStorageKey = `${config.storage_key ?? asset.label}-navigation-level`;
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLDivElement>(null);
  const handledRevealRevisionRef = useRef(0);
  const activateOnChangeRef = useRef(false);
  const focusOnChangeRef = useRef<NavigationLevel | null>(null);
  const persistOnChangeRef = useRef(true);
  const [svgMarkup, setSvgMarkup] = useState("");
  const [svgFailed, setSvgFailed] = useState(false);
  const [revealRevision, setRevealRevision] = useState(0);
  const [stepIndex, setStepIndex] = useState(() =>
    Math.max(
      0,
      Math.min(
        entries.length - 1,
        Number(localStorage.getItem(storageKey) ?? 0),
      ),
    ),
  );
  const [focusLevel, setFocusLevel] = useState<NavigationLevel>(() => {
    const saved = localStorage.getItem(focusStorageKey);
    return saved === "card" || saved === "task" || saved === "block" || saved === "phase"
      || saved === "step" || saved === "snapshot"
      ? saved
      : "step";
  });

  useEffect(
    () => localStorage.setItem(storageKey, String(stepIndex)),
    [stepIndex, storageKey],
  );
  useEffect(
    () => localStorage.setItem(focusStorageKey, focusLevel),
    [focusLevel, focusStorageKey],
  );
  useEffect(() => {
    let current = true;
    setSvgFailed(false);
    fetch(asset.href, { cache: "no-store" })
      .then((response) => {
        if (!response.ok) throw new Error(`HTTP ${response.status}`);
        return response.text();
      })
      .then((markup) => {
        const document = new DOMParser().parseFromString(
          markup,
          "image/svg+xml",
        );
        const svg = document.documentElement;
        if (
          svg.nodeName.toLowerCase() !== "svg" ||
          document.querySelector("parsererror")
        ) {
          throw new Error("Niepoprawny SVG");
        }
        document
          .querySelectorAll("script, foreignObject")
          .forEach((node) => node.remove());
        svg.removeAttribute("width");
        svg.removeAttribute("height");
        svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
        if (current) setSvgMarkup(svg.outerHTML);
      })
      .catch(() => {
        if (current) setSvgFailed(true);
      });
    return () => {
      current = false;
    };
  }, [asset.href]);

  useEffect(() => {
    const svg = canvasRef.current?.querySelector("svg");
    if (!svg) return;
    svg.setAttribute("role", "img");
    svg.setAttribute("aria-label", asset.alt);
    svg.querySelectorAll(".uml-step-hit").forEach((node) => node.remove());
    svg
      .querySelectorAll<SVGElement>("[data-uml-step-element]")
      .forEach((element) => {
        element.classList.remove(
          "uml-step-element",
          "uml-step-element-active",
          "uml-step-element-completed",
        );
        element.removeAttribute("data-uml-step-element");
      });

    const texts = Array.from(svg.querySelectorAll<SVGTextElement>("text"));
    const lines = Array.from(svg.querySelectorAll<SVGLineElement>("line"));
    const polygons = Array.from(svg.querySelectorAll<SVGPolygonElement>("polygon"));
    const viewBox = svg.viewBox.baseVal;
    const hitX = viewBox?.width ? viewBox.x : 0;
    const hitWidth = viewBox?.width || 1200;

    entries.forEach(({ step }, index) => {
      const svgLabel = step.svg_label ?? String(step.number).padStart(2, "0");
      const numberText = texts.find(
        (text) => (text.textContent ?? "").trim() === svgLabel,
      );
      const labelY = Number(numberText?.getAttribute("y"));
      if (!numberText || !Number.isFinite(labelY)) return;
      const arrowY = labelY + 5;
      const completed = Boolean(
        step.progress_id &&
          progress?.items?.[step.progress_id]?.status === "approved",
      );
      const related: SVGElement[] = texts.filter(
        (text) => Math.abs(Number(text.getAttribute("y")) - labelY) < 0.8,
      );
      related.push(
        ...lines.filter((line) => {
          const y1 = Number(line.getAttribute("y1"));
          const y2 = Number(line.getAttribute("y2"));
          return (
            Number.isFinite(y1) &&
            Number.isFinite(y2) &&
            Math.abs(y1 - y2) < 0.8 &&
            Math.abs((y1 + y2) / 2 - arrowY) <= 14
          );
        }),
      );
      related.push(
        ...polygons.filter((polygon) => {
          const points = (polygon.getAttribute("points") ?? "")
            .trim()
            .split(/\s+/)
            .map((point) => Number(point.split(",")[1]))
            .filter(Number.isFinite);
          if (!points.length) return false;
          const minY = Math.min(...points);
          const maxY = Math.max(...points);
          return maxY - minY <= 16 && Math.abs((minY + maxY) / 2 - arrowY) <= 14;
        }),
      );
      related.forEach((element) => {
        element.dataset.umlStepElement = String(index);
        element.classList.add("uml-step-element");
        element.classList.toggle("uml-step-element-active", index === stepIndex);
        element.classList.toggle("uml-step-element-completed", completed);
      });

      const hit = document.createElementNS("http://www.w3.org/2000/svg", "rect");
      hit.setAttribute("x", String(hitX));
      hit.setAttribute("y", String(labelY - 12));
      hit.setAttribute("width", String(hitWidth));
      hit.setAttribute("height", "31");
      hit.setAttribute("fill", "transparent");
      hit.setAttribute("pointer-events", "all");
      hit.setAttribute("role", "button");
      hit.setAttribute("tabindex", "0");
      hit.setAttribute(
        "aria-label",
        `Pokaż krok ${String(step.number).padStart(2, "0")}: ${step.label}`,
      );
      hit.dataset.umlStep = String(index);
      hit.classList.add("uml-step-hit");
      hit.classList.toggle("uml-step-hit-active", focusLevel !== "card" && index === stepIndex);
      hit.classList.toggle("uml-step-hit-completed", completed);
      svg.appendChild(hit);
    });
  }, [asset.alt, entries, progress, stepIndex, svgMarkup]);

  useEffect(() => {
    if (
      revealRevision === 0
      || handledRevealRevisionRef.current === revealRevision
    ) return;

    let cancelled = false;
    let correctionFrame = 0;
    const reveal = (pass: number) => {
      if (cancelled) return;
      const target = canvasRef.current?.querySelector<SVGRectElement>(
        `.uml-step-hit[data-uml-step="${stepIndex}"]`,
      );
      if (!target) return;
      const chrome = document.querySelector<HTMLElement>(".viewer-chrome");
      if (chrome?.classList.contains("is-nvim-fit")) {
        handledRevealRevisionRef.current = revealRevision;
        return;
      }
      const windowViewport = chromeAwareWindowViewport(target);
      if (!windowViewport) {
        handledRevealRevisionRef.current = revealRevision;
        return;
      }
      const viewport = visibleVerticalViewport(target);
      if (!viewport) {
        const pageMovement = bringTargetSheetIntoWindow(target, windowViewport);
        const fallbackDelta = pageMovement === 0
          ? umlScrolloffDelta(target, windowViewport)
          : 0;
        const fallbackMovement = pageMovement === 0
          ? applyVerticalScrollDelta(target, fallbackDelta)
          : pageMovement;
        if (
          pass < UML_SCROLLOFF_MAX_PASSES - 1
          && Math.abs(fallbackMovement) > UML_SCROLLOFF_EPSILON_PX
        ) {
          correctionFrame = window.requestAnimationFrame(() => reveal(pass + 1));
          return;
        }
        handledRevealRevisionRef.current = revealRevision;
        return;
      }
      const delta = umlScrolloffDelta(target, viewport);
      if (Math.abs(delta) <= UML_SCROLLOFF_EPSILON_PX) {
        handledRevealRevisionRef.current = revealRevision;
        return;
      }
      const applied = applyVerticalScrollDelta(target, delta);
      if (
        pass < UML_SCROLLOFF_MAX_PASSES - 1
        && Math.abs(applied) > UML_SCROLLOFF_EPSILON_PX
      ) {
        correctionFrame = window.requestAnimationFrame(() => reveal(pass + 1));
        return;
      }
      handledRevealRevisionRef.current = revealRevision;
    };
    const frame = window.requestAnimationFrame(() => reveal(0));
    return () => {
      cancelled = true;
      window.cancelAnimationFrame(frame);
      window.cancelAnimationFrame(correctionFrame);
    };
  }, [revealRevision, stepIndex, svgMarkup]);

  const publishEntry = (
    entry: { phase: UmlSequencePhase; step: UmlSequenceStep },
    activate: boolean,
    level: NavigationLevel = focusLevel,
    persist = true,
  ) => {
    const detail: ActiveNvimStep = {
      task: config.task ?? null,
      block: config.block ?? null,
      phase: { id: entry.phase.id, label: entry.phase.label },
      step: entry.step,
      focusLevel: level,
      activate: activate && document.documentElement.dataset.nvimSyncMode === "on",
    };
    const dispatch = () => {
      window.dispatchEvent(new CustomEvent<ActiveNvimStep>("stem:nvim-step", { detail }));
    };
    if (!persist) {
      dispatch();
      return;
    }
    const save = fetch("/api/navigation/current", {
      method: "PUT",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        task: detail.task,
        block: detail.block,
        phase: detail.phase,
        step: {
          id: detail.step.id,
          number: detail.step.number,
          label: detail.step.label,
        },
        snapshot_ref: detail.step.snapshot_ref ?? null,
        focus_level: detail.focusLevel,
        sync_requested: Boolean(detail.activate),
        actor: document.documentElement.dataset.cardClientId ?? "browser",
      }),
    });
    if (detail.activate) {
      // Activation must be audited against the newly selected step. In a static
      // export the request fails, but the local interactive model still works.
      void save.then(dispatch, dispatch);
    } else {
      dispatch();
      void save.catch(() => undefined);
    }
  };
  const selectIndex = (
    next: number,
    activate = false,
    level: NavigationLevel = focusLevel,
    persist = true,
  ) => {
    const bounded = Math.max(0, Math.min(entries.length - 1, next));
    const entry = entries[bounded];
    if (!entry) return;
    setFocusLevel(level);
    if (bounded === stepIndex) {
      publishEntry(entry, activate, level, persist);
      return;
    }
    setRevealRevision(current => current + 1);
    activateOnChangeRef.current = activate;
    focusOnChangeRef.current = level;
    persistOnChangeRef.current = persist;
    setStepIndex(bounded);
  };
  const selectStepAtPoint = (clientX: number, clientY: number) => {
    const hits = Array.from(
      canvasRef.current?.querySelectorAll<SVGRectElement>(
        ".uml-step-hit[data-uml-step]",
      ) ?? [],
    )
      .filter((hit) => {
        const bounds = hit.getBoundingClientRect();
        return (
          clientX >= bounds.left &&
          clientX <= bounds.right &&
          clientY >= bounds.top &&
          clientY <= bounds.bottom
        );
      })
      .sort((left, right) => {
        const a = left.getBoundingClientRect();
        const b = right.getBoundingClientRect();
        return (
          Math.abs(clientY - (a.top + a.bottom) / 2) -
          Math.abs(clientY - (b.top + b.bottom) / 2)
        );
      });
    const next = Number(hits[0]?.dataset.umlStep);
    if (Number.isInteger(next)) selectIndex(next, false, "step");
  };
  const handleSvgKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (event.key !== "Enter" && event.key !== " ") return;
    const target = event.target as SVGRectElement;
    const next = Number(target.dataset?.umlStep);
    if (!Number.isInteger(next)) return;
    event.preventDefault();
    selectIndex(next, event.key === "Enter", "step");
  };
  const selectPhase = (phase: UmlSequencePhase) => {
    const next = entries.findIndex((entry) => entry.phase.id === phase.id);
    if (next >= 0) selectIndex(next, false, "phase");
  };

  const active = entries[stepIndex];
  useEffect(() => {
    if (!active) return;
    publishEntry(
      active,
      activateOnChangeRef.current,
      focusOnChangeRef.current ?? focusLevel,
      persistOnChangeRef.current,
    );
    activateOnChangeRef.current = false;
    focusOnChangeRef.current = null;
    persistOnChangeRef.current = true;
  }, [active?.phase.id, active?.step.id, config.block]);

  useEffect(() => {
    const applyCommand = (event: Event) => {
      const command = (event as CustomEvent<ApiNavigation>).detail;
      if (!command) return;
      if (config.task?.id && command.task.id !== config.task.id) return;
      if (config.block?.id && command.block.id !== config.block.id) return;
      const next = entries.findIndex(entry =>
        entry.phase.id === command.phase.id && entry.step.id === command.step.id,
      );
      if (next < 0) return;
      // Remote navigation only moves the teaching cursor. The producer that
      // requested a replay owns it; reflecting state must never replay twice.
      selectIndex(next, false, command.focus_level, false);
    };
    window.addEventListener("stem:navigation-command", applyCommand);
    return () => window.removeEventListener("stem:navigation-command", applyCommand);
  }, [config.block?.id, config.task?.id, entries, focusLevel, stepIndex]);

  useEffect(() => {
    const navigate = (event: KeyboardEvent) => {
      if (event.metaKey) return;
      const target = event.target as HTMLElement | null;
      if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
      const root = rootRef.current;
      if (!root) return;
      const diagrams = Array.from(
        document.querySelectorAll<HTMLElement>("[data-stem-nav='uml-block']"),
      );
      const viewportCenter = window.innerHeight / 2;
      const nearest = diagrams.sort((left, right) => {
        const a = left.getBoundingClientRect();
        const b = right.getBoundingClientRect();
        return Math.abs((a.top + a.bottom) / 2 - viewportCenter)
          - Math.abs((b.top + b.bottom) / 2 - viewportCenter);
      })[0];
      if (nearest !== root) return;

      if (event.key === "Enter" && event.ctrlKey && !event.altKey && !event.shiftKey) {
        if (!active?.step.progress_id || !progress) return;
        event.preventDefault();
        event.stopPropagation();
        const approved = progress.items?.[active.step.progress_id]?.status === "approved";
        void onSetStatus(active.step.progress_id, approved ? "pending" : "approved");
        return;
      }

      if (event.key === "Enter" && !event.altKey && !event.shiftKey) {
        if (!active) return;
        event.preventDefault();
        event.stopPropagation();
        publishEntry(active, true, focusLevel);
        return;
      }

      const hierarchy: NavigationLevel[] = ["card", "task", "block", "phase", "step", "snapshot"];
      if ((event.key === "ArrowLeft" || event.key === "ArrowRight" || event.key === "Escape")
        && !event.altKey && !event.ctrlKey && !event.shiftKey) {
        const direction = event.key === "ArrowRight" ? 1 : -1;
        const nextLevel = hierarchy[Math.max(
          0,
          Math.min(hierarchy.length - 1, hierarchy.indexOf(focusLevel) + direction),
        )];
        event.preventDefault();
        event.stopPropagation();
        setFocusLevel(nextLevel);
        if (active) publishEntry(active, false, nextLevel);
        return;
      }

      if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
      if (focusLevel === "card" && !event.altKey && !event.ctrlKey && !event.shiftKey) return;
      const direction = event.key === "ArrowUp" ? -1 : 1;
      let level: NavigationLevel = focusLevel;
      if (event.altKey && event.shiftKey && !event.ctrlKey) level = "snapshot";
      else if (event.ctrlKey && event.shiftKey && !event.altKey) level = "step";
      else if (event.altKey && !event.ctrlKey && !event.shiftKey) level = "task";
      else if (event.ctrlKey && !event.altKey && !event.shiftKey) level = "block";
      else if (event.shiftKey && !event.altKey && !event.ctrlKey) level = "phase";
      else if (event.altKey || event.ctrlKey || event.shiftKey) return;
      event.preventDefault();
      event.stopPropagation();
      void fetch("/api/navigation/move", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({
          level,
          delta: direction,
          sync_requested: false,
          actor: document.documentElement.dataset.cardClientId ?? "browser",
        }),
      })
        .then(response => response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`)))
        .then(payload => {
          if (payload.current) {
            window.dispatchEvent(new CustomEvent<ApiNavigation>("stem:navigation-command", {
              detail: payload.current,
            }));
          }
        })
        .catch(() => undefined);
    };
    window.addEventListener("keydown", navigate);
    return () => window.removeEventListener("keydown", navigate);
  }, [active, entries, focusLevel, onSetStatus, progress]);
  if (!active) return null;
  const { phase, step } = active;
  const phaseEntries = entries.filter((entry) => entry.phase.id === phase.id);

  return (
    <div
      ref={rootRef}
      className="uml-react uml-snapshot-react"
      data-stem-nav="uml-block"
      data-task-id={config.task?.id}
      data-block-id={config.block?.id}
      data-navigation-level={focusLevel}
    >
      <header className="uml-snapshot-header">
        <div>
          <small>BLOCK · {config.block?.label ?? "PRZEPŁYW UML"}</small>
          <h3>{config.title ?? config.block?.label ?? asset.caption}</h3>
          {config.block?.description && <p>{config.block.description}</p>}
        </div>
        <div className="uml-phase-actions" aria-label="Fazy diagramu">
          {phases.map((item) => (
            <button
              key={item.id}
              aria-pressed={phase.id === item.id}
              onClick={() => selectPhase(item)}
            >
              {item.label}
            </button>
          ))}
        </div>
      </header>
      <div className="uml-step-actions" aria-label={`Kroki fazy ${phase.label}`}>
        <span>{phase.label}</span>
        {phaseEntries.map((entry) => {
          const index = entries.indexOf(entry);
          const isDone = Boolean(
            entry.step.progress_id &&
              progress?.items?.[entry.step.progress_id]?.status === "approved",
          );
          return (
            <button
              key={entry.step.id}
              aria-label={`Krok ${entry.step.number}: ${entry.step.label}`}
              aria-pressed={focusLevel !== "card" && index === stepIndex}
              data-completed={isDone ? "true" : "false"}
              onClick={() => selectIndex(index)}
            >
              {String(entry.step.number).padStart(2, "0")}
            </button>
          );
        })}
      </div>
      <div className="uml-snapshot-layout">
        <div
          ref={canvasRef}
          className="uml-canvas uml-step-canvas"
          onClick={(event) => selectStepAtPoint(event.clientX, event.clientY)}
          onKeyDown={handleSvgKeyDown}
        >
          {svgMarkup ? (
            <div
              className="uml-svg-host"
              dangerouslySetInnerHTML={{ __html: svgMarkup }}
            />
          ) : (
            <img src={asset.href} alt={asset.alt} />
          )}
          {svgFailed && (
            <span className="uml-inline-warning">
              Widok statyczny — wybierz krok przyciskiem nad diagramem.
            </span>
          )}
        </div>
      </div>
    </div>
  );
}

function InteractiveUmlSequence(props: {
  config: InteractiveAsset;
  asset: Asset;
  progress: Progress | null;
  onSetStatus: SetProgressStatus;
}) {
  return props.config.phases?.length ? (
    <SnapshotInteractiveUmlSequence {...props} />
  ) : (
    <LegacyInteractiveUmlStages {...props} />
  );
}

function AssetFigure({
  asset,
  number,
  progress,
  onSetStatus,
}: {
  asset: Asset;
  number: number;
  progress: Progress | null;
  onSetStatus: SetProgressStatus;
}) {
  const interactiveKind = asset.interactive?.kind;
  const isInteractive = Boolean(interactiveKind);
  return (
    <figure
      className={`card-asset asset-${asset.kind} ${isInteractive ? "asset-interactive" : ""}`}
      id={asset.label.replace(":", "-")}
    >
      {interactiveKind === "uml-sequence" ? (
        <InteractiveUmlSequence
          config={asset.interactive!}
          asset={asset}
          progress={progress}
          onSetStatus={onSetStatus}
        />
      ) : interactiveKind === "allocator-memory-flow" ? (
        <>
          <InteractiveMemoryFlow
            config={asset.interactive!}
            title={asset.caption}
          />
          <img
            className="asset-static-fallback"
            src={asset.href}
            alt={asset.alt}
          />
        </>
      ) : (
        <a href={asset.href} target="_blank" rel="noopener">
          <img
            src={asset.href}
            alt={asset.alt}
            loading="lazy"
            style={{ width: `${asset.width * 100}%` }}
          />
        </a>
      )}
      {asset.source_href && (
        <a
          className="asset-source-link"
          href={asset.source_href}
          target="_blank"
          rel="noopener"
        >
          Źródło PlantUML
        </a>
      )}
      <figcaption>
        Rys. {number}. {asset.caption}
      </figcaption>
    </figure>
  );
}

function StepMap({
  steps,
  progress,
  onCycle,
}: {
  steps: Section["steps"];
  progress: Progress | null;
  onCycle: (id: string, status: Status) => void;
}) {
  if (!steps.length) return null;
  const labels: Record<Status, string> = {
    pending: "○ niezatwierdzony",
    approved: "● zatwierdzony",
  };
  return (
    <div className="step-map">
      {steps.map((step) => {
        const status: Status =
          progress?.items?.[step.id]?.status ?? "pending";
        return (
          <div
            className={`step-row ${status === "approved" ? "lesson-progress-completed" : ""}`}
            key={step.id}
          >
            <button
              className="lesson-progress-control"
              data-status={status}
              onClick={() => onCycle(step.id, status)}
              disabled={!progress}
            >
              {labels[status]}
            </button>
            <span className="step-title">{step.title}</span>
          </div>
        );
      })}
    </div>
  );
}

function SectionPage({
  section,
  progress,
  onCycle,
  onSetStatus,
  figureOffset,
}: {
  section: Section;
  progress: Progress | null;
  onCycle: (id: string, status: Status) => void;
  onSetStatus: SetProgressStatus;
  figureOffset: number;
}) {
  return (
    <Sheet
      header={section.header_html}
      left={section.left_margin_html}
      right={section.right_margin_html}
      className="section-sheet"
    >
      <div id={section.id} className="section-anchor" />
      {section.show_heading && (
        <h2 className="section-heading">
          <span className="section-index">{section.index}</span>
          <span className="section-title">{section.title}</span>
        </h2>
      )}
      <StepMap steps={section.steps} progress={progress} onCycle={onCycle} />
      {section.content_html && <Html html={section.content_html} />}
      <div className="tasks">
        {section.tasks.map((task) => (
          <TaskFlow key={task.ref} task={task} />
        ))}
      </div>
      {section.assets.map((asset, index) => (
        <AssetFigure
          key={asset.label}
          asset={asset}
          number={figureOffset + index + 1}
          progress={progress}
          onSetStatus={onSetStatus}
        />
      ))}
      {section.tasks.map(
        (task) =>
          task.conclusion_html && (
            <aside className="task-conclusion" key={`${task.ref}-conclusion`}>
              <strong>WNIOSEK</strong>
              <Html html={task.conclusion_html} />
            </aside>
          ),
      )}
    </Sheet>
  );
}

function ProgressDock({
  progress,
  error,
}: {
  progress: Progress | null;
  error: string;
}) {
  if (!progress?.summary && !error) return null;
  const counts = progress?.summary.counts;
  return (
    <aside className="lesson-progress-dock">
      <strong>Przebieg zajęć</strong>
      {counts && (
        <p>
          Zatwierdzone: {counts.approved}/{progress!.summary.total}
        </p>
      )}
      {error && <p className="lesson-progress-error">{error}</p>}
      <a href="/api/report/teams.md" target="_blank" rel="noopener">
        Otwórz raport do Teams
      </a>
      <br />
      <a href="/api/report/lesson.html" target="_blank" rel="noopener">
        Otwórz ślad lekcji ze zrzutami
      </a>
    </aside>
  );
}

function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void }) {
  if (!open) return null;
  const rows = [
    ["Alt + ↑ / ↓", "poprzedni / następny TASK"],
    ["Ctrl + ↑ / ↓", "poprzedni / następny BLOCK"],
    ["Shift + ↑ / ↓", "poprzednia / następna PHASE"],
    ["Ctrl + Shift + ↑ / ↓", "poprzedni / następny STEP"],
    ["Alt + Shift + ↑ / ↓", "poprzedni / następny unikalny SNAPSHOT"],
    ["↑ / ↓", "poprzedni / następny element aktywnego poziomu"],
    ["→", "zejdź poziom niżej"],
    ["← / Esc", "wróć poziom wyżej"],
    ["Enter", "wybierz element; przy SYNC ON odtwórz stan"],
    ["F1", "reset aktualnego celu Neovima/kontenera"],
    ["F2", "synchronizuj cel z kursorem drzewa UML"],
    ["Ctrl + `", "maksymalizuj pane, dopasuj siatkę i odśwież Neovima"],
    ["Ctrl + Enter", "zatwierdź lub cofnij zatwierdzenie kroku"],
    ["Alt + 1", "pokaż lub ukryj panel Neovima"],
    ["Alt + 2", "przełącz SYNC OFF / SYNC ON"],
    ["Alt + 3", "uzbrój lub rozbrój sterowanie Neovimem"],
    ["Alt + 4", "wysoki panel 90% / zapisana wysokość normalna"],
    ["Alt + 5", "dopasuj cały ekran Neovima; wyłącz ręczny suwak"],
    ["Alt + 6", "przypnij lub ukryj model alokatora"],
    ["Alt + 3, potem najedź", "przekaż klawiaturę i mysz do aktywnej sesji"],
    ["Alt + W", "prefiks poleceń okien Neovima (<C-W>)"],
    ["Alt + strzałka", "przejdź do sąsiedniego okna Neovima"],
    ["F12", "pokaż lub ukryj ten skorowidz"],
  ];
  return (
    <div className="shortcut-help-backdrop" role="presentation" onMouseDown={onClose}>
      <section
        className="shortcut-help"
        role="dialog"
        aria-modal="true"
        aria-labelledby="shortcut-help-title"
        onMouseDown={event => event.stopPropagation()}
      >
        <header>
          <div>
            <small>F12 · NAWIGACJA DYDAKTYCZNA</small>
            <h2 id="shortcut-help-title">CARD → TASK → BLOCK → PHASE → STEP → SNAPSHOT</h2>
          </div>
          <button onClick={onClose} aria-label="Zamknij skorowidz">×</button>
        </header>
        <dl>
          {rows.map(([keys, description]) => (
            <div key={keys}>
              <dt><kbd>{keys}</kbd></dt>
              <dd>{description}</dd>
            </div>
          ))}
        </dl>
        <p>
          Przy SYNC ON wybór stepu odtwarza i weryfikuje przypięty stan maszyny.
          SYNC OFF nie resetuje debuggera. Po najechaniu panel przekazuje do
          Neovima wyłącznie kontrolowane zdarzenia klawiatury i myszy, więc można
          normalnie kontynuować debugowanie.
        </p>
      </section>
    </div>
  );
}

function App({ model }: { model: CardModel }) {
  const viewerChromeRef = useRef<HTMLDivElement>(null);
  const clientIdRef = useRef(
    `browser-${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`,
  );
  const viewerRevisionRef = useRef(0);
  const navigationRevisionRef = useRef(0);
  const applyingRemoteScrollRef = useRef(false);
  const pendingRemoteNavigationReportRef = useRef(false);
  const remoteScrollFrameRef = useRef(0);
  const remoteScrollTokenRef = useRef(0);
  const shortcutStatusTimerRef = useRef(0);
  const [scale, setScaleState] = useState(2.18);
  const [nvimVisible, setNvimVisibleState] = useState(
    () => localStorage.getItem("stem-card-nvim-visible") !== "false",
  );
  const [nvimSyncMode, setNvimSyncModeState] = useState(
    () => localStorage.getItem("stem-card-nvim-sync-mode") === "true",
  );
  const [nvimControlMode, setNvimControlModeState] = useState(
    () => localStorage.getItem("stem-card-nvim-sync-mode") === "true"
      && localStorage.getItem("stem-card-nvim-control-mode") === "true",
  );
  const [nvimExpanded, setNvimExpandedState] = useState(false);
  const [nvimFit, setNvimFitState] = useState(false);
  const [allocatorVisible, setAllocatorVisibleState] = useState(
    () => localStorage.getItem("stem-card-allocator-visible") !== "false",
  );
  const [nvimSummary, setNvimSummary] = useState<NvimPanelSummary>({
    state: "hidden",
    syncMode: false,
    syncReady: false,
    controlMode: false,
    controlEnabled: false,
  });
  const [progress, setProgress] = useState<Progress | null>(null);
  const [progressError, setProgressError] = useState("");
  const [shortcutsOpen, setShortcutsOpen] = useState(false);
  const [shortcutStatus, setShortcutStatus] = useState("");
  const showShortcutStatus = useCallback((message: string) => {
    window.clearTimeout(shortcutStatusTimerRef.current);
    setShortcutStatus(message);
    shortcutStatusTimerRef.current = window.setTimeout(() => setShortcutStatus(""), 4200);
  }, []);
  const runWebFunctionKey = useCallback(async (key: "F1" | "F2") => {
    showShortcutStatus(`${key} · wykonywanie…`);
    try {
      const endpoint = key === "F1" ? "/api/nvim/reset" : "/api/navigation/sync";
      const response = await fetch(endpoint, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ actor: clientIdRef.current }),
      });
      const payload = await response.json().catch(() => ({}));
      if (!response.ok) throw new Error(payload.message ?? payload.error ?? `HTTP ${response.status}`);
      if (key === "F2" && payload.current) {
        navigationRevisionRef.current = Math.max(
          navigationRevisionRef.current,
          Number(payload.current.revision ?? 0),
        );
        window.dispatchEvent(new CustomEvent<ApiNavigation>("stem:navigation-command", {
          detail: payload.current,
        }));
        showShortcutStatus(
          `F2 · ${String(payload.current.step.number).padStart(2, "0")} ${payload.current.step.label} · READY`,
        );
      } else {
        showShortcutStatus("F1 · reset przyjęty");
      }
      window.setTimeout(() => window.dispatchEvent(new Event("stem:nvim-refresh")), 120);
    } catch (error) {
      showShortcutStatus(`${key} · ${error instanceof Error ? error.message : String(error)}`);
    }
  }, [showShortcutStatus]);
  const runWebRedraw = useCallback(async () => {
    showShortcutStatus("Ctrl+` · przeliczanie widoku…");
    try {
      const response = await fetch("/api/nvim/redraw", {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ actor: clientIdRef.current }),
      });
      const payload = await response.json().catch(() => ({}));
      if (!response.ok) throw new Error(payload.message ?? payload.error ?? `HTTP ${response.status}`);
      const grid = payload.external_ui?.grid;
      const dimensions = grid ? ` · ${grid.width}×${grid.height}` : "";
      showShortcutStatus(`Ctrl+\` · widok odświeżony${dimensions}`);
      window.dispatchEvent(new Event("stem:nvim-refresh"));
    } catch (error) {
      showShortcutStatus(`Ctrl+\` · ${error instanceof Error ? error.message : String(error)}`);
    }
  }, [showShortcutStatus]);
  const publishViewer = useCallback(async (viewer: Record<string, unknown>) => {
    try {
      const response = await fetch("/api/viewer/state", {
        method: "PUT",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ actor: clientIdRef.current, viewer }),
      });
      if (!response.ok) return;
      const payload = await response.json();
      viewerRevisionRef.current = Math.max(
        viewerRevisionRef.current,
        Number(payload.viewer?.revision ?? 0),
      );
    } catch {
      // Statyczny HTML pozostaje użyteczny bez lokalnego API sterowania.
    }
  }, []);
  const setScale = (value: number) => {
    const next = Math.max(0.25, Math.min(3, value));
    setScaleState(next);
    void publishViewer({ scale: next });
  };
  useEffect(() => {
    document.documentElement.style.setProperty(
      "--viewer-canvas-scale",
      scale.toFixed(3),
    );
  }, [scale]);
  useEffect(() => {
    document.documentElement.dataset.nvimSyncMode = nvimSyncMode ? "on" : "off";
  }, [nvimSyncMode]);
  useEffect(() => {
    document.documentElement.dataset.cardClientId = clientIdRef.current;
  }, []);
  const setNvimVisible = (value: boolean) => {
    setNvimVisibleState(value);
    if (!value) {
      setNvimExpandedState(false);
      setNvimFitState(false);
    }
    localStorage.setItem("stem-card-nvim-visible", String(value));
    void publishViewer({
      nvim: {
        visible: value,
        ...(value ? {} : { expanded: false, fit: false }),
      },
    });
  };
  const setNvimControlMode = (value: boolean) => {
    const next = value && nvimSyncMode;
    setNvimControlModeState(next);
    localStorage.setItem("stem-card-nvim-control-mode", String(next));
    void publishViewer({ nvim: { control: next } });
  };
  const setNvimSyncMode = (value: boolean) => {
    setNvimSyncModeState(value);
    localStorage.setItem("stem-card-nvim-sync-mode", String(value));
    if (!value) {
      setNvimControlModeState(false);
      localStorage.setItem("stem-card-nvim-control-mode", "false");
    }
    void publishViewer({ nvim: { sync: value, control: value ? nvimControlMode : false } });
  };
  const setNvimExpanded = (value: boolean) => {
    if (value) setNvimVisible(true);
    if (value) setNvimFitState(false);
    setNvimExpandedState(value);
    void publishViewer({ nvim: { visible: value || nvimVisible, expanded: value, fit: false } });
  };
  const setNvimFit = (value: boolean) => {
    if (value) {
      setNvimVisible(true);
      setNvimExpandedState(false);
    }
    setNvimFitState(value);
    void publishViewer({ nvim: { visible: value || nvimVisible, fit: value, expanded: false } });
  };
  const setAllocatorVisible = (value: boolean) => {
    setAllocatorVisibleState(value);
    localStorage.setItem("stem-card-allocator-visible", String(value));
    void publishViewer({ allocator: { visible: value } });
  };
  useEffect(() => {
    let second = 0;
    const first = window.requestAnimationFrame(() => {
      second = window.requestAnimationFrame(() => {
        window.dispatchEvent(new Event("stem:nvim-refresh"));
      });
    });
    return () => {
      window.cancelAnimationFrame(first);
      window.cancelAnimationFrame(second);
    };
  }, [nvimExpanded, nvimFit]);
  useEffect(() => {
    fetch("/api/progress")
      .then((response) =>
        response.ok
          ? response.json()
          : Promise.reject(new Error(`HTTP ${response.status}`)),
      )
      .then((data) => setProgress(data.progress))
      .catch((error) =>
        setProgressError(
          `Postęp działa tylko przez serwer karty (${error.message}).`,
        ),
      );
  }, []);
  useEffect(() => {
    let cancelled = false;
    let initialised = false;
    const initialViewer = {
      scale,
      viewport: { width: window.innerWidth, height: window.innerHeight },
      nvim: {
        visible: nvimVisible,
        sync: nvimSyncMode,
        control: nvimControlMode,
        expanded: nvimExpanded,
        fit: nvimFit,
      },
      allocator: { visible: allocatorVisible },
    };
    const applyViewer = (viewer: ApiViewerState) => {
      const fit = viewer.nvim.visible && viewer.nvim.fit;
      const expanded = viewer.nvim.visible && !fit && viewer.nvim.expanded;
      setScaleState(Math.max(0.25, Math.min(3, viewer.scale)));
      setNvimVisibleState(viewer.nvim.visible);
      setNvimSyncModeState(viewer.nvim.sync);
      setNvimControlModeState(viewer.nvim.sync && viewer.nvim.control);
      setNvimExpandedState(expanded);
      setNvimFitState(fit);
      const allocator = viewer.allocator?.visible ?? true;
      setAllocatorVisibleState(allocator);
      localStorage.setItem("stem-card-nvim-visible", String(viewer.nvim.visible));
      localStorage.setItem("stem-card-nvim-sync-mode", String(viewer.nvim.sync));
      localStorage.setItem(
        "stem-card-nvim-control-mode",
        String(viewer.nvim.sync && viewer.nvim.control),
      );
      localStorage.setItem("stem-card-allocator-visible", String(allocator));
      const token = ++remoteScrollTokenRef.current;
      applyingRemoteScrollRef.current = true;
      window.cancelAnimationFrame(remoteScrollFrameRef.current);
      remoteScrollFrameRef.current = window.requestAnimationFrame(() => {
        if (token !== remoteScrollTokenRef.current) return;
        const root = documentScroller();
        setScrollPositionInstant(root, {
          left: viewer.scroll.x,
          top: viewer.scroll.y,
        });

        const sheets = Array.from(
          document.querySelectorAll<HTMLElement>(".paper > .sheet"),
        );
        const sheet = sheets[Math.max(
          0,
          Math.min(sheets.length - 1, viewer.scroll.page_index),
        )];
        const sheetContent = sheet?.querySelector<HTMLElement>(".sheet-content");
        if (
          sheetContent
          && Number.isFinite(viewer.scroll.sheet_scroll_top)
        ) {
          setScrollPositionInstant(sheetContent, {
            top: viewer.scroll.sheet_scroll_top ?? 0,
          });
        }
        remoteScrollFrameRef.current = window.requestAnimationFrame(() => {
          if (token === remoteScrollTokenRef.current) {
            applyingRemoteScrollRef.current = false;
            if (pendingRemoteNavigationReportRef.current) {
              pendingRemoteNavigationReportRef.current = false;
              window.dispatchEvent(new Event("stem:viewer-scroll-settled"));
            }
          }
        });
      });
    };
    const poll = async () => {
      try {
        const response = await fetch("/api/control/state", { cache: "no-store" });
        if (!response.ok || cancelled) return;
        const payload = await response.json();
        const viewer = payload.viewer as ApiViewerState;
        if (!initialised) {
          initialised = true;
          if (Number(viewer?.revision ?? 0) === 0) void publishViewer(initialViewer);
        }
        if (viewer && viewer.revision > viewerRevisionRef.current) {
          viewerRevisionRef.current = viewer.revision;
          if (viewer.actor !== clientIdRef.current) applyViewer(viewer);
        }
        const navigation = payload.navigation as ApiNavigation | null;
        if (navigation && navigation.revision > navigationRevisionRef.current) {
          navigationRevisionRef.current = navigation.revision;
          if (navigation.actor !== clientIdRef.current) {
            if (applyingRemoteScrollRef.current) {
              pendingRemoteNavigationReportRef.current = true;
            }
            window.dispatchEvent(new CustomEvent<ApiNavigation>("stem:navigation-command", {
              detail: navigation,
            }));
          }
        }
      } catch {
        // Tryb statyczny nie ma API; lokalne skróty nadal pozostają dostępne.
      }
    };
    void poll();
    const timer = window.setInterval(poll, 300);
    return () => {
      cancelled = true;
      window.clearInterval(timer);
      remoteScrollTokenRef.current += 1;
      window.cancelAnimationFrame(remoteScrollFrameRef.current);
      applyingRemoteScrollRef.current = false;
      pendingRemoteNavigationReportRef.current = false;
    };
  }, []);
  useEffect(() => {
    void publishViewer({
      nvim: {
        connection: nvimSummary.state,
        screen_cursor: nvimSummary.screenCursor,
        grid: nvimSummary.grid,
      },
    });
  }, [nvimSummary.grid?.columns, nvimSummary.grid?.rows, nvimSummary.screenCursor?.column, nvimSummary.screenCursor?.row, nvimSummary.state, publishViewer]);
  useEffect(() => {
    let timer = 0;
    let pendingSheetContent: HTMLElement | null = null;
    const sheets = Array.from(
      document.querySelectorAll<HTMLElement>(".paper > .sheet"),
    );
    const sheetContents = sheets
      .map(sheet => sheet.querySelector<HTMLElement>(".sheet-content"))
      .filter((sheet): sheet is HTMLElement => Boolean(sheet));
    const report = (event?: Event) => {
      if (applyingRemoteScrollRef.current) return;
      const source = event?.currentTarget;
      if (source instanceof HTMLElement && source.classList.contains("sheet-content")) {
        pendingSheetContent = source;
      }
      window.clearTimeout(timer);
      timer = window.setTimeout(() => {
        const sourceSheet = pendingSheetContent?.closest<HTMLElement>(".sheet");
        const sourceIndex = sourceSheet ? sheets.indexOf(sourceSheet) : -1;
        const pageIndex = sourceIndex >= 0
          ? sourceIndex
          : sheets.reduce((best, sheet, index) => {
              const bounds = sheet.getBoundingClientRect();
              const distance = Math.abs(
                (bounds.top + bounds.bottom) / 2 - window.innerHeight / 2,
              );
              return distance < best.distance ? { index, distance } : best;
            }, { index: 0, distance: Number.POSITIVE_INFINITY }).index;
        const sheetContent = sourceIndex >= 0
          ? pendingSheetContent
          : sheets[pageIndex]?.querySelector<HTMLElement>(".sheet-content") ?? null;
        void publishViewer({
          scroll: {
            x: window.scrollX,
            y: window.scrollY,
            page_index: pageIndex,
            sheet_scroll_top: sheetContent?.scrollTop ?? 0,
          },
          viewport: { width: window.innerWidth, height: window.innerHeight },
        });
        pendingSheetContent = null;
      }, 120);
    };
    window.addEventListener("scroll", report, { passive: true });
    window.addEventListener("resize", report);
    window.addEventListener("stem:viewer-scroll-settled", report);
    sheetContents.forEach(sheet => sheet.addEventListener("scroll", report, { passive: true }));
    report();
    return () => {
      window.clearTimeout(timer);
      window.removeEventListener("scroll", report);
      window.removeEventListener("resize", report);
      window.removeEventListener("stem:viewer-scroll-settled", report);
      sheetContents.forEach(sheet => sheet.removeEventListener("scroll", report));
    };
  }, [publishViewer]);
  useEffect(() => {
    const navigate = (event: KeyboardEvent) => {
      if (event.repeat) return;
      const browserCommand = event.altKey && !event.ctrlKey && !event.metaKey
        ? event.code
        : "";
      if (browserCommand === "Digit1") {
        event.preventDefault();
        event.stopImmediatePropagation();
        setNvimVisible(!nvimVisible);
        return;
      }
      if (browserCommand === "Digit2") {
        event.preventDefault();
        event.stopImmediatePropagation();
        setNvimSyncMode(!nvimSyncMode);
        return;
      }
      if (browserCommand === "Digit3") {
        event.preventDefault();
        event.stopImmediatePropagation();
        if (!nvimSyncMode) return;
        setNvimVisible(true);
        setNvimControlMode(!nvimControlMode);
        return;
      }
      if (browserCommand === "Digit4") {
        event.preventDefault();
        event.stopImmediatePropagation();
        setNvimExpanded(!nvimExpanded);
        return;
      }
      if (browserCommand === "Digit5") {
        event.preventDefault();
        event.stopImmediatePropagation();
        setNvimFit(!nvimFit);
        return;
      }
      if (browserCommand === "Digit6") {
        event.preventDefault();
        event.stopImmediatePropagation();
        setAllocatorVisible(!allocatorVisible);
        return;
      }
      if ((event.key === "F1" || event.key === "F2")
        && !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
        event.preventDefault();
        event.stopImmediatePropagation();
        void runWebFunctionKey(event.key);
        return;
      }
      if (event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey
        && (event.code === "Backquote" || event.key === "`")) {
        event.preventDefault();
        event.stopImmediatePropagation();
        void runWebRedraw();
        return;
      }
      if (event.key === "F12") {
        event.preventDefault();
        event.stopImmediatePropagation();
        setShortcutsOpen(current => !current);
        return;
      }
      if (event.key === "Escape" && shortcutsOpen) {
        event.preventDefault();
        setShortcutsOpen(false);
        return;
      }
    };
    window.addEventListener("keydown", navigate, true);
    return () => window.removeEventListener("keydown", navigate, true);
  }, [allocatorVisible, nvimControlMode, nvimExpanded, nvimFit, nvimSyncMode, nvimVisible, runWebFunctionKey, runWebRedraw, shortcutsOpen]);
  useEffect(() => () => window.clearTimeout(shortcutStatusTimerRef.current), []);
  useEffect(() => {
    const wheel = (event: WheelEvent) => {
      if (!event.altKey || event.ctrlKey) return;
      event.preventDefault();
      setScaleState((current) => {
        const next = Math.max(
          0.25,
          Math.min(
            3,
            current *
              Math.exp(-Math.max(-120, Math.min(120, event.deltaY)) * 0.001),
          ),
        );
        void publishViewer({ scale: next });
        return next;
      });
    };
    window.addEventListener("wheel", wheel, { passive: false });
    return () => window.removeEventListener("wheel", wheel);
  }, [publishViewer]);
  const setStatus: SetProgressStatus = async (id, status) => {
    try {
      const response = await fetch(`/api/progress/${encodeURIComponent(id)}`, {
        method: "PUT",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ status }),
      });
      if (!response.ok) throw new Error(await response.text());
      setProgress((await response.json()).progress);
      setProgressError("");
    } catch (error) {
      setProgressError(
        `Nie zapisano postępu: ${error instanceof Error ? error.message : String(error)}`,
      );
    }
  };
  const cycle = (id: string, current: Status) => {
    const next: Record<Status, Status> = {
      pending: "approved",
      approved: "pending",
    };
    void setStatus(id, next[current]);
  };
  let figureCount = 0;
  return (
    <>
      <div
        ref={viewerChromeRef}
        className={`viewer-chrome${nvimExpanded ? " is-nvim-expanded" : ""}${nvimFit ? " is-nvim-fit" : ""}`}
      >
        <Topbar
          toc={model.toc}
          scale={scale}
          setScale={setScale}
          nvimVisible={nvimVisible}
          setNvimVisible={setNvimVisible}
          nvimSyncMode={nvimSyncMode}
          setNvimSyncMode={setNvimSyncMode}
          nvimControlMode={nvimControlMode}
          setNvimControlMode={setNvimControlMode}
          nvimExpanded={nvimExpanded}
          setNvimExpanded={setNvimExpanded}
          nvimFit={nvimFit}
          setNvimFit={setNvimFit}
          allocatorVisible={allocatorVisible}
          setAllocatorVisible={setAllocatorVisible}
          nvimSummary={nvimSummary}
        />
        <AllocatorPanel visible={allocatorVisible} model={model} />
        <NeovimPanel
          visible={nvimVisible}
          syncMode={nvimSyncMode}
          controlMode={nvimControlMode}
          expanded={nvimExpanded}
          fit={nvimFit}
          scale={scale}
          onSummaryChange={setNvimSummary}
        />
        {shortcutStatus && <output className="viewer-action-toast">{shortcutStatus}</output>}
      </div>
      <main className="paper">
        <FrontPage front={model.front} />
        {model.sections.map((section) => {
          const offset = figureCount;
          figureCount += section.assets.length;
          return (
            <SectionPage
              key={section.id}
              section={section}
              progress={progress}
              onCycle={cycle}
              onSetStatus={setStatus}
              figureOffset={offset}
            />
          );
        })}
        {model.dictionary && (
          <Sheet
            header={model.dictionary.header_html}
            className="dictionary-sheet"
          >
            <h2>Słownik WE/EN/EK/KW</h2>
            <Html html={model.dictionary.content_html} />
          </Sheet>
        )}
      </main>
      <ProgressDock progress={progress} error={progressError} />
      <ShortcutHelp open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
    </>
  );
}

async function start() {
  const root = createRoot(document.getElementById("root")!);
  try {
    const response = await fetch("./card-data.json", { cache: "no-store" });
    if (!response.ok)
      throw new Error(`Nie można wczytać card-data.json: ${response.status}`);
    const model = (await response.json()) as CardModel;
    root.render(<App model={model} />);
  } catch (error) {
    root.render(
      <main className="app-load-error">
        <h1>Nie udało się uruchomić karty</h1>
        <pre>{error instanceof Error ? error.message : String(error)}</pre>
      </main>,
    );
  }
}

void start();
