Files
card-layouts/react/src/main.tsx
T
2026-07-17 18:08:24 +02:00

3709 lines
129 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();