feat: add approvable interactive UML stages

This commit is contained in:
user
2026-07-16 14:10:16 +02:00
parent 41804a8ad1
commit 1c06ca35d6
4 changed files with 440 additions and 36 deletions
+265 -15
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./react.css"; import "./react.css";
@@ -15,6 +15,8 @@ type UmlStage = {
label: string; label: string;
description: string; description: string;
code_ref?: string; code_ref?: string;
progress_id?: string;
svg_label?: string;
}; };
type InteractiveAsset = { type InteractiveAsset = {
kind: "allocator-memory-flow" | "uml-sequence"; kind: "allocator-memory-flow" | "uml-sequence";
@@ -47,6 +49,7 @@ type Task = {
title: string; title: string;
uuid?: string; uuid?: string;
criterion: string; criterion: string;
conclusion_html: string;
prompt_html?: string; prompt_html?: string;
flow: FlowBlock[]; flow: FlowBlock[];
}; };
@@ -66,6 +69,7 @@ type Progress = {
items: Record<string, { status: Status }>; items: Record<string, { status: Status }>;
summary: { total: number; counts: Record<Status, number> }; summary: { total: number; counts: Record<Status, number> };
}; };
type SetProgressStatus = (id: string, status: Status) => Promise<void>;
type CardModel = { type CardModel = {
schema: string; schema: string;
card: Record<string, string>; card: Record<string, string>;
@@ -396,12 +400,20 @@ function InteractiveMemoryFlow({
function InteractiveUmlSequence({ function InteractiveUmlSequence({
config, config,
asset, asset,
progress,
onSetStatus,
}: { }: {
config: InteractiveAsset; config: InteractiveAsset;
asset: Asset; asset: Asset;
progress: Progress | null;
onSetStatus: SetProgressStatus;
}) { }) {
const stages = config.stages ?? []; const stages = config.stages ?? [];
const storageKey = `${config.storage_key ?? asset.label}-uml-stage`; const storageKey = `${config.storage_key ?? asset.label}-uml-stage`;
const canvasRef = useRef<HTMLDivElement>(null);
const [svgMarkup, setSvgMarkup] = useState("");
const [svgFailed, setSvgFailed] = useState(false);
const [approvalBusy, setApprovalBusy] = useState(false);
const [stageIndex, setStageIndex] = useState(() => const [stageIndex, setStageIndex] = useState(() =>
Math.max( Math.max(
0, 0,
@@ -415,6 +427,156 @@ function InteractiveUmlSequence({
() => localStorage.setItem(storageKey, String(stageIndex)), () => localStorage.setItem(storageKey, String(stageIndex)),
[stageIndex, storageKey], [stageIndex, storageKey],
); );
useEffect(() => {
let current = true;
setSvgFailed(false);
fetch(asset.href, { cache: "no-store" })
.then((response) => {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.text();
})
.then((markup) => {
const document = new DOMParser().parseFromString(
markup,
"image/svg+xml",
);
const svg = document.documentElement;
if (svg.nodeName.toLowerCase() !== "svg" || document.querySelector("parsererror")) {
throw new Error("Niepoprawny SVG");
}
document.querySelectorAll("script, foreignObject").forEach((node) => node.remove());
svg.removeAttribute("width");
svg.removeAttribute("height");
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
if (current) setSvgMarkup(svg.outerHTML);
})
.catch(() => {
if (current) setSvgFailed(true);
});
return () => {
current = false;
};
}, [asset.href]);
useEffect(() => {
const svg = canvasRef.current?.querySelector("svg");
if (!svg) return;
svg.setAttribute("role", "img");
svg.setAttribute("aria-label", asset.alt);
svg
.querySelectorAll<SVGRectElement>("[data-uml-stage]")
.forEach((rect) => {
rect.classList.remove(
"uml-stage-region",
"uml-stage-region-bg",
"uml-stage-region-outline",
"uml-stage-region-active",
"uml-stage-region-completed",
);
rect.removeAttribute("data-uml-stage");
rect.removeAttribute("data-uml-stage-label");
rect.removeAttribute("role");
rect.removeAttribute("tabindex");
rect.removeAttribute("aria-label");
});
const texts = Array.from(svg.querySelectorAll<SVGTextElement>("text"));
const rects = Array.from(svg.querySelectorAll<SVGRectElement>("rect"));
stages.forEach((item, index) => {
if (!item.svg_label) return;
const label = texts.find((text) =>
(text.textContent ?? "").trim().startsWith(item.svg_label!),
);
const labelY = Number(label?.getAttribute("y"));
if (!label || !Number.isFinite(labelY)) return;
const candidates = rects
.map((rect) => {
const y = Number(rect.getAttribute("y"));
const height = Number(rect.getAttribute("height"));
const width = Number(rect.getAttribute("width"));
const style = rect.getAttribute("style") ?? "";
const strokeWidth = Number(
style.match(/stroke-width:\s*([\d.]+)/)?.[1] ??
rect.getAttribute("stroke-width") ??
0,
);
return { rect, y, height, width, strokeWidth, area: width * height };
})
.filter(
({ y, height, width, strokeWidth }) =>
Number.isFinite(y) &&
Number.isFinite(height) &&
Number.isFinite(width) &&
strokeWidth >= 2 &&
y <= labelY &&
y + height >= labelY,
)
.sort((left, right) => left.area - right.area);
const region = candidates[0];
if (!region) return;
const sameRegion = candidates.filter(
({ y, height, width }) =>
Math.abs(y - region.y) < 0.1 &&
Math.abs(height - region.height) < 0.1 &&
Math.abs(width - region.width) < 0.1,
);
const isCompleted = Boolean(
item.progress_id &&
progress?.items?.[item.progress_id]?.status === "completed",
);
sameRegion.forEach(({ rect }, duplicateIndex) => {
const isBackground = rect.getAttribute("fill") !== "none";
rect.dataset.umlStage = String(index);
rect.dataset.umlStageLabel = item.label;
rect.classList.add(
"uml-stage-region",
isBackground ? "uml-stage-region-bg" : "uml-stage-region-outline",
);
rect.classList.toggle("uml-stage-region-active", index === stageIndex);
rect.classList.toggle("uml-stage-region-completed", isCompleted);
if (duplicateIndex === 0) {
rect.setAttribute("role", "button");
rect.setAttribute("tabindex", "0");
rect.setAttribute("aria-label", `Pokaż etap ${item.label}`);
}
});
});
}, [asset.alt, progress, stageIndex, stages, svgMarkup]);
const selectStageAtPoint = (clientX: number, clientY: number) => {
const svg = canvasRef.current?.querySelector("svg");
if (!svg) return;
const hit = Array.from(
svg.querySelectorAll<SVGRectElement>(".uml-stage-region-bg[data-uml-stage]"),
)
.filter((rect) => {
const bounds = rect.getBoundingClientRect();
return (
clientX >= bounds.left &&
clientX <= bounds.right &&
clientY >= bounds.top &&
clientY <= bounds.bottom
);
})
.sort((left, right) => {
const a = left.getBoundingClientRect();
const b = right.getBoundingClientRect();
return a.width * a.height - b.width * b.height;
})[0];
const next = Number(hit?.dataset.umlStage);
if (Number.isInteger(next)) setStageIndex(next);
};
const handleSvgKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Enter" && event.key !== " ") return;
const target = event.target as SVGRectElement;
const next = Number(target.dataset?.umlStage);
if (!Number.isInteger(next)) return;
event.preventDefault();
setStageIndex(next);
};
const stage = stages[stageIndex]; const stage = stages[stageIndex];
return ( return (
<div className="uml-react"> <div className="uml-react">
@@ -428,21 +590,45 @@ function InteractiveUmlSequence({
<button <button
key={item.id} key={item.id}
aria-pressed={stageIndex === index} aria-pressed={stageIndex === index}
data-completed={
item.progress_id &&
progress?.items?.[item.progress_id]?.status === "completed"
? "true"
: "false"
}
onClick={() => setStageIndex(index)} onClick={() => setStageIndex(index)}
> >
{item.progress_id &&
progress?.items?.[item.progress_id]?.status === "completed" && (
<span aria-hidden="true"> </span>
)}
{item.label} {item.label}
</button> </button>
))} ))}
</div> </div>
</header> </header>
<a <div
ref={canvasRef}
className="uml-canvas" className="uml-canvas"
href={asset.href} onClick={(event) => selectStageAtPoint(event.clientX, event.clientY)}
target="_blank" onKeyDown={handleSvgKeyDown}
rel="noopener"
> >
{svgMarkup ? (
<div
className="uml-svg-host"
// The SVG is a generated, same-origin PlantUML asset; scripts and
// foreignObject nodes are removed before it reaches the DOM.
dangerouslySetInnerHTML={{ __html: svgMarkup }}
/>
) : (
<img src={asset.href} alt={asset.alt} /> <img src={asset.href} alt={asset.alt} />
</a> )}
{svgFailed && (
<span className="uml-inline-warning">
Widok statyczny nie udało się wczytać interaktywnego SVG.
</span>
)}
</div>
{stage && ( {stage && (
<aside className="uml-stage"> <aside className="uml-stage">
<strong>{stage.label}</strong> <strong>{stage.label}</strong>
@@ -450,11 +636,53 @@ function InteractiveUmlSequence({
{stage.code_ref && <code>{stage.code_ref}</code>} {stage.code_ref && <code>{stage.code_ref}</code>}
</aside> </aside>
)} )}
{stage?.progress_id && (
<footer className="uml-approval">
<span>
{progress?.items?.[stage.progress_id]?.status === "completed"
? "Ten stan jest zatwierdzony i zapisany w JSON-ie zajęć."
: "Kursor wybiera stan; zatwierdzenie zapisuje go w JSON-ie zajęć."}
</span>
<button
disabled={!progress || approvalBusy}
onClick={async () => {
if (!stage.progress_id || !progress) return;
const completed =
progress.items?.[stage.progress_id]?.status === "completed";
setApprovalBusy(true);
try {
await onSetStatus(
stage.progress_id,
completed ? "not_started" : "completed",
);
} finally {
setApprovalBusy(false);
}
}}
>
{approvalBusy
? "Zapisywanie…"
: progress?.items?.[stage.progress_id]?.status === "completed"
? "Cofnij zatwierdzenie"
: "Zatwierdź stan"}
</button>
</footer>
)}
</div> </div>
); );
} }
function AssetFigure({ asset, number }: { asset: Asset; number: number }) { function AssetFigure({
asset,
number,
progress,
onSetStatus,
}: {
asset: Asset;
number: number;
progress: Progress | null;
onSetStatus: SetProgressStatus;
}) {
const interactiveKind = asset.interactive?.kind; const interactiveKind = asset.interactive?.kind;
const isInteractive = Boolean(interactiveKind); const isInteractive = Boolean(interactiveKind);
return ( return (
@@ -463,7 +691,12 @@ function AssetFigure({ asset, number }: { asset: Asset; number: number }) {
id={asset.label.replace(":", "-")} id={asset.label.replace(":", "-")}
> >
{interactiveKind === "uml-sequence" ? ( {interactiveKind === "uml-sequence" ? (
<InteractiveUmlSequence config={asset.interactive!} asset={asset} /> <InteractiveUmlSequence
config={asset.interactive!}
asset={asset}
progress={progress}
onSetStatus={onSetStatus}
/>
) : interactiveKind === "allocator-memory-flow" ? ( ) : interactiveKind === "allocator-memory-flow" ? (
<> <>
<InteractiveMemoryFlow <InteractiveMemoryFlow
@@ -548,11 +781,13 @@ function SectionPage({
section, section,
progress, progress,
onCycle, onCycle,
onSetStatus,
figureOffset, figureOffset,
}: { }: {
section: Section; section: Section;
progress: Progress | null; progress: Progress | null;
onCycle: (id: string, status: Status) => void; onCycle: (id: string, status: Status) => void;
onSetStatus: SetProgressStatus;
figureOffset: number; figureOffset: number;
}) { }) {
return ( return (
@@ -579,8 +814,19 @@ function SectionPage({
key={asset.label} key={asset.label}
asset={asset} asset={asset}
number={figureOffset + index + 1} number={figureOffset + index + 1}
progress={progress}
onSetStatus={onSetStatus}
/> />
))} ))}
{section.tasks.map(
(task) =>
task.conclusion_html && (
<aside className="task-conclusion" key={`${task.ref}-conclusion`}>
<strong>WNIOSEK</strong>
<Html html={task.conclusion_html} />
</aside>
),
)}
</Sheet> </Sheet>
); );
} }
@@ -655,17 +901,12 @@ function App({ model }: { model: CardModel }) {
window.addEventListener("wheel", wheel, { passive: false }); window.addEventListener("wheel", wheel, { passive: false });
return () => window.removeEventListener("wheel", wheel); return () => window.removeEventListener("wheel", wheel);
}, []); }, []);
const cycle = async (id: string, current: Status) => { const setStatus: SetProgressStatus = async (id, status) => {
const next: Record<Status, Status> = {
not_started: "discussed",
discussed: "completed",
completed: "not_started",
};
try { try {
const response = await fetch(`/api/progress/${encodeURIComponent(id)}`, { const response = await fetch(`/api/progress/${encodeURIComponent(id)}`, {
method: "PUT", method: "PUT",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ status: next[current] }), body: JSON.stringify({ status }),
}); });
if (!response.ok) throw new Error(await response.text()); if (!response.ok) throw new Error(await response.text());
setProgress((await response.json()).progress); setProgress((await response.json()).progress);
@@ -676,6 +917,14 @@ function App({ model }: { model: CardModel }) {
); );
} }
}; };
const cycle = (id: string, current: Status) => {
const next: Record<Status, Status> = {
not_started: "discussed",
discussed: "completed",
completed: "not_started",
};
void setStatus(id, next[current]);
};
let figureCount = 0; let figureCount = 0;
return ( return (
<> <>
@@ -691,6 +940,7 @@ function App({ model }: { model: CardModel }) {
section={section} section={section}
progress={progress} progress={progress}
onCycle={cycle} onCycle={cycle}
onSetStatus={setStatus}
figureOffset={offset} figureOffset={offset}
/> />
); );
+118 -11
View File
@@ -167,8 +167,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 8px;
padding: 8px 10px; padding: 6px 8px;
border-bottom: 1px solid #bbb; border-bottom: 1px solid #bbb;
background: #fafafa; background: #fafafa;
} }
@@ -186,31 +186,93 @@
} }
.uml-actions { .uml-actions {
display: flex; display: flex;
gap: 5px; gap: 3px;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end;
} }
.uml-actions button { .uml-actions button {
border: 1px solid #777; border: 1px solid #aaa;
background: #fff; border-radius: 2px;
padding: 4px 8px; background: #fcfcfc;
color: #444;
padding: 2px 6px;
font:
600 10px/1.25 ui-monospace,
monospace;
cursor: pointer; cursor: pointer;
} }
.uml-actions button:hover {
border-color: #64879a;
background: #f5fafc;
}
.uml-actions button[data-completed="true"] {
border-color: #62a47f;
background: #edf8f1;
color: #14613a;
}
.uml-actions button[aria-pressed="true"] { .uml-actions button[aria-pressed="true"] {
border-color: #315f78; border-color: #287495;
background: #eaf4fa; background: #edf7fb;
font-weight: 700; color: #174d64;
box-shadow: inset 0 0 0 1px #b9dce9;
} }
.uml-canvas { .uml-canvas {
display: block; display: block;
padding: 8px; padding: 8px;
text-align: center; text-align: center;
} }
.uml-canvas img { .uml-canvas img,
.uml-svg-host svg {
display: block; display: block;
width: 100%; width: 100% !important;
height: auto !important;
max-height: 760px; max-height: 760px;
object-fit: contain; object-fit: contain;
} }
.uml-svg-host {
cursor: pointer;
}
.uml-stage-region {
cursor: pointer;
transition:
fill 140ms ease,
stroke 140ms ease,
stroke-width 140ms ease;
}
.uml-stage-region-completed.uml-stage-region-bg {
fill: #e7f6ed !important;
fill-opacity: 1 !important;
stroke: #258154 !important;
stroke-width: 3px !important;
}
.uml-stage-region-completed.uml-stage-region-outline {
stroke: #258154 !important;
stroke-width: 3px !important;
}
.uml-stage-region-active.uml-stage-region-bg {
fill: #e7f6fc !important;
fill-opacity: 1 !important;
stroke: #187ea7 !important;
stroke-width: 3px !important;
}
.uml-stage-region-active.uml-stage-region-outline {
stroke: #187ea7 !important;
stroke-width: 3px !important;
}
.uml-stage-region-active.uml-stage-region-completed.uml-stage-region-bg {
fill: #e7f6ed !important;
}
.uml-stage-region:focus {
outline: none;
stroke: #005f87 !important;
stroke-width: 4px !important;
}
.uml-inline-warning {
display: block;
padding-top: 5px;
color: #8b2d20;
font-size: 11px;
}
.uml-stage { .uml-stage {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
@@ -227,6 +289,50 @@
grid-column: 2; grid-column: 2;
font-size: 11px; font-size: 11px;
} }
.uml-approval {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 0 8px 8px;
padding-top: 6px;
border-top: 1px solid #d2d2d2;
color: #555;
font-size: 11px;
}
.uml-approval button {
flex: 0 0 auto;
border: 1px solid #477a5d;
border-radius: 2px;
background: #edf8f1;
color: #174e30;
padding: 3px 7px;
font: 600 10px/1.25 system-ui, sans-serif;
cursor: pointer;
}
.uml-approval button:disabled {
border-color: #bbb;
background: #f3f3f3;
color: #888;
cursor: not-allowed;
}
.task-conclusion {
margin: 8px 0 0;
padding: 7px 9px;
border: 1px solid #315f78;
background: #f3f9fc;
}
.task-conclusion > strong {
display: block;
margin-bottom: 3px;
color: #315f78;
font:
700 10px/1.2 ui-monospace,
monospace;
}
.task-conclusion p {
margin: 0;
}
.asset-source-link { .asset-source-link {
display: inline-block; display: inline-block;
margin: 5px 0; margin: 5px 0;
@@ -320,6 +426,7 @@
} }
.uml-actions, .uml-actions,
.uml-stage, .uml-stage,
.uml-approval,
.asset-source-link, .asset-source-link,
.lesson-progress-dock, .lesson-progress-dock,
.lesson-progress-control { .lesson-progress-control {
+15 -1
View File
@@ -663,7 +663,17 @@
"id": { "type": "string", "minLength": 1 }, "id": { "type": "string", "minLength": 1 },
"label": { "type": "string", "minLength": 1 }, "label": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 }, "description": { "type": "string", "minLength": 1 },
"code_ref": { "type": "string" } "code_ref": { "type": "string" },
"progress_id": {
"type": "string",
"minLength": 1,
"description": "Identyfikator zatwierdzenia etapu zapisywany w JSON-ie przebiegu zajęć."
},
"svg_label": {
"type": "string",
"minLength": 1,
"description": "Początek etykiety grupy PlantUML zaznaczanej w inline SVG."
}
}, },
"additionalProperties": false "additionalProperties": false
} }
@@ -724,6 +734,10 @@
"criterion": { "criterion": {
"type": "string" "type": "string"
}, },
"conclusion_tex": {
"type": "string",
"description": "Końcowy wniosek renderowany po zasobach sekcji."
},
"title": { "title": {
"type": "string", "type": "string",
"minLength": 1 "minLength": 1
+41 -8
View File
@@ -765,6 +765,7 @@ def render_preamble(data: dict) -> list[str]:
r"\newtcolorbox{ESCTaskFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black,boxsep=0pt,left=1.4mm,right=1.4mm,top=0.8mm,bottom=0.8mm,colbacktitle=black!7,coltitle=black,title={\ttfamily\bfseries TASK\quad #1}}", r"\newtcolorbox{ESCTaskFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black,boxsep=0pt,left=1.4mm,right=1.4mm,top=0.8mm,bottom=0.8mm,colbacktitle=black!7,coltitle=black,title={\ttfamily\bfseries TASK\quad #1}}",
r"\newtcolorbox{ESCBlockFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black!55,boxsep=0pt,left=1.0mm,right=1.0mm,top=0.6mm,bottom=0.6mm,colbacktitle=black!4,coltitle=black,title={\ttfamily\bfseries BLOCK\quad #1}}", r"\newtcolorbox{ESCBlockFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black!55,boxsep=0pt,left=1.0mm,right=1.0mm,top=0.6mm,bottom=0.6mm,colbacktitle=black!4,coltitle=black,title={\ttfamily\bfseries BLOCK\quad #1}}",
r"\newtcolorbox{ESCStepFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black!28,boxsep=0pt,left=0.8mm,right=0.8mm,top=0.45mm,bottom=0.45mm,colbacktitle=black!2,coltitle=black,title={\ttfamily STEP\quad #1}}", r"\newtcolorbox{ESCStepFrame}[1]{enhanced,breakable,arc=0pt,boxrule=0.35pt,colback=white,colframe=black!28,boxsep=0pt,left=0.8mm,right=0.8mm,top=0.45mm,bottom=0.45mm,colbacktitle=black!2,coltitle=black,title={\ttfamily STEP\quad #1}}",
r"\newtcolorbox{ESCConclusionFrame}{enhanced,breakable,arc=0pt,boxrule=0.45pt,colback=blue!2,colframe=blue!45!black,boxsep=0pt,left=1.2mm,right=1.2mm,top=0.7mm,bottom=0.7mm,colbacktitle=blue!7,coltitle=black,title={\ttfamily\bfseries WNIOSEK}}",
"", "",
] ]
@@ -971,7 +972,6 @@ def render_task_flow_tex(task_ref: str, task: dict) -> list[str]:
def render_tasks(section: dict, data: dict) -> list[str]: def render_tasks(section: dict, data: dict) -> list[str]:
tasks = data["tasks"] tasks = data["tasks"]
lines: list[str] = [] lines: list[str] = []
legacy_refs: list[str] = []
for index, task_ref in enumerate(section["task_refs"]): for index, task_ref in enumerate(section["task_refs"]):
if index: if index:
# Kolejny task jest niezależnym etapem karty: jego ramka ma # Kolejny task jest niezależnym etapem karty: jego ramka ma
@@ -981,12 +981,22 @@ def render_tasks(section: dict, data: dict) -> list[str]:
if task.get("flow"): if task.get("flow"):
lines.extend(render_task_flow_tex(task_ref, task)) lines.extend(render_task_flow_tex(task_ref, task))
else: else:
legacy_refs.append(task_ref) lines.extend(
if legacy_refs: [
lines.append(r"\begin{enumerate}[label=\textbf{\arabic*.}]") rf"\begin{{enumerate}}[label=\textbf{{\arabic*.}},start={index + 1}]",
for task_ref in legacy_refs: rf" \item {task['prompt_tex']}",
lines.append(rf" \item {tasks[task_ref]['prompt_tex']}") r"\end{enumerate}",
lines.append(r"\end{enumerate}") ]
)
return lines
def render_task_conclusions_tex(section: dict, data: dict) -> list[str]:
lines: list[str] = []
for task_ref in section.get("task_refs", []) or []:
conclusion = str(data["tasks"][task_ref].get("conclusion_tex", "")).strip()
if conclusion:
lines.extend([r"\begin{ESCConclusionFrame}", conclusion, r"\end{ESCConclusionFrame}"])
return lines return lines
@@ -1844,6 +1854,7 @@ def render_sections(data: dict) -> list[str]:
else: else:
lines.append(section.get("content_tex", "")) lines.append(section.get("content_tex", ""))
lines.extend(render_section_assets_tex(section, data)) lines.extend(render_section_assets_tex(section, data))
lines.extend(render_task_conclusions_tex(section, data))
lines.append(r"\ESCSectionBlockEnd") lines.append(r"\ESCSectionBlockEnd")
lines.append("") lines.append("")
return lines return lines
@@ -2801,6 +2812,24 @@ def render_tasks_html(section: dict, data: dict, equation_numbers: dict[str, int
return f'<div class="tasks">{"".join(items)}</div>' return f'<div class="tasks">{"".join(items)}</div>'
def render_task_conclusions_html(
section: dict,
data: dict,
equation_numbers: dict[str, int],
figure_numbers: dict[str, int],
) -> str:
items: list[str] = []
for task_ref in section.get("task_refs", []) or []:
conclusion = str(data["tasks"][task_ref].get("conclusion_tex", "")).strip()
if conclusion:
items.append(
'<aside class="task-conclusion"><strong>WNIOSEK</strong>'
+ render_latex_fragment_html(conclusion, equation_numbers, figure_numbers)
+ "</aside>"
)
return "".join(items)
def render_learning_tree_overview_html(data: dict) -> str: def render_learning_tree_overview_html(data: dict) -> str:
requirements = data.get("educational_requirements", {}) requirements = data.get("educational_requirements", {})
chunks: list[str] = [] chunks: list[str] = []
@@ -2910,6 +2939,7 @@ def render_main_html(data: dict) -> str:
else render_latex_fragment_html(section.get("content_tex", ""), equation_numbers, figure_numbers) else render_latex_fragment_html(section.get("content_tex", ""), equation_numbers, figure_numbers)
) )
content += render_section_assets_html(section, figure_numbers) content += render_section_assets_html(section, figure_numbers)
content += render_task_conclusions_html(section, data, equation_numbers, figure_numbers)
left_margin, right_margin = render_section_margins_html(section, data) left_margin, right_margin = render_section_margins_html(section, data)
task_identity = section.get("task_identity", {}) or {} task_identity = section.get("task_identity", {}) or {}
identity_html = "" identity_html = ""
@@ -3026,7 +3056,7 @@ p{font-size:16px;margin:0 0 3mm}.pdf-main ul{font-size:16px;margin:2mm 0 3mm 7mm
figure{margin:24px 0;text-align:center}figure img{max-width:100%;display:block;margin:0 auto;border:0;border-radius:0}figcaption{font-size:14px;color:#444;margin-top:8px;text-align:left}.card-asset.asset-screenshot img{border:1px solid #aaa}.card-asset a{display:block;cursor:zoom-in} figure{margin:24px 0;text-align:center}figure img{max-width:100%;display:block;margin:0 auto;border:0;border-radius:0}figcaption{font-size:14px;color:#444;margin-top:8px;text-align:left}.card-asset.asset-screenshot img{border:1px solid #aaa}.card-asset a{display:block;cursor:zoom-in}
.table-wrap{overflow:auto;margin:14px 0}table{border-collapse:collapse;width:100%;font-size:16px}th,td{border:1px solid #111;padding:5px 8px;text-align:left;vertical-align:top}th{font-weight:400;background:white} .table-wrap{overflow:auto;margin:14px 0}table{border-collapse:collapse;width:100%;font-size:16px}th,td{border:1px solid #111;padding:5px 8px;text-align:left;vertical-align:top}th{font-weight:400;background:white}
.empty-check{display:inline-block;width:1em;height:1em;border:1px solid #111;vertical-align:-.12em}.answer-line,.dotfill{display:block;border-bottom:1px dotted #111;height:1.45em;min-width:10em}.write-row{display:block;min-height:2.8em} .empty-check{display:inline-block;width:1em;height:1em;border:1px solid #111;vertical-align:-.12em}.answer-line,.dotfill{display:block;border-bottom:1px dotted #111;height:1.45em;min-width:10em}.write-row{display:block;min-height:2.8em}
code{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}.tasks{font-size:16px}.task-legacy{margin:2.5mm 0}.task-legacy>strong{display:block;font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#555}.task-flow{border:1px solid #222;margin:1mm 0 2mm;background:#fff}.task-flow-header{display:grid;grid-template-columns:auto 1fr auto;align-items:baseline;gap:6px;padding:5px 7px;background:#eaeaea;border-bottom:1px solid #222}.task-flow-header span{font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#222}.task-flow-header h3{font-size:18px;margin:0}.task-flow-header code{font-size:8px;color:#555}.task-block{margin:6px 7px;padding:5px 6px;border:1px solid #777;background:#fff}.task-block header,.task-exercise header{display:flex;align-items:baseline;gap:5px;margin-bottom:3px}.task-block header span,.task-exercise header span{font:700 9px/1.2 "Latin Modern Mono",ui-monospace,monospace;text-transform:uppercase;color:#555}.task-block h4,.task-exercise h4{font-size:16px;margin:0}.block-steps{margin:4px 0 0 16px!important;padding:0;display:grid;gap:3px}.block-steps li{margin:0;border:1px solid #b9b9b9;padding:4px 5px;background:#fff}.block-steps li::marker{font-family:"Latin Modern Mono",ui-monospace,monospace;font-weight:700}.block-steps li p:last-child{margin-bottom:0}.task-exercise{margin:6px 7px;border:1px solid #777;border-left:1px solid #222;padding:5px 6px;background:#fafafa}.task-exercise header{flex-wrap:wrap}.exercise-based-on{margin-left:auto;font-size:8px;color:#777}.exercise-evidence{border-top:1px dashed #bbb;margin-top:5px;padding-top:4px}.exercise-evidence>strong{font-size:11px;text-transform:uppercase}.exercise-criterion{font-size:13px;margin:4px 0 0}.task-flow>footer{padding:5px 7px;border-top:1px solid #222;font-size:12px;background:#f6f6f6}.we-block{border-top:1px solid #ddd;padding:8px 0}.we-block summary{cursor:pointer}.dictionary-sheet .pdf-margin{display:none} code{font-family:"Latin Modern Mono",ui-monospace,SFMono-Regular,Consolas,monospace}.tasks{font-size:16px}.task-legacy{margin:2.5mm 0}.task-legacy>strong{display:block;font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#555}.task-flow{border:1px solid #222;margin:1mm 0 2mm;background:#fff}.task-flow-header{display:grid;grid-template-columns:auto 1fr auto;align-items:baseline;gap:6px;padding:5px 7px;background:#eaeaea;border-bottom:1px solid #222}.task-flow-header span{font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#222}.task-flow-header h3{font-size:18px;margin:0}.task-flow-header code{font-size:8px;color:#555}.task-block{margin:6px 7px;padding:5px 6px;border:1px solid #777;background:#fff}.task-block header,.task-exercise header{display:flex;align-items:baseline;gap:5px;margin-bottom:3px}.task-block header span,.task-exercise header span{font:700 9px/1.2 "Latin Modern Mono",ui-monospace,monospace;text-transform:uppercase;color:#555}.task-block h4,.task-exercise h4{font-size:16px;margin:0}.block-steps{margin:4px 0 0 16px!important;padding:0;display:grid;gap:3px}.block-steps li{margin:0;border:1px solid #b9b9b9;padding:4px 5px;background:#fff}.block-steps li::marker{font-family:"Latin Modern Mono",ui-monospace,monospace;font-weight:700}.block-steps li p:last-child{margin-bottom:0}.task-exercise{margin:6px 7px;border:1px solid #777;border-left:1px solid #222;padding:5px 6px;background:#fafafa}.task-exercise header{flex-wrap:wrap}.exercise-based-on{margin-left:auto;font-size:8px;color:#777}.exercise-evidence{border-top:1px dashed #bbb;margin-top:5px;padding-top:4px}.exercise-evidence>strong{font-size:11px;text-transform:uppercase}.exercise-criterion{font-size:13px;margin:4px 0 0}.task-flow>footer{padding:5px 7px;border-top:1px solid #222;font-size:12px;background:#f6f6f6}.task-conclusion{margin:8px 0;padding:7px 9px;border:1px solid #315f78;background:#f3f9fc}.task-conclusion>strong{display:block;margin-bottom:3px;font:700 10px/1.2 "Latin Modern Mono",ui-monospace,monospace;color:#315f78}.task-conclusion p{margin:0}.we-block{border-top:1px solid #ddd;padding:8px 0}.we-block summary{cursor:pointer}.dictionary-sheet .pdf-margin{display:none}
.inspector{position:fixed;right:14px;bottom:14px;width:min(360px,calc(100vw - 28px));max-height:46vh;overflow:auto;background:white;border:1px solid #cfcfcf;box-shadow:0 4px 18px rgba(0,0,0,.18);border-radius:6px;padding:12px;font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:13px;z-index:30}.inspector h2{font-size:14px;margin:0 0 8px}.inspector .empty{color:#777}.tree-card h3{font-size:15px;margin:6px 0}.tree-card code{display:inline-block;margin:4px 0}.tree-card .line{font-weight:700}.tree-card .line.OG{color:var(--og)}.tree-card .line.TECH{color:var(--tech)}.kw-list{padding-left:18px}.kw-list code{color:var(--kw)} .inspector{position:fixed;right:14px;bottom:14px;width:min(360px,calc(100vw - 28px));max-height:46vh;overflow:auto;background:white;border:1px solid #cfcfcf;box-shadow:0 4px 18px rgba(0,0,0,.18);border-radius:6px;padding:12px;font-family:system-ui,-apple-system,Segoe UI,sans-serif;font-size:13px;z-index:30}.inspector h2{font-size:14px;margin:0 0 8px}.inspector .empty{color:#777}.tree-card h3{font-size:15px;margin:6px 0}.tree-card code{display:inline-block;margin:4px 0}.tree-card .line{font-weight:700}.tree-card .line.OG{color:var(--og)}.tree-card .line.TECH{color:var(--tech)}.kw-list{padding-left:18px}.kw-list code{color:var(--kw)}
@media(max-width:900px){.sheet{display:block;min-height:0}.sheet-content{min-height:0;padding:26px 18px}.pdf-margin{position:static;max-height:none;overflow:visible;margin:10px 0;padding:8px;border:1px solid #ddd}.pdf-margin.left{border-left:3px solid var(--tech)}.pdf-margin.right{border-left:3px solid var(--og)}.hero .byline{float:none;margin:0 0 14px}.meta{grid-template-columns:1fr}.section-heading{flex-wrap:wrap}.task-identity{width:100%;padding:6px 0 0}.inspector{position:static;width:auto;max-height:none;margin:0 12px 18px}.topbar{position:static}.viewer-zoom-status{font-size:16px}} @media(max-width:900px){.sheet{display:block;min-height:0}.sheet-content{min-height:0;padding:26px 18px}.pdf-margin{position:static;max-height:none;overflow:visible;margin:10px 0;padding:8px;border:1px solid #ddd}.pdf-margin.left{border-left:3px solid var(--tech)}.pdf-margin.right{border-left:3px solid var(--og)}.hero .byline{float:none;margin:0 0 14px}.meta{grid-template-columns:1fr}.section-heading{flex-wrap:wrap}.task-identity{width:100%;padding:6px 0 0}.inspector{position:static;width:auto;max-height:none;margin:0 12px 18px}.topbar{position:static}.viewer-zoom-status{font-size:16px}}
@media print{html,body{background:white}.topbar,.inspector{display:none}.paper{padding:0}.sheet{position:relative;display:block;width:auto;min-height:0;margin:0;box-shadow:none;break-after:page;padding:0;overflow:visible;zoom:1!important}.sheet-content{position:relative;width:auto;min-height:0;padding:0;transform:none!important}.sheet:last-child{break-after:auto}.resource-header-enabled .sheet-content{padding:0}.sheet-content>.page-resource-header,.sheet-content>.page-resource-footer{display:none!important}.pdf-margin{position:absolute;top:0;width:18mm;max-height:none;overflow:hidden;margin:0;padding:0;border:0}.pdf-margin.left{left:-19.5mm}.pdf-margin.right{right:-19.5mm}.task-block,.task-exercise,.block-steps li,figure{break-inside:avoid}} @media print{html,body{background:white}.topbar,.inspector{display:none}.paper{padding:0}.sheet{position:relative;display:block;width:auto;min-height:0;margin:0;box-shadow:none;break-after:page;padding:0;overflow:visible;zoom:1!important}.sheet-content{position:relative;width:auto;min-height:0;padding:0;transform:none!important}.sheet:last-child{break-after:auto}.resource-header-enabled .sheet-content{padding:0}.sheet-content>.page-resource-header,.sheet-content>.page-resource-footer{display:none!important}.pdf-margin{position:absolute;top:0;width:18mm;max-height:none;overflow:hidden;margin:0;padding:0;border:0}.pdf-margin.left{left:-19.5mm}.pdf-margin.right{right:-19.5mm}.task-block,.task-exercise,.block-steps li,figure{break-inside:avoid}}
@@ -6974,6 +7004,9 @@ def react_task_model(
"title": str(task.get("title") or task_ref), "title": str(task.get("title") or task_ref),
"uuid": str(task.get("uuid", "")), "uuid": str(task.get("uuid", "")),
"criterion": str(task.get("criterion", "")), "criterion": str(task.get("criterion", "")),
"conclusion_html": render_latex_fragment_html(
str(task.get("conclusion_tex", "")), equation_numbers, figure_numbers
),
"prompt_html": render_latex_fragment_html( "prompt_html": render_latex_fragment_html(
str(task.get("prompt_tex", "")), equation_numbers, figure_numbers str(task.get("prompt_tex", "")), equation_numbers, figure_numbers
), ),