From fc357bc1903990f80f5fd967448bce0e890e8186 Mon Sep 17 00:00:00 2001 From: user Date: Thu, 16 Jul 2026 18:18:52 +0200 Subject: [PATCH] feat: add live Neovim checkpoint views --- react/src/main.tsx | 1811 ++++++++++++++++++++++++++++++- react/src/react.css | 833 ++++++++++++++ schemas/card-source.schema.json | 351 ++++++ tools/render_card.py | 45 +- 4 files changed, 3035 insertions(+), 5 deletions(-) diff --git a/react/src/main.tsx b/react/src/main.tsx index e779c36..e59aada 100644 --- a/react/src/main.tsx +++ b/react/src/main.tsx @@ -18,13 +18,118 @@ type UmlStage = { 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; + 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; @@ -79,6 +184,34 @@ type CardModel = { dictionary: { header_html: string; content_html: string }; toc: { id: string; label: string }[]; }; +type NvimPanelSummary = { + state: + | "hidden" + | "connecting" + | "connected" + | "replaying" + | "verifying" + | "ready" + | "failed" + | "offline" + | "unselected"; + message?: string; + instance?: string; + detail?: string; + controlEnabled: boolean; +}; + +const nvimStatusLabel: Record = { + 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 Html = ({ html, className }: { html: string; className?: string }) => (
@@ -120,10 +253,16 @@ function Topbar({ toc, scale, setScale, + nvimVisible, + setNvimVisible, + nvimSummary, }: { toc: CardModel["toc"]; scale: number; setScale: (value: number) => void; + nvimVisible: boolean; + setNvimVisible: (value: boolean) => void; + nvimSummary: NvimPanelSummary; }) { return (
+
+ + {nvimVisible && ( + <> + + {nvimStatusLabel[nvimSummary.state]} + + + {nvimSummary.instance ?? "Neovim MCP"} + {nvimSummary.detail && {nvimSummary.detail}} + + + {nvimSummary.controlEnabled ? "STEROWANIE" : "PODGLĄD"} + + + )} +
Płótno {Math.round(scale * 100)}% · Treść 100% @@ -151,6 +313,873 @@ function Topbar({ ); } +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; + foreground: number; + background: number; + cursor: { row: number; column: number }; +}; +type ActiveNvimStep = { + block: { id?: string; label?: string } | null; + phase: { id: string; label: string }; + step: UmlSequenceStep; + activate?: boolean; +}; +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>; + 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 { + 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 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 foreground = xterm256(update[3]) ?? Number(update[0]); + const background = xterm256(update[4]) ?? Number(update[1]); + if (Number.isFinite(foreground) && foreground >= 0) model.foreground = foreground; + if (Number.isFinite(background) && background >= 0) model.background = background; + } else if (name === "hl_attr_define") { + const id = Number(update[0]); + const attributes = ctermHighlight(update[2]) ?? 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 matchingTextColumn(line: NvimCell[], text: string, occurrence = 1) { + const content = line.map(cell => cell.text || " ").join(""); + let from = 0; + let found = -1; + for (let index = 0; index < Math.max(1, occurrence); index += 1) { + found = content.indexOf(text, from); + if (found < 0) return -1; + from = found + Math.max(1, text.length); + } + return found; +} + +function drawNvimGrid( + canvas: HTMLCanvasElement, + model: NvimGridModel, + annotations: NvimAnnotation[], +) { + const availableWidth = canvas.parentElement?.clientWidth ?? model.width * 10; + const cellWidth = Math.max(8, Math.min(12, availableWidth / model.width)); + const fontSize = Math.max(15, Math.min(19, cellWidth * 1.65)); + const cellHeight = Math.max(21, Math.min(25, cellWidth * 2.18)); + const ratio = Math.max(1, window.devicePixelRatio || 1); + const width = model.width * cellWidth; + const height = model.height * 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(model.height); + const context = canvas.getContext("2d"); + if (!context) return; + 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 row = 0; row < model.height; row += 1) { + 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, row * 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, row * cellHeight + cellHeight * 0.76); + } + if (highlight.underline || highlight.undercurl || highlight.strikethrough) { + context.strokeStyle = nvimColor(highlight.special, foreground); + context.lineWidth = 1; + const y = row * cellHeight + (highlight.strikethrough ? cellHeight * 0.5 : cellHeight - 2); + context.beginPath(); + context.moveTo(column * cellWidth, y); + context.lineTo((column + 1) * cellWidth, y); + context.stroke(); + } + } + } + + context.strokeStyle = "rgba(255,255,255,.72)"; + context.lineWidth = 1; + context.strokeRect( + model.cursor.column * cellWidth + 0.5, + model.cursor.row * cellHeight + 0.5, + cellWidth - 1, + cellHeight - 1, + ); + + 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) { + row = -1; + for (let candidate = 0; candidate < model.height; candidate += 1) { + const match = matchingTextColumn( + model.cells[candidate], + anchor.text, + anchor.occurrence, + ); + if (match >= 0) { + row = candidate; + column = match; + columns = anchor.text.length; + break; + } + } + } + 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) { + if (event.nativeEvent.isComposing || event.metaKey) return null; + const special: Record = { + 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 === "<" ? "" : event.key; +} + +function NeovimPanel({ + visible, + onSummaryChange, +}: { + visible: boolean; + onSummaryChange: (summary: NvimPanelSummary) => void; +}) { + const canvasRef = useRef(null); + const screenRef = useRef(null); + const websocketRef = useRef(null); + const gridRef = useRef(createNvimGrid()); + const activeStepRef = useRef(null); + const presentedSnapshotRef = useRef(null); + const checkpointGenerationRef = useRef(0); + const checkpointRequestRef = useRef(null); + const resizeStartRef = useRef<{ y: number; heightVh: number } | null>(null); + const [activeStep, setActiveStep] = useState(null); + const [hasGrid, setHasGrid] = useState(false); + 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({ state: "hidden" }); + const [checkpoint, setCheckpoint] = useState({ state: "idle" }); + const checkpointBusy = checkpoint.state === "replaying" || checkpoint.state === "verifying"; + const controlEnabled = pointerInside && status.state === "connected" && !checkpointBusy; + + const redraw = () => { + if (!canvasRef.current) return; + drawNvimGrid( + canvasRef.current, + gridRef.current, + presentedSnapshotRef.current === activeStepRef.current?.step.snapshot_ref + ? activeStepRef.current?.step.view?.annotations ?? [] + : [], + ); + }; + + useEffect(() => { + const selected = (event: Event) => { + const detail = (event as CustomEvent).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}.`); + } + presentedSnapshotRef.current = snapshotRef; + setCheckpoint({ + state: "ready", + phase: result.phase, + generation, + snapshot_ref: snapshotRef, + message: result.message ?? "Stan maszyny został odtworzony i zweryfikowany.", + }); + window.setTimeout(() => { + if (checkpointGenerationRef.current !== generation) return; + saveNvimGrid(snapshotRef, gridRef.current); + redraw(); + }, 180); + }) + .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(() => { + if (!visible) return; + let lastSize = ""; + let lastSentAt = 0; + let lastSocket: WebSocket | null = null; + const requestResize = () => { + const screen = screenRef.current; + const socket = websocketRef.current; + if (!screen || socket?.readyState !== WebSocket.OPEN) return; + if (socket !== lastSocket) { + lastSocket = socket; + lastSize = ""; + } + const columns = Math.max(40, Math.min(400, Math.floor(screen.clientWidth / 10))); + const rows = Math.max(8, Math.min(200, Math.floor(screen.clientHeight / 22))); + const size = `${columns}x${rows}`; + const now = performance.now(); + if (size === lastSize && now - lastSentAt < 2000) return; + lastSize = size; + lastSentAt = now; + socket.send(JSON.stringify({ type: "resize", columns, rows })); + }; + const observer = new ResizeObserver(requestResize); + if (screenRef.current) observer.observe(screenRef.current); + const retry = window.setInterval(requestResize, 500); + requestResize(); + return () => { + observer.disconnect(); + window.clearInterval(retry); + }; + }, [visible]); + + 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(" · "), + controlEnabled, + }); + }, [activeStep, checkpoint, controlEnabled, onSummaryChange, status]); + + useEffect(() => { + if (!controlEnabled) return; + const protectBrowserShortcuts = (event: KeyboardEvent) => { + const socket = websocketRef.current; + if (socket?.readyState !== WebSocket.OPEN) return; + if (event.ctrlKey && !event.altKey && !event.metaKey && event.key.toLowerCase() === "w") { + event.preventDefault(); + event.stopImmediatePropagation(); + if (!event.repeat) socket.send(JSON.stringify({ type: "input", keys: "" })); + return; + } + const directions: Record = { + 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: `${directions[event.key]}` })); + } + } + }; + window.addEventListener("keydown", protectBrowserShortcuts, true); + return () => window.removeEventListener("keydown", protectBrowserShortcuts, true); + }, [controlEnabled]); + + useEffect(() => { + if (!visible) { + websocketRef.current?.close(); + websocketRef.current = null; + setStatus({ 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") { + 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); + redraw(); + } + } 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); + websocketRef.current?.close(); + websocketRef.current = null; + }; + }, [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) => { + 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), + )), + }; + }; + return ( +
+
{ + 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 }); + }} + > + { + 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: "", ...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: "", ...point }); + }} + onContextMenu={event => controlEnabled && event.preventDefault()} + /> + {!hasGrid && ( +
+ {nvimStatusLabel[status.state]} + {status.message ?? "Oczekiwanie na ekran Neovima lub zapisany stan kroku."} +
+ )} +
+
{ + 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); + } + }} + /> +
+ ); +} + function FrontPage({ front }: { front: any }) { return (
{label} @@ -234,7 +1264,7 @@ function TaskBlock({ taskRef, block }: { taskRef: string; block: FlowBlock }) { function TaskFlow({ task }: { task: Task }) { if (!task.flow.length) return ( -
+
{task.ref.toUpperCase()}
@@ -243,7 +1273,7 @@ function TaskFlow({ task }: { task: Task }) {
); return ( -
+
{task.ref.toUpperCase()}

{task.title}

@@ -397,7 +1427,7 @@ function InteractiveMemoryFlow({ ); } -function InteractiveUmlSequence({ +function LegacyInteractiveUmlStages({ config, asset, progress, @@ -672,6 +1702,671 @@ function InteractiveUmlSequence({ ); } +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 viewStorageKey = `${config.storage_key ?? asset.label}-evidence-view`; + const rootRef = useRef(null); + const canvasRef = useRef(null); + const activateOnChangeRef = useRef(false); + const [svgMarkup, setSvgMarkup] = useState(""); + const [svgFailed, setSvgFailed] = useState(false); + const [approvalBusy, setApprovalBusy] = useState(false); + const [evidenceView, setEvidenceView] = useState<"code" | "terminal">(() => + localStorage.getItem(viewStorageKey) === "terminal" ? "terminal" : "code", + ); + const [stepIndex, setStepIndex] = useState(() => + Math.max( + 0, + Math.min( + entries.length - 1, + Number(localStorage.getItem(storageKey) ?? 0), + ), + ), + ); + + useEffect( + () => localStorage.setItem(storageKey, String(stepIndex)), + [stepIndex, storageKey], + ); + useEffect( + () => localStorage.setItem(viewStorageKey, evidenceView), + [evidenceView, viewStorageKey], + ); + 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("[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("text")); + const lines = Array.from(svg.querySelectorAll("line")); + const polygons = Array.from(svg.querySelectorAll("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 === "completed", + ); + 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", index === stepIndex); + hit.classList.toggle("uml-step-hit-completed", completed); + svg.appendChild(hit); + }); + }, [asset.alt, entries, progress, stepIndex, svgMarkup]); + + const publishEntry = ( + entry: { phase: UmlSequencePhase; step: UmlSequenceStep }, + activate: boolean, + ) => { + window.dispatchEvent(new CustomEvent("stem:nvim-step", { + detail: { + block: config.block ?? null, + phase: { id: entry.phase.id, label: entry.phase.label }, + step: entry.step, + activate, + }, + })); + }; + const selectIndex = (next: number, activate = true) => { + const bounded = Math.max(0, Math.min(entries.length - 1, next)); + const entry = entries[bounded]; + if (!entry) return; + if (bounded === stepIndex) { + publishEntry(entry, activate); + return; + } + activateOnChangeRef.current = activate; + setStepIndex(bounded); + }; + const selectStepAtPoint = (clientX: number, clientY: number) => { + const hits = Array.from( + canvasRef.current?.querySelectorAll( + ".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); + }; + const handleSvgKeyDown = (event: React.KeyboardEvent) => { + 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); + }; + const selectPhase = (phase: UmlSequencePhase) => { + const next = entries.findIndex((entry) => entry.phase.id === phase.id); + if (next >= 0) selectIndex(next); + }; + + const active = entries[stepIndex]; + useEffect(() => { + if (!active) return; + publishEntry(active, activateOnChangeRef.current); + activateOnChangeRef.current = false; + }, [active?.phase.id, active?.step.id, config.block]); + useEffect(() => { + const navigate = (event: KeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + if (event.metaKey) return; + const target = event.target as HTMLElement | null; + if (target?.closest("input, textarea, select, [contenteditable='true']")) return; + if (document.activeElement?.closest(".nvim-screen-scroll[data-control='true']")) return; + const root = rootRef.current; + if (!root) return; + const diagrams = Array.from( + document.querySelectorAll("[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; + const direction = event.key === "ArrowLeft" ? -1 : 1; + + if (event.ctrlKey && event.shiftKey) { + const snapshots = entries.filter((entry, index) => + Boolean(entry.step.snapshot_ref) + && entries.findIndex(candidate => + candidate.step.snapshot_ref === entry.step.snapshot_ref, + ) === index, + ); + const current = snapshots.findIndex(entry => + entry.step.snapshot_ref === active?.step.snapshot_ref, + ); + const next = snapshots[Math.max(0, Math.min(snapshots.length - 1, current + direction))]; + if (!next) return; + event.preventDefault(); + event.stopPropagation(); + selectIndex(entries.indexOf(next)); + return; + } + if (event.ctrlKey || event.altKey) return; + event.preventDefault(); + event.stopPropagation(); + if (event.shiftKey) { + const current = phases.findIndex(item => item.id === active?.phase.id); + const next = phases[Math.max(0, Math.min(phases.length - 1, current + direction))]; + if (next) selectPhase(next); + } else { + selectIndex(stepIndex + direction); + } + }; + window.addEventListener("keydown", navigate); + return () => window.removeEventListener("keydown", navigate); + }, [active?.phase.id, active?.step.snapshot_ref, entries, phases, stepIndex]); + if (!active) return null; + const { phase, step } = active; + const snapshot = step.snapshot; + const completed = Boolean( + step.progress_id && + progress?.items?.[step.progress_id]?.status === "completed", + ); + const phaseEntries = entries.filter((entry) => entry.phase.id === phase.id); + const arena = snapshot.memory.arena; + const arenaPercent = arena?.offset == null + ? 0 + : Math.min(100, (arena.offset / arena.size_bytes) * 100); + const terminalView = config.terminal_view; + const visibleEvidenceView = + evidenceView === "terminal" && terminalView?.href ? "terminal" : "code"; + const frameFields = [...snapshot.frame.fields].sort( + (left, right) => + Number.parseInt(left.address, 16) - Number.parseInt(right.address, 16), + ); + const frameStart = Number.parseInt(snapshot.frame.sp, 16); + const frameEnd = Number.parseInt(snapshot.frame.fp, 16); + const frameSegments: Array< + | { kind: "gap"; address: number; sizeBytes: number } + | { kind: "field"; field: UmlSnapshotFrameField; sizeBytes: number } + > = []; + let frameCursor = frameStart; + if (Number.isFinite(frameStart) && Number.isFinite(frameEnd)) { + frameFields.forEach((field) => { + const address = Number.parseInt(field.address, 16); + const sizeBytes = field.size_bytes ?? 4; + if (!Number.isFinite(address) || address < frameStart || address >= frameEnd) return; + if (address > frameCursor) { + frameSegments.push({ + kind: "gap", + address: frameCursor, + sizeBytes: address - frameCursor, + }); + } + frameSegments.push({ kind: "field", field, sizeBytes }); + frameCursor = Math.max(frameCursor, address + sizeBytes); + }); + if (frameCursor < frameEnd) { + frameSegments.push({ + kind: "gap", + address: frameCursor, + sizeBytes: frameEnd - frameCursor, + }); + } + } + + return ( +
+
+
+ BLOCK · {config.block?.label ?? "PRZEPŁYW UML"} +

{config.title ?? config.block?.label ?? asset.caption}

+ {config.block?.description &&

{config.block.description}

} +
+
+ {phases.map((item) => ( + + ))} +
+
+
+ {phase.label} + {phaseEntries.map((entry) => { + const index = entries.indexOf(entry); + const isDone = Boolean( + entry.step.progress_id && + progress?.items?.[entry.step.progress_id]?.status === "completed", + ); + return ( + + ); + })} +
+
+
selectStepAtPoint(event.clientX, event.clientY)} + onKeyDown={handleSvgKeyDown} + > + {svgMarkup ? ( +
+ ) : ( + {asset.alt} + )} + {svgFailed && ( + + Widok statyczny — wybierz krok przyciskiem nad diagramem. + + )} +
+
+ +
+
+
+ + {visibleEvidenceView === "terminal" + ? "DOWÓD REFERENCYJNY · TERMDEBUG" + : `DOWÓD · KROK ${String(step.number).padStart(2, "0")}`} + + + {visibleEvidenceView === "terminal" + ? terminalView?.title ?? "Termdebug" + : step.label} + +
+
+ + +
+
+ {visibleEvidenceView === "terminal" && terminalView?.href ? ( +
+ {terminalView.alt} + {(terminalView.title || terminalView.caption) && ( +
+ {terminalView.title && {terminalView.title}} + {terminalView.caption && {terminalView.caption}} +
+ )} +
+ ) : ( +
+
+
+ C + {snapshot.code?.source_ref ?? step.code_ref ?? "źródło"} +
+
{snapshot.code?.source ?? step.label}
+
+
+
+ RV32 + pc = {snapshot.pc} +
+
{snapshot.code?.assembly ?? step.code_ref ?? snapshot.pc}
+
+ {snapshot.code?.note &&

{snapshot.code.note}

} +
+ )} +
+ + + {step.progress_id && ( +
+ + {completed + ? "Ten krok jest zatwierdzony i zapisany w JSON-ie zajęć." + : "Kursor wybiera strzałkę; zatwierdzenie zapisuje krok i timestamp."} + + +
+ )} +
+ ); +} + +function InteractiveUmlSequence(props: { + config: InteractiveAsset; + asset: Asset; + progress: Progress | null; + onSetStatus: SetProgressStatus; +}) { + return props.config.phases?.length ? ( + + ) : ( + + ); +} + function AssetFigure({ asset, number, @@ -857,10 +2552,63 @@ function ProgressDock({ ); } +function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void }) { + if (!open) return null; + const rows = [ + ["← / →", "poprzedni / następny STEP"], + ["Shift + ← / →", "poprzednia / następna PHASE"], + ["Ctrl + Shift + ← / →", "poprzedni / następny unikalny SNAPSHOT"], + ["Alt + ← / →", "poprzedni / następny BLOCK"], + ["Ctrl + ← / →", "poprzedni / następny TASK"], + ["F1", "pokaż lub ukryj ten skorowidz"], + ["Esc", "zamknij skorowidz"], + ]; + return ( +
+
event.stopPropagation()} + > +
+
+ F1 · NAWIGACJA DYDAKTYCZNA +

TASK → BLOCK → PHASE → STEP → SNAPSHOT

+
+ +
+
+ {rows.map(([keys, description]) => ( +
+
{keys}
+
{description}
+
+ ))} +
+

+ STEP wybiera strzałkę UML. ONLINE odtwarza przypięty stan maszyny; + OFFLINE pokazuje jego zapisany grid. W aktywnym panelu Neovima skróty + klawiszowe — poza F1 — trafiają do Neovima. +

+
+
+ ); +} + function App({ model }: { model: CardModel }) { const [scale, setScaleState] = useState(2.18); + const [nvimVisible, setNvimVisibleState] = useState( + () => localStorage.getItem("stem-card-nvim-visible") !== "false", + ); + const [nvimSummary, setNvimSummary] = useState({ + state: "hidden", + controlEnabled: false, + }); const [progress, setProgress] = useState(null); const [progressError, setProgressError] = useState(""); + const [shortcutsOpen, setShortcutsOpen] = useState(false); const setScale = (value: number) => setScaleState(Math.max(0.25, Math.min(3, value))); useEffect(() => { @@ -869,6 +2617,10 @@ function App({ model }: { model: CardModel }) { scale.toFixed(3), ); }, [scale]); + const setNvimVisible = (value: boolean) => { + setNvimVisibleState(value); + localStorage.setItem("stem-card-nvim-visible", String(value)); + }; useEffect(() => { fetch("/api/progress") .then((response) => @@ -883,6 +2635,46 @@ function App({ model }: { model: CardModel }) { ), ); }, []); + useEffect(() => { + const navigate = (event: KeyboardEvent) => { + if (event.key === "F1") { + event.preventDefault(); + event.stopImmediatePropagation(); + setShortcutsOpen(current => !current); + return; + } + if (event.key === "Escape" && shortcutsOpen) { + event.preventDefault(); + setShortcutsOpen(false); + return; + } + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + if (document.activeElement?.closest(".nvim-screen-scroll[data-control='true']")) return; + const target = event.target as HTMLElement | null; + if (target?.closest("input, textarea, select, [contenteditable='true']")) return; + const isTask = event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey; + const isBlock = event.altKey && !event.ctrlKey && !event.metaKey; + if (!isTask && !isBlock) return; + const selector = isTask + ? "[data-stem-nav='task']" + : "[data-stem-nav='block'], [data-stem-nav='uml-block']"; + const items = Array.from(document.querySelectorAll(selector)); + if (!items.length) return; + const viewportCenter = window.innerHeight / 2; + const current = items.reduce((best, item, index) => { + const bounds = item.getBoundingClientRect(); + const distance = Math.abs((bounds.top + bounds.bottom) / 2 - viewportCenter); + return distance < best.distance ? { index, distance } : best; + }, { index: 0, distance: Number.POSITIVE_INFINITY }).index; + const direction = event.key === "ArrowLeft" ? -1 : 1; + const next = Math.max(0, Math.min(items.length - 1, current + direction)); + event.preventDefault(); + event.stopPropagation(); + items[next].scrollIntoView({ behavior: "smooth", block: "start" }); + }; + window.addEventListener("keydown", navigate, true); + return () => window.removeEventListener("keydown", navigate, true); + }, [shortcutsOpen]); useEffect(() => { const wheel = (event: WheelEvent) => { if (!event.altKey || event.ctrlKey) return; @@ -928,7 +2720,17 @@ function App({ model }: { model: CardModel }) { let figureCount = 0; return ( <> - +
+ + +
{model.sections.map((section) => { @@ -954,6 +2756,7 @@ function App({ model }: { model: CardModel }) {
+ setShortcutsOpen(false)} /> ); } diff --git a/react/src/react.css b/react/src/react.css index bc70adb..04a9c98 100644 --- a/react/src/react.css +++ b/react/src/react.css @@ -1,3 +1,254 @@ +.viewer-chrome { + position: sticky; + top: 0; + z-index: 80; + width: 100%; +} +.viewer-chrome .topbar { + position: relative; + top: auto; +} +.topbar-nvim-section { + display: flex; + align-items: center; + min-width: 0; + gap: 6px; +} +.topbar-nvim-meta { + display: flex; + align-items: baseline; + min-width: 0; + max-width: min(44vw, 720px); + gap: 6px; +} +.topbar-nvim-meta strong, +.topbar-nvim-meta small { + overflow: auto; + text-overflow: ellipsis; + white-space: nowrap; +} +.topbar-nvim-meta strong { + color: #263238; + font: 700 12px/1.25 system-ui, sans-serif; +} +.topbar-nvim-meta small { + color: #66747a; + font: 500 10px/1.25 system-ui, sans-serif; +} +.viewer-nvim-toggle { + border: 1px solid #68747a; + border-radius: 3px; + background: #fff; + color: #263238; + padding: 2px 8px; + font: 650 16px/1.3 system-ui, sans-serif; + cursor: pointer; +} +.viewer-nvim-toggle[aria-pressed="true"] { + border-color: #267697; + background: #eaf6fb; + color: #174d64; +} +.nvim-panel { + width: 100%; + box-sizing: border-box; + border-bottom: 1px solid #52616a; + background: #11171a; + color: #e7eef1; + box-shadow: 0 5px 16px #0004; + font: 12px/1.3 system-ui, sans-serif; +} +.nvim-status { + flex: none; + border: 1px solid #66757c; + border-radius: 999px; + padding: 1px 6px; + color: #c9d3d8; + font: 750 9px/1.35 ui-monospace, monospace; + letter-spacing: 0.04em; +} +.nvim-status--connected, +.nvim-status--ready { + border-color: #31a86d; + background: #173b2a; + color: #8aefb7; +} +.nvim-status--connecting, +.nvim-status--replaying, +.nvim-status--verifying { + border-color: #c38f2a; + background: #3e321b; + color: #ffd47d; +} +.nvim-status--failed, +.nvim-status--offline, +.nvim-status--unselected { + border-color: #a34b4b; + background: #3b2020; + color: #ffaaaa; +} +.nvim-control-indicator { + flex: none; + border: 1px solid #60747e; + border-radius: 3px; + background: #222e34; + color: #b9c6cc; + padding: 3px 8px; + font: 700 10px/1.2 ui-monospace, monospace; + letter-spacing: 0.04em; +} +.nvim-control-indicator.is-active { + border-color: #3cb3e2; + background: #17465a; + color: #d9f5ff; +} +.nvim-screen-scroll { + position: relative; + width: 100%; + box-sizing: border-box; + height: var(--nvim-panel-height, 75vh); + max-height: none; + min-height: 240px; + border: 2px solid transparent; + overflow: hidden; + background: #101315; + outline: none; + scrollbar-color: #58666d #171d20; +} +.nvim-screen-scroll[data-control="true"] { + border-color: #35b9ef; +} +.nvim-screen { + display: block; + max-width: none; + margin: 0; + outline: none; + image-rendering: auto; +} +.nvim-screen:focus { + box-shadow: none; +} +.nvim-screen-empty { + position: absolute; + inset: 0; + display: grid; + place-content: center; + gap: 5px; + padding: 16px; + color: #9cabb2; + text-align: center; +} +.nvim-screen-empty strong { + color: #e0e8eb; + font: 750 11px/1.3 ui-monospace, monospace; +} +.nvim-resize-handle { + position: relative; + width: 100%; + height: 12px; + border-top: 1px solid #46565e; + background: #182126; + cursor: ns-resize; + touch-action: none; +} +.nvim-resize-handle::after { + content: ""; + position: absolute; + top: 4px; + left: 50%; + width: min(160px, 22vw); + height: 3px; + border-radius: 999px; + background: #6c7e87; + transform: translateX(-50%); +} +.nvim-resize-handle:hover, +.nvim-resize-handle:focus-visible { + background: #23323a; + outline: none; +} +.nvim-resize-handle:hover::after, +.nvim-resize-handle:focus-visible::after { + background: #35b9ef; +} +.shortcut-help-backdrop { + position: fixed; + z-index: 200; + inset: 0; + display: grid; + place-items: center; + padding: 20px; + background: #10171dcc; +} +.shortcut-help { + width: min(680px, calc(100vw - 40px)); + max-height: calc(100vh - 40px); + overflow: auto; + border: 1px solid #71818a; + border-radius: 6px; + background: #f8fafb; + color: #172127; + box-shadow: 0 18px 60px #0008; + font: 14px/1.4 system-ui, sans-serif; +} +.shortcut-help > header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 16px; + padding: 16px 18px; + border-bottom: 1px solid #c8d1d5; + background: #e8eef1; +} +.shortcut-help header small { + color: #52646d; + font: 750 10px/1.2 ui-monospace, monospace; + letter-spacing: .08em; +} +.shortcut-help h2 { + margin: 4px 0 0; + font-size: 17px; +} +.shortcut-help header button { + width: 32px; + height: 32px; + border: 1px solid #71818a; + border-radius: 3px; + background: #fff; + font: 22px/1 system-ui, sans-serif; + cursor: pointer; +} +.shortcut-help dl { + margin: 0; + padding: 10px 18px; +} +.shortcut-help dl > div { + display: grid; + grid-template-columns: minmax(180px, .8fr) 1.5fr; + gap: 14px; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #dde3e6; +} +.shortcut-help dt, +.shortcut-help dd { + margin: 0; +} +.shortcut-help kbd { + display: inline-block; + border: 1px solid #839198; + border-bottom-width: 2px; + border-radius: 3px; + background: #fff; + padding: 3px 7px; + font: 700 12px/1.2 ui-monospace, monospace; +} +.shortcut-help > p { + margin: 0; + padding: 4px 18px 16px; + color: #526068; +} + .memory-react { border: 1px solid #555; background: #fff; @@ -316,6 +567,550 @@ color: #888; cursor: not-allowed; } +.uml-snapshot-header p { + max-width: 620px; + margin: 2px 0 0; + color: #555; + font-size: 10px; +} +.uml-phase-actions, +.uml-step-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 3px; +} +.uml-phase-actions { + justify-content: flex-end; +} +.uml-phase-actions button, +.uml-step-actions button, +.uml-snapshot-cursor button { + border: 1px solid #aaa; + border-radius: 2px; + background: #fff; + color: #444; + cursor: pointer; + font: 600 9px/1.2 ui-monospace, monospace; +} +.uml-phase-actions button { + padding: 3px 7px; +} +.uml-phase-actions button[aria-pressed="true"] { + border-color: #287495; + background: #edf7fb; + color: #174d64; +} +.uml-step-actions { + min-height: 25px; + padding: 3px 8px; + border-bottom: 1px solid #ddd; + background: #fcfcfc; +} +.uml-step-actions > span { + margin-right: 4px; + color: #555; + font: 700 9px/1.2 ui-monospace, monospace; +} +.uml-step-actions button { + min-width: 25px; + padding: 2px 4px; +} +.uml-step-actions button[data-completed="true"] { + border-color: #62a47f; + background: #edf8f1; + color: #14613a; +} +.uml-step-actions button[aria-pressed="true"] { + border-color: #287495; + background: #287495; + color: #fff; +} +.uml-snapshot-layout { + background: #fff; + border-bottom: 1px solid #ddd; +} +.uml-step-canvas { + min-width: 0; +} +.uml-step-canvas .uml-svg-host svg { + max-height: 720px; +} +.uml-step-hit { + cursor: pointer; + fill: transparent; +} +.uml-step-hit-active { + fill: #3ea6ce !important; + fill-opacity: 0.08 !important; +} +.uml-step-element { + transition: fill 120ms ease, stroke 120ms ease, stroke-width 120ms ease; +} +line.uml-step-element-completed:not(.uml-step-element-active) { + stroke: #258154 !important; + stroke-width: 1.8px !important; +} +polygon.uml-step-element-completed:not(.uml-step-element-active) { + fill: #258154 !important; + stroke: #258154 !important; +} +text.uml-step-element-completed:not(.uml-step-element-active) { + fill: #1d7049 !important; +} +line.uml-step-element-active { + stroke: #087ca8 !important; + stroke-width: 2.6px !important; +} +polygon.uml-step-element-active { + fill: #087ca8 !important; + stroke: #087ca8 !important; +} +text.uml-step-element-active { + fill: #075d7d !important; + font-weight: 700 !important; +} +.uml-step-hit:focus { + outline: none; + stroke: #087ca8; + stroke-width: 1.5px; + stroke-dasharray: 4 3; +} +.uml-evidence-view { + border-bottom: 1px solid #b8b8b8; + background: #f4f5f6; +} +.uml-evidence-view > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 38px; + padding: 5px 8px; + border-bottom: 1px solid #c8c8c8; + background: #fff; +} +.uml-evidence-view > header small, +.uml-evidence-view > header strong { + display: block; +} +.uml-evidence-view > header small { + color: #56707c; + font: 700 8px/1.2 ui-monospace, monospace; +} +.uml-evidence-view > header strong { + margin-top: 1px; + font: 700 11px/1.25 system-ui, sans-serif; +} +.uml-evidence-tabs { + display: flex; + gap: 2px; + padding: 2px; + border: 1px solid #c5c9cb; + border-radius: 4px; + background: #eef0f1; +} +.uml-evidence-tabs button { + min-width: 68px; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: #4b5154; + padding: 3px 10px; + font: 600 10px/1.2 system-ui, sans-serif; + cursor: pointer; +} +.uml-evidence-tabs button[aria-selected="true"] { + border-color: #8da6b1; + background: #fff; + color: #174d64; + box-shadow: 0 1px 2px #0001; +} +.uml-evidence-tabs button:disabled { + opacity: 0.4; + cursor: default; +} +.uml-code-view { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + background: #40505a; +} +.uml-code-view > section { + min-width: 0; + background: #12191e; + color: #e8edf0; +} +.uml-code-view > section > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 5px 8px; + border-bottom: 1px solid #34434b; + color: #a8c9d7; + font: 700 9px/1.2 ui-monospace, monospace; +} +.uml-code-view > section > header span { + border: 1px solid #5b7c8a; + border-radius: 2px; + padding: 1px 5px; + color: #d7f4ff; +} +.uml-code-view > section > header code { + overflow-wrap: anywhere; + color: #aebac0; + text-align: right; +} +.uml-code-view pre { + min-height: 76px; + max-height: 260px; + margin: 0; + padding: 10px; + overflow: auto; + white-space: pre-wrap; + color: #eef4f6; + font: 10px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace; + tab-size: 2; +} +.uml-code-view > p { + grid-column: 1/-1; + margin: 0; + padding: 5px 8px; + background: #eef7fb; + color: #315f78; + font-size: 9px; +} +.uml-terminal-view { + margin: 0; + background: #111; +} +.uml-terminal-view img { + display: block; + width: 100%; + max-height: 760px; + object-fit: contain; + object-position: top center; +} +.uml-terminal-view figcaption { + display: flex; + align-items: baseline; + gap: 8px; + padding: 5px 8px; + border-top: 1px solid #38434a; + background: #1a2227; + color: #cbd4d8; + font: 9px/1.35 system-ui, sans-serif; +} +.uml-terminal-view figcaption strong { + flex: 0 0 auto; + color: #fff; +} +.uml-snapshot-panel { + display: grid; + gap: 8px; + min-width: 0; + padding: 8px; + background: #fafafa; + font-size: 10px; +} +.uml-snapshot-panel > header { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: center; +} +.uml-snapshot-panel > header > div:first-child > span, +.uml-snapshot-panel > header > div:first-child > strong { + display: block; +} +.uml-snapshot-panel > header > div:first-child > span { + color: #5a5a5a; + font: 700 9px/1.2 ui-monospace, monospace; +} +.uml-snapshot-panel > header > div:first-child > strong { + font-size: 12px; +} +.uml-snapshot-panel > header > div:first-child > strong code { + display: inline-block; + min-width: 24px; + color: #075d7d; +} +.uml-snapshot-cursor { + display: flex; + align-items: center; + gap: 4px; +} +.uml-snapshot-cursor button { + width: 23px; + height: 23px; + padding: 0; +} +.uml-snapshot-cursor button:disabled { + opacity: 0.35; + cursor: default; +} +.uml-snapshot-cursor output { + min-width: 34px; + text-align: center; + font: 700 9px/1 ui-monospace, monospace; +} +.uml-snapshot-description { + margin: 0; + color: #333; +} +.uml-snapshot-panel section { + min-width: 0; + border: 1px solid #d0d0d0; + background: #fff; + padding: 7px; +} +.uml-snapshot-panel h4 { + margin: 0 0 4px; + color: #315f78; + font: 700 9px/1.2 ui-monospace, monospace; + text-transform: uppercase; +} +.uml-state-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 7px; + min-width: 0; +} +.uml-snapshot-registers dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px; + margin: 0; +} +.uml-snapshot-registers dl > div { + display: grid; + grid-template-columns: auto 1fr; + gap: 5px; + min-width: 0; + padding: 2px 4px; + border-left: 2px solid #bbb; + background: #f6f6f6; +} +.uml-snapshot-registers dl > div.primary { + grid-column: 1/-1; + border-color: #287495; + background: #edf7fb; +} +.uml-snapshot-registers dt { + color: #555; + font-weight: 700; +} +.uml-snapshot-registers dd, +.uml-snapshot-variables dd, +.uml-snapshot-memory dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; + text-align: right; + font: 600 9px/1.25 ui-monospace, monospace; +} +.uml-snapshot-variables dl { + display: grid; + gap: 3px; + margin: 0; +} +.uml-snapshot-variables dl > div { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 5px; + padding: 3px 5px; + border-left: 2px solid #5f899b; + background: #f5f8f9; +} +.uml-snapshot-variables dt { + font-weight: 700; +} +.uml-snapshot-variables dt small { + display: block; + color: #777; + font: 8px/1.2 ui-monospace, monospace; +} +.uml-snapshot-variables [data-state="uninitialised"], +.uml-stack-lane article[data-state="uninitialised"] { + color: #777; + background: #f5f5f5; +} +.uml-snapshot-variables [data-state="uninitialised"] dt::after, +.uml-stack-lane article[data-state="uninitialised"] > strong::after { + content: " ?"; + color: #9a6a00; +} +.uml-arena-snapshot > div { + position: relative; + height: 10px; + border: 1px solid #666; + background: repeating-linear-gradient(90deg, #fff 0 7px, #eee 7px 8px); +} +.uml-arena-snapshot > div span { + display: block; + height: 100%; + background: #b8ddeb; +} +.uml-arena-snapshot > div i { + position: absolute; + top: -3px; + bottom: -3px; + width: 2px; + background: #075d7d; + transform: translateX(-1px); +} +.uml-arena-snapshot > p { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 4px; + margin: 3px 0; + font-size: 8px; +} +.uml-arena-snapshot > p code:last-child { + text-align: right; +} +.uml-arena-bytes { + display: block; + margin-bottom: 4px; + overflow-wrap: anywhere; + color: #666; + font-size: 8px; +} +.uml-snapshot-memory dl { + display: grid; + gap: 2px; + margin: 0; +} +.uml-snapshot-memory dl > div { + display: grid; + grid-template-columns: 1fr auto; + gap: 4px; + padding-top: 2px; + border-top: 1px solid #eee; +} +.uml-snapshot-memory dt { + font-weight: 700; +} +.uml-snapshot-memory dt small { + display: block; + color: #777; + font: 8px/1.2 ui-monospace, monospace; +} +.uml-snapshot-stack > header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +.uml-snapshot-stack > header > p { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px 9px; + margin: 0; + color: #555; + font-size: 8px; +} +.uml-stack-axis { + display: flex; + justify-content: space-between; + gap: 8px; + margin: 3px 89px 2px; + color: #777; + font: 8px/1.2 ui-monospace, monospace; +} +.uml-stack-lane { + display: flex; + align-items: stretch; + min-width: 0; + overflow-x: auto; + border: 1px solid #66757c; + background: #eef1f2; +} +.uml-stack-boundary { + display: grid; + place-content: center; + flex: 0 1 82px; + min-width: 64px; + padding: 5px; + background: #273941; + color: #fff; + text-align: center; + font: 700 8px/1.25 ui-monospace, monospace; +} +.uml-stack-boundary small { + color: #a9d9eb; + font-size: 8px; + text-transform: uppercase; +} +.uml-stack-gap { + display: grid; + place-content: center; + flex: 0.7 1 70px; + min-width: 60px; + min-height: 52px; + border-right: 1px dashed #9da8ad; + color: #78858b; + text-align: center; + font: 8px/1.2 ui-monospace, monospace; +} +.uml-stack-gap strong, +.uml-stack-gap small { + display: block; +} +.uml-stack-gap strong { + color: #52636b; +} +.uml-stack-gap small { + color: #78858b; + font-size: 8px; +} +.uml-stack-lane article { + display: grid; + align-content: center; + flex: 1 1 92px; + min-width: 76px; + padding: 5px 7px; + border-right: 1px solid #9da8ad; + background: #fff; + text-align: center; +} +.uml-stack-lane article[data-state="initialised"] { + border-top: 3px solid #2c8060; +} +.uml-stack-lane article[data-state="saved"] { + border-top: 3px solid #6d7196; + background: #f7f6fb; +} +.uml-stack-lane article strong, +.uml-stack-lane article code, +.uml-stack-lane article small { + display: block; + overflow-wrap: anywhere; +} +.uml-stack-lane article strong { + font: 700 9px/1.2 ui-monospace, monospace; +} +.uml-stack-lane article code { + margin: 2px 0; + color: #1f4555; + font-size: 8px; +} +.uml-stack-lane article small { + color: #777; + font: 8px/1.2 ui-monospace, monospace; +} +.uml-snapshot-observation p { + margin: 0 0 3px; +} +.uml-snapshot-observation small { + display: block; + overflow-wrap: anywhere; + color: #666; + font: 8px/1.3 ui-monospace, monospace; +} .task-conclusion { margin: 8px 0 0; padding: 7px 9px; @@ -404,6 +1199,12 @@ white-space: pre-wrap; } @media (max-width: 800px) { + .topbar-nvim-meta { + display: none; + } + .topbar-nvim-section .nvim-control-indicator { + display: none; + } .memory-lanes { grid-template-columns: 1fr 1fr; } @@ -415,8 +1216,36 @@ .memory-actions { justify-content: flex-start; } + .uml-snapshot-layout { + display: block; + } + .uml-step-canvas { + border: 0; + } + .uml-phase-actions { + justify-content: flex-start; + } + .uml-code-view, + .uml-state-grid { + grid-template-columns: 1fr; + } + .uml-terminal-view figcaption, + .uml-snapshot-stack > header { + align-items: flex-start; + flex-direction: column; + } + .uml-snapshot-stack > header > p { + justify-content: flex-start; + } + .uml-stack-axis { + margin-right: 0; + margin-left: 0; + } } @media print { + .viewer-chrome { + display: none !important; + } .memory-react { display: none !important; } @@ -425,6 +1254,10 @@ width: 100%; } .uml-actions, + .uml-phase-actions, + .uml-step-actions, + .uml-evidence-view, + .uml-snapshot-panel, .uml-stage, .uml-approval, .asset-source-link, diff --git a/schemas/card-source.schema.json b/schemas/card-source.schema.json index 9c0b010..731771a 100644 --- a/schemas/card-source.schema.json +++ b/schemas/card-source.schema.json @@ -28,6 +28,9 @@ "generated": { "$ref": "#/$defs/generated" }, + "debug_checkpoints": { + "$ref": "#/$defs/debugCheckpoints" + }, "template": { "type": "string", "minLength": 1 @@ -617,6 +620,323 @@ }, "additionalProperties": false }, + "umlSnapshotRegister": { + "type": "object", + "required": ["name", "value"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "value": { "type": "string", "minLength": 1 }, + "note": { "type": "string" } + }, + "additionalProperties": false + }, + "umlSnapshotFrameField": { + "type": "object", + "required": ["name", "address", "value", "state"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "address": { "type": "string", "minLength": 1 }, + "value": { "type": "string", "minLength": 1 }, + "size_bytes": { "type": "integer", "minimum": 1 }, + "state": { "enum": ["initialised", "uninitialised", "saved"] }, + "note": { "type": "string" } + }, + "additionalProperties": false + }, + "umlSnapshotFrame": { + "type": "object", + "required": ["name", "sp", "fp", "size_bytes", "fields"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "sp": { "type": "string", "minLength": 1 }, + "fp": { "type": "string", "minLength": 1 }, + "stack_top": { "type": "string", "minLength": 1 }, + "size_bytes": { "type": "integer", "minimum": 0 }, + "fields": { + "type": "array", + "items": { "$ref": "#/$defs/umlSnapshotFrameField" } + } + }, + "additionalProperties": false + }, + "umlSnapshotMemoryItem": { + "type": "object", + "required": ["name", "address", "value"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "address": { "type": "string", "minLength": 1 }, + "value": { "type": "string", "minLength": 1 }, + "note": { "type": "string" } + }, + "additionalProperties": false + }, + "umlSnapshotArena": { + "type": "object", + "required": ["base", "size_bytes", "cursor", "offset", "bytes"], + "properties": { + "base": { "type": "string", "minLength": 1 }, + "size_bytes": { "type": "integer", "minimum": 1 }, + "cursor": { "type": "string", "minLength": 1 }, + "offset": { + "oneOf": [ + { "type": "integer", "minimum": 0 }, + { "type": "null" } + ] + }, + "bytes": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "umlSnapshot": { + "type": "object", + "required": ["pc", "registers", "frame", "memory", "description"], + "properties": { + "target": { "type": "string", "minLength": 1 }, + "captured_at": { "type": "string", "minLength": 1 }, + "captured_with": { "type": "string", "minLength": 1 }, + "elf": { "type": "string", "minLength": 1 }, + "pc": { "type": "string", "minLength": 1 }, + "registers": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/umlSnapshotRegister" } + }, + "frame": { "$ref": "#/$defs/umlSnapshotFrame" }, + "memory": { + "type": "object", + "required": ["items"], + "properties": { + "items": { + "type": "array", + "items": { "$ref": "#/$defs/umlSnapshotMemoryItem" } + }, + "arena": { "$ref": "#/$defs/umlSnapshotArena" } + }, + "additionalProperties": false + }, + "code": { + "type": "object", + "required": ["source", "assembly"], + "properties": { + "source": { "type": "string", "minLength": 1 }, + "source_ref": { "type": "string" }, + "assembly": { "type": "string", "minLength": 1 }, + "note": { "type": "string" } + }, + "additionalProperties": false + }, + "description": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "umlSnapshotContext": { + "type": "object", + "required": ["target", "captured_at"], + "properties": { + "target": { "type": "string", "minLength": 1 }, + "captured_at": { "type": "string", "minLength": 1 }, + "captured_with": { "type": "string", "minLength": 1 }, + "elf": { "type": "string", "minLength": 1 }, + "stack_top": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "nvimAnnotationAnchor": { + "type": "object", + "required": ["kind"], + "properties": { + "kind": { "enum": ["screen-text", "grid-range"] }, + "text": { "type": "string", "minLength": 1 }, + "occurrence": { "type": "integer", "minimum": 1 }, + "row": { "type": "integer", "minimum": 0 }, + "column": { "type": "integer", "minimum": 0 }, + "rows": { "type": "integer", "minimum": 1 }, + "columns": { "type": "integer", "minimum": 1 }, + "padding_cells": { "type": "integer", "minimum": 0, "maximum": 8 } + }, + "allOf": [ + { + "if": { "properties": { "kind": { "const": "screen-text" } } }, + "then": { "required": ["text"] } + }, + { + "if": { "properties": { "kind": { "const": "grid-range" } } }, + "then": { "required": ["row", "column", "rows", "columns"] } + } + ], + "additionalProperties": false + }, + "nvimAnnotation": { + "type": "object", + "required": ["id", "anchor"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "anchor_ref": { + "type": "string", + "minLength": 1, + "description": "Semantyczna kotwica rozwiązywana przez adapter Neovima; anchor jest fallbackiem dla nagranego gridu." + }, + "label": { "type": "string", "minLength": 1 }, + "tone": { "enum": ["danger", "info", "success", "warning"] }, + "shape": { "enum": ["outline", "underline"] }, + "anchor": { "$ref": "#/$defs/nvimAnnotationAnchor" } + }, + "additionalProperties": false + }, + "nvimStepView": { + "type": "object", + "properties": { + "connection_ref": { + "type": "string", + "minLength": 1, + "description": "Logiczny profil/adapter celu, nigdy ID procesu, bufora ani kontenera." + }, + "layout": { "type": "string", "minLength": 1 }, + "recorded_grid_ref": { + "type": "string", + "minLength": 1, + "description": "Wersjonowany fallback ekranu używany, gdy kontener jest offline." + }, + "reveal": { + "type": "object", + "properties": { + "anchor_ref": { "type": "string", "minLength": 1 }, + "align": { "enum": ["top", "center", "bottom"] } + }, + "additionalProperties": false + }, + "annotations": { + "type": "array", + "items": { "$ref": "#/$defs/nvimAnnotation" } + } + }, + "additionalProperties": false + }, + "debugCheckpointExpression": { + "type": "object", + "required": ["expr", "equals"], + "properties": { + "expr": { "type": "string", "minLength": 1 }, + "equals": { "type": "integer" } + }, + "additionalProperties": false + }, + "debugCheckpointRecipe": { + "type": "object", + "required": ["stop"], + "properties": { + "stop": { + "type": "object", + "required": ["symbol", "offset"], + "properties": { + "symbol": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, + "offset": { "type": "integer", "minimum": 0 }, + "condition": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + "verify": { + "type": "object", + "properties": { + "expressions": { + "type": "array", + "maxItems": 16, + "items": { "$ref": "#/$defs/debugCheckpointExpression" } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "debugCheckpoints": { + "type": "object", + "required": ["schema", "semantics", "artifact", "targets", "items"], + "properties": { + "schema": { "const": "stem-debug-checkpoints.v1" }, + "semantics": { "const": "deterministic-replay" }, + "recorded_grid_pattern": { + "type": "string", + "pattern": "^[A-Za-z0-9._/-]*\\{snapshot_ref\\}[A-Za-z0-9._/-]*$" + }, + "artifact": { + "type": "object", + "required": ["source", "elf"], + "properties": { + "source": { "type": "string", "minLength": 1 }, + "source_git_blob": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "repository_revision": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "elf": { "type": "string", "minLength": 1 }, + "hazard3_elf_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "additionalProperties": false + }, + "targets": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "required": ["adapter", "baseline"], + "properties": { + "adapter": { "type": "string", "minLength": 1 }, + "baseline": { "type": "string", "minLength": 1 }, + "clean_ram": { "type": "boolean" }, + "requires_probe_lease": { "type": "boolean" }, + "persistent_flash": { "type": "boolean" } + }, + "additionalProperties": false + } + }, + "items": { + "type": "object", + "minProperties": 1, + "propertyNames": { "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" }, + "additionalProperties": { "$ref": "#/$defs/debugCheckpointRecipe" } + } + }, + "additionalProperties": false + }, + "umlSequenceStep": { + "type": "object", + "required": ["id", "number", "label", "description", "snapshot"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "number": { "type": "integer", "minimum": 1 }, + "label": { "type": "string", "minLength": 1 }, + "description": { "type": "string", "minLength": 1 }, + "code_ref": { "type": "string" }, + "progress_id": { "type": "string", "minLength": 1 }, + "snapshot_ref": { + "type": "string", + "minLength": 1, + "description": "Kanoniczny stan wykonania; wiele kroków dydaktycznych może współdzielić jeden snapshot." + }, + "view": { "$ref": "#/$defs/nvimStepView" }, + "svg_label": { + "type": "string", + "minLength": 1, + "description": "Dokładny numer lub etykieta wiadomości PlantUML wybieranej w inline SVG." + }, + "snapshot": { "$ref": "#/$defs/umlSnapshot" } + }, + "additionalProperties": false + }, + "umlSequencePhase": { + "type": "object", + "required": ["id", "label", "steps"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 }, + "description": { "type": "string" }, + "svg_label": { "type": "string", "minLength": 1 }, + "steps": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/umlSequenceStep" } + } + }, + "additionalProperties": false + }, "interactiveAsset": { "type": "object", "required": ["kind"], @@ -628,6 +948,32 @@ "type": "string", "minLength": 1 }, + "title": { + "type": "string", + "minLength": 1 + }, + "block": { + "type": "object", + "required": ["id", "label"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 }, + "description": { "type": "string" } + }, + "additionalProperties": false + }, + "snapshot_context": { "$ref": "#/$defs/umlSnapshotContext" }, + "terminal_view": { + "type": "object", + "required": ["path", "alt"], + "properties": { + "path": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "alt": { "type": "string", "minLength": 1 }, + "caption": { "type": "string" } + }, + "additionalProperties": false + }, "arena_size": { "type": "integer", "minimum": 1 @@ -677,6 +1023,11 @@ }, "additionalProperties": false } + }, + "phases": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/umlSequencePhase" } } }, "additionalProperties": false diff --git a/tools/render_card.py b/tools/render_card.py index fbd1c69..7feaa02 100644 --- a/tools/render_card.py +++ b/tools/render_card.py @@ -2,6 +2,7 @@ from __future__ import annotations import base64 +import copy import json import html as html_lib import os @@ -6918,6 +6919,17 @@ def render_section_assets_tex(section: dict, data: dict) -> list[str]: return lines +def recorded_grid_ref(data: dict, step: dict) -> str: + explicit = str((step.get("view", {}) or {}).get("recorded_grid_ref", "")) + if explicit: + return explicit + snapshot_ref = str(step.get("snapshot_ref", "")) + pattern = str((data.get("debug_checkpoints", {}) or {}).get("recorded_grid_pattern", "")) + if not snapshot_ref or "{snapshot_ref}" not in pattern: + return "" + return pattern.replace("{snapshot_ref}", snapshot_ref) + + def prepare_html_figure_assets(data: dict) -> None: output_dir = html_output_dir(data) / "figures" output_dir.mkdir(parents=True, exist_ok=True) @@ -6934,6 +6946,7 @@ def prepare_html_figure_assets(data: dict) -> None: for asset in section_assets(data.get("sections", [])): add_graphic(str(asset.get("html_path") or asset.get("path", ""))) add_graphic(str(asset.get("source_path", ""))) + add_graphic(str((asset.get("interactive", {}).get("terminal_view", {}) or {}).get("path", ""))) for figure in data.get("figures", []): add_graphic(figure.get("path", "")) pdf_links: list[str] = [] @@ -6970,6 +6983,26 @@ def prepare_html_figure_assets(data: dict) -> None: if source.exists(): shutil.copy2(source, output_dir / source.name) + recorded_grids: set[str] = set() + for asset in section_assets(data.get("sections", [])): + interactive = asset.get("interactive", {}) or {} + for phase in interactive.get("phases", []) or []: + for step in phase.get("steps", []) or []: + recorded_ref = recorded_grid_ref(data, step) + if recorded_ref: + recorded_grids.add(recorded_ref) + json_root = (CARD_ROOT / "json").resolve() + web_root = html_output_dir(data).resolve() + for recorded_ref in sorted(recorded_grids): + source = (json_root / recorded_ref).resolve() + target = (web_root / recorded_ref).resolve() + if json_root not in source.parents or web_root not in target.parents: + raise ValueError(f"recorded_grid_ref wychodzi poza dozwolony katalog: {recorded_ref}") + if not source.is_file(): + raise FileNotFoundError(f"Brak zapisanego ekranu Neovima: {source}") + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + def react_task_model( task_ref: str, @@ -7071,6 +7104,16 @@ def react_card_model(data: dict) -> dict: include_section_assets = not task_ref or task_ref == str(task_refs[0]) for asset in (section.get("assets", []) or []) if include_section_assets else []: source_path = str(asset.get("html_path") or asset.get("path", "")) + interactive = copy.deepcopy(asset.get("interactive")) + if interactive: + terminal_view = interactive.get("terminal_view") or {} + if terminal_view.get("path"): + terminal_view["href"] = html_figure_asset(str(terminal_view["path"])) + for phase in interactive.get("phases", []) or []: + for step in phase.get("steps", []) or []: + recorded_ref = recorded_grid_ref(data, step) + if recorded_ref: + step.setdefault("view", {})["recorded_grid_ref"] = recorded_ref assets.append( { "label": str(asset.get("label", "")), @@ -7082,7 +7125,7 @@ def react_card_model(data: dict) -> dict: else "", "width": float(asset.get("width", 1.0)), "kind": str(asset.get("kind", "diagram")), - "interactive": asset.get("interactive"), + "interactive": interactive, } ) title = str(section.get("title", ""))