From 759fdacd14230107abc968def753ae6faf288f93 Mon Sep 17 00:00:00 2001 From: user Date: Fri, 17 Jul 2026 17:07:08 +0200 Subject: [PATCH] feat: add pinned allocator lesson view --- docs/api.md | 30 ++++- react/src/main.tsx | 318 +++++++++++++++++++++++++++++++++++++++++--- react/src/react.css | 187 ++++++++++++++++++++++++++ 3 files changed, 516 insertions(+), 19 deletions(-) diff --git a/docs/api.md b/docs/api.md index 197b71d..6db709b 100644 --- a/docs/api.md +++ b/docs/api.md @@ -27,9 +27,15 @@ dlatego nie należy wystawiać go bezpośrednio poza zaufany host. Hierarchia nawigacji jest stała: ```text -task → block → phase → step → snapshot +card → task → block → phase → step → snapshot ``` +Ruch kursora przez `move` lub `PUT current` nie uruchamia symulatora. +Jawna aktywacja odbywa się przez `Enter` w WWW albo `F2` w WWW/Neovimie, +które wywołują `POST /api/navigation/sync`. Dla poziomu BLOCK backend wybiera +pierwszy STEP tego bloku. CARD i TASK są odrzucane, ponieważ nie wskazują +konkretnego checkpointu diagramu. + | Metoda i endpoint | Znaczenie | | --- | --- | | `GET /api/keyboard` | Skróty i obsługiwane poziomy nawigacji. | @@ -37,6 +43,7 @@ task → block → phase → step → snapshot | `GET /api/navigation/current` | Bieżąca pozycja oraz status checkpointu. | | `PUT /api/navigation/current` | Ustawia dokładną pozycję i poziom fokusu. | | `POST /api/navigation/move` | Przesuwa kursor o `delta` na wybranym poziomie. | +| `POST /api/navigation/sync` | Odtwarza checkpoint przypisany do bieżącej, zapisanej pozycji. | Przykład ustawienia kroku — identyfikatory są danymi konkretnej karty: @@ -81,6 +88,13 @@ Wywołanie orkiestratora przypina zarówno kontener, jak i rewizję karty: } ``` +Backend przechowuje bieżącą nawigację i stan viewera w SQLite. Domyślna baza +to `.stem/card-state.sqlite3`; ścieżkę można zmienić przez +`CARD_STATE_DATABASE`. Ta sama aplikacja wystawia HTTP również przez lokalny +socket `.stem/card-api.sock` (`CARD_API_SOCKET`). Repozytorium jest montowane w +kontenerze jako `/workspace`, dlatego Neovim wywołuje API przez +`/workspace/.stem/card-api.sock` bez otwierania dodatkowego portu sieciowego. + Replay kończy się powodzeniem wyłącznie ze statusem `ready`. Zmiana kontenera, socketu MCP albo źródłowego JSON-a podczas operacji powoduje odrzucenie wyniku; pozycja strony i `SYNC ON` nie są wtedy publikowane. @@ -93,6 +107,8 @@ wyniku; pozycja strony i `SYNC ON` nie są wtedy publikowane. | `GET /api/nvim/state` | Bieżący bufor, okno, kursor i tryb Neovima. | | `PUT /api/nvim/cursor` | Ustawia `row`, `column` i opcjonalnie `window`. | | `POST /api/nvim/input` | Przekazuje do 256 znaków przez `nvim_input`. | +| `POST /api/nvim/reset` | Resetuje cel i odświeża debugger (`F1`). | +| `POST /api/nvim/redraw` | Dopasowuje host pane i grid UI, a następnie wykonuje pełny redraw (`Ctrl+\``). | | `WS /api/nvim-ui` | Strumień zewnętrznego UI Neovima do panelu WWW. | Pole `viewer.scroll` zapisuje położenie dokumentu (`x`, `y`), numer strony @@ -104,6 +120,10 @@ Wyłączenie `viewer.nvim.sync` automatycznie wyłącza również `viewer.nvim.control`. Samo ponowne włączenie synchronizacji nie uzbraja sterowania klawiaturą bez jawnego `control: true`. +`viewer.allocator.visible` przechowuje stan przypiętego modelu alokatora. +Model jest przełączany przez `Alt+6` i pokazuje fazę/krok, `allocp`, zajętość +areny oraz wartości związane z bieżącym checkpointem. + ### Podział odpowiedzialności widoków Interaktywny diagram UML pokazuje przepływ i służy do wyboru kroku. Nie @@ -119,6 +139,14 @@ diagramem. | `GET /api/progress` | Katalog, statusy i podsumowanie wykonania. | | `PUT /api/progress/:itemId` | Ustawia `pending` albo `approved`, notatkę i aktora. | | `GET /api/report/teams.md` | Generuje bieżący raport Markdown do Teams. | +| `GET/POST /api/events` | Odczytuje lub dopisuje chronologiczne zdarzenia z timestampem backendu. | +| `POST /api/evidence` | Zapisuje PNG/JPEG i przypina go do zdarzenia. | +| `GET /api/evidence/:name` | Udostępnia zapisany dowód. | +| `GET /api/report/lesson.html` | Renderuje chronologiczny raport HTML z dowodami. | + +Zdarzenia są dopisywane do SQLite i obejmują nawigację, aktywacje +checkpointów, reset/redraw Neovima, zmiany viewera oraz postęp. Dzięki temu +raport pokazuje rzeczywistą kolejność pracy, a nie wyłącznie końcowy stan. ## Planowane strategie i adnotacje diff --git a/react/src/main.tsx b/react/src/main.tsx index dcb2d9a..3359ecc 100644 --- a/react/src/main.tsx +++ b/react/src/main.tsx @@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client"; import "./react.css"; type Status = "pending" | "approved"; -type NavigationLevel = "task" | "block" | "phase" | "step" | "snapshot"; +type NavigationLevel = "card" | "task" | "block" | "phase" | "step" | "snapshot"; type NavigationPosition = { task_index: number; block_index: number; @@ -36,6 +36,7 @@ type ApiViewerState = { sheet_scroll_top?: number; }; viewport: { width: number; height: number }; + allocator?: { visible: boolean }; nvim: { visible: boolean; sync: boolean; @@ -465,6 +466,8 @@ function Topbar({ setNvimExpanded, nvimFit, setNvimFit, + allocatorVisible, + setAllocatorVisible, nvimSummary, }: { toc: CardModel["toc"]; @@ -480,6 +483,8 @@ function Topbar({ setNvimExpanded: (value: boolean) => void; nvimFit: boolean; setNvimFit: (value: boolean) => void; + allocatorVisible: boolean; + setAllocatorVisible: (value: boolean) => void; nvimSummary: NvimPanelSummary; }) { return ( @@ -549,6 +554,16 @@ function Topbar({ > A5FIT + {nvimVisible && ( @@ -561,7 +576,7 @@ function Topbar({ {nvimSummary.detail && {nvimSummary.detail}} - Alt+W okna · F12 skróty + F1 reset · F2 sync · Alt+W okna · F12 skróty )} @@ -608,6 +623,138 @@ type ActiveNvimStep = { 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(null); + + useEffect(() => { + const selected = (event: Event) => { + setActive((event as CustomEvent).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 ( +
+
+ ALLOCATOR · PINNED + + {active + ? `${active.phase.label} · ${String(active.step.number).padStart(2, "0")} ${active.step.label}` + : "Oczekiwanie na diagram UML"} + + + {hasUmlCursor + ? active!.step.snapshot.description + : "Wybierz co najmniej BLOCK. F2 wybierze jego pierwszy krok i wykona synchronizację."} + +
+
+
+
FOCUS
{focusLevel.toUpperCase()}
+
BASE
{arena?.base ?? "—"}
+
ALLOCP
{arena?.cursor ?? "—"}
+
USED / FREE
{offset == null ? "—" : `${offset} / ${size - offset} B`}
+
+
+
+ allocbuf[0] + {offset == null ? "allocp jeszcze nieustawiony" : `offset ${offset}`} + allocbuf[{size - 1}] +
+
+ {Array.from({ length: cellCount }, (_, index) => ( + + ))} + {offset != null && ( + + )} +
+
+
+ {facts.length ? facts.map(fact => ( +
{fact.name}{fact.value}
+ )) :

Brak snapshotu.

} +
+
+
+ ); +} type NvimConnectionStatus = { state: "hidden" | "connecting" | "connected" | "offline" | "unselected"; message?: string; @@ -1253,6 +1400,25 @@ function NeovimPanel({ }); saveNvimGrid(snapshotRef, gridRef.current); redraw(); + await new Promise(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; @@ -2168,7 +2334,7 @@ function SnapshotInteractiveUmlSequence({ ); const [focusLevel, setFocusLevel] = useState(() => { const saved = localStorage.getItem(focusStorageKey); - return saved === "task" || saved === "block" || saved === "phase" + return saved === "card" || saved === "task" || saved === "block" || saved === "phase" || saved === "step" || saved === "snapshot" ? saved : "step"; @@ -2304,7 +2470,7 @@ function SnapshotInteractiveUmlSequence({ ); 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-active", focusLevel !== "card" && index === stepIndex); hit.classList.toggle("uml-step-hit-completed", completed); svg.appendChild(hit); }); @@ -2390,9 +2556,14 @@ function SnapshotInteractiveUmlSequence({ focusLevel: level, activate: activate && document.documentElement.dataset.nvimSyncMode === "on", }; - window.dispatchEvent(new CustomEvent("stem:nvim-step", { detail })); - if (!persist) return; - void fetch("/api/navigation/current", { + const dispatch = () => { + window.dispatchEvent(new CustomEvent("stem:nvim-step", { detail })); + }; + if (!persist) { + dispatch(); + return; + } + const save = fetch("/api/navigation/current", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -2409,11 +2580,19 @@ function SnapshotInteractiveUmlSequence({ sync_requested: Boolean(detail.activate), actor: document.documentElement.dataset.cardClientId ?? "browser", }), - }).catch(() => undefined); + }); + 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 = true, + activate = false, level: NavigationLevel = focusLevel, persist = true, ) => { @@ -2455,7 +2634,7 @@ function SnapshotInteractiveUmlSequence({ ); }); const next = Number(hits[0]?.dataset.umlStep); - if (Number.isInteger(next)) selectIndex(next, true, "step"); + if (Number.isInteger(next)) selectIndex(next, false, "step"); }; const handleSvgKeyDown = (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; @@ -2463,11 +2642,11 @@ function SnapshotInteractiveUmlSequence({ const next = Number(target.dataset?.umlStep); if (!Number.isInteger(next)) return; event.preventDefault(); - selectIndex(next, true, "step"); + 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, true, "phase"); + if (next >= 0) selectIndex(next, false, "phase"); }; const active = entries[stepIndex]; @@ -2494,7 +2673,9 @@ function SnapshotInteractiveUmlSequence({ entry.phase.id === command.phase.id && entry.step.id === command.step.id, ); if (next < 0) return; - selectIndex(next, command.sync_requested, command.focus_level, false); + // 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); @@ -2536,7 +2717,7 @@ function SnapshotInteractiveUmlSequence({ return; } - const hierarchy: NavigationLevel[] = ["task", "block", "phase", "step", "snapshot"]; + 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; @@ -2552,6 +2733,7 @@ function SnapshotInteractiveUmlSequence({ } 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"; @@ -2568,7 +2750,7 @@ function SnapshotInteractiveUmlSequence({ body: JSON.stringify({ level, delta: direction, - sync_requested: document.documentElement.dataset.nvimSyncMode === "on", + sync_requested: false, actor: document.documentElement.dataset.cardClientId ?? "browser", }), }) @@ -2628,7 +2810,7 @@ function SnapshotInteractiveUmlSequence({ @@ -2930,6 +3120,7 @@ function App({ model }: { model: CardModel }) { 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", @@ -2943,6 +3134,9 @@ function App({ model }: { model: CardModel }) { ); 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({ state: "hidden", syncMode: false, @@ -2953,6 +3147,60 @@ function App({ model }: { model: CardModel }) { const [progress, setProgress] = useState(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("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) => { try { const response = await fetch("/api/viewer/state", { @@ -3030,6 +3278,11 @@ function App({ model }: { model: CardModel }) { 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(() => { @@ -3069,6 +3322,7 @@ function App({ model }: { model: CardModel }) { expanded: nvimExpanded, fit: nvimFit, }, + allocator: { visible: allocatorVisible }, }; const applyViewer = (viewer: ApiViewerState) => { const fit = viewer.nvim.visible && viewer.nvim.fit; @@ -3079,12 +3333,15 @@ function App({ model }: { model: CardModel }) { 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); @@ -3267,6 +3524,26 @@ function App({ model }: { model: CardModel }) { 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(); @@ -3281,7 +3558,8 @@ function App({ model }: { model: CardModel }) { }; window.addEventListener("keydown", navigate, true); return () => window.removeEventListener("keydown", navigate, true); - }, [nvimControlMode, nvimExpanded, nvimFit, nvimSyncMode, nvimVisible, shortcutsOpen]); + }, [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; @@ -3346,8 +3624,11 @@ function App({ model }: { model: CardModel }) { setNvimExpanded={setNvimExpanded} nvimFit={nvimFit} setNvimFit={setNvimFit} + allocatorVisible={allocatorVisible} + setAllocatorVisible={setAllocatorVisible} nvimSummary={nvimSummary} /> + + {shortcutStatus && {shortcutStatus}}
diff --git a/react/src/react.css b/react/src/react.css index 8b01702..cc0baf9 100644 --- a/react/src/react.css +++ b/react/src/react.css @@ -24,6 +24,24 @@ z-index: 80; width: 100%; } +.viewer-action-toast { + position: absolute; + z-index: 12; + right: 9px; + bottom: 9px; + max-width: min(720px, calc(100vw - 18px)); + box-sizing: border-box; + overflow: hidden; + border: 1px solid #42b8e7; + border-radius: 3px; + background: #102c38ee; + color: #dff7ff; + padding: 5px 9px; + box-shadow: 0 4px 14px #0007; + text-overflow: ellipsis; + white-space: nowrap; + font: 750 10px/1.25 ui-monospace, monospace; +} .viewer-chrome .topbar { position: relative; top: auto; @@ -41,6 +59,9 @@ .viewer-chrome.is-nvim-fit .topbar { flex: none; } +.viewer-chrome.is-nvim-fit .allocator-panel { + flex: none; +} .viewer-chrome.is-nvim-fit .nvim-panel { display: flex; min-height: 0; @@ -159,6 +180,159 @@ background: #eaf6fb; color: #174d64; } +.allocator-panel { + width: 100%; + box-sizing: border-box; + border-top: 1px solid #40515a; + border-bottom: 1px solid #40515a; + background: #f7f9f5; + color: #182127; + box-shadow: 0 4px 12px #0002; + font: 11px/1.25 system-ui, sans-serif; +} +.allocator-panel > header { + display: grid; + grid-template-columns: 125px minmax(260px, auto) 1fr; + gap: 8px; + align-items: baseline; + min-height: 22px; + padding: 3px 8px; + border-bottom: 1px solid #b7c1c4; + background: #e8eef0; +} +.allocator-panel > header > span { + color: #166987; + font: 800 9px/1.2 ui-monospace, monospace; + letter-spacing: .06em; +} +.allocator-panel > header > strong { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 750 11px/1.2 ui-monospace, monospace; +} +.allocator-panel > header > small { + overflow: hidden; + color: #536168; + text-overflow: ellipsis; + white-space: nowrap; +} +.allocator-panel-body { + display: grid; + grid-template-columns: 230px minmax(420px, 1fr) minmax(310px, .8fr); + gap: 9px; + align-items: stretch; + padding: 5px 8px 7px; +} +.allocator-metrics { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3px 8px; + margin: 0; +} +.allocator-metrics > div { + min-width: 0; + border-left: 2px solid #8ea2ab; + padding-left: 5px; +} +.allocator-metrics dt { + color: #627077; + font: 750 7px/1.2 ui-monospace, monospace; + letter-spacing: .05em; +} +.allocator-metrics dd { + margin: 1px 0 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: 700 10px/1.2 ui-monospace, monospace; +} +.allocator-arena-view { + min-width: 0; +} +.allocator-arena-view.is-inactive { + opacity: .42; +} +.allocator-arena-labels { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 3px; + color: #536168; + font: 700 8px/1.2 ui-monospace, monospace; +} +.allocator-arena-labels strong { + color: #075f80; +} +.allocator-pinned-arena { + position: relative; + display: grid; + grid-template-columns: repeat(var(--allocator-size), minmax(2px, 1fr)); + height: 32px; + border: 1px solid #28343a; + background: #fff; +} +.allocator-pinned-arena i { + min-width: 0; + border-right: 1px solid #e1e5e6; + background: #fbfcfc; +} +.allocator-pinned-arena i:nth-child(8n) { + border-right-color: #87969d; +} +.allocator-pinned-arena i.is-allocated { + border-right-color: #b2d8e6; + background: #43a8ce; +} +.allocator-cursor { + position: absolute; + z-index: 2; + top: -4px; + bottom: -4px; + width: 2px; + background: #d02d25; + transform: translateX(-1px); + box-shadow: 0 0 0 1px #fff9; +} +.allocator-cursor::before { + content: "allocp"; + position: absolute; + top: 1px; + left: 4px; + color: #8d1d17; + font: 800 7px/1 ui-monospace, monospace; +} +.allocator-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 2px 7px; + min-width: 0; +} +.allocator-facts > div { + display: flex; + min-width: 0; + justify-content: space-between; + gap: 5px; + border-bottom: 1px dotted #b4bec2; +} +.allocator-facts span { + overflow: hidden; + color: #46565e; + text-overflow: ellipsis; + white-space: nowrap; +} +.allocator-facts code { + overflow: hidden; + color: #142e39; + text-overflow: ellipsis; + white-space: nowrap; + font: 700 9px/1.3 ui-monospace, monospace; +} +.allocator-facts p { + grid-column: 1 / -1; + margin: 0; + color: #647278; +} .nvim-panel { width: 100%; box-sizing: border-box; @@ -1460,6 +1634,19 @@ text.uml-step-element-active { .nvim-work-indicator { display: none; } + .allocator-panel > header { + grid-template-columns: auto 1fr; + } + .allocator-panel > header > small { + display: none; + } + .allocator-panel-body { + grid-template-columns: 1fr; + } + .allocator-metrics, + .allocator-facts { + display: none; + } .memory-lanes { grid-template-columns: 1fr 1fr; }