diff --git a/react/src/main.tsx b/react/src/main.tsx index d8996b4..e779c36 100644 --- a/react/src/main.tsx +++ b/react/src/main.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import "./react.css"; @@ -15,6 +15,8 @@ type UmlStage = { label: string; description: string; code_ref?: string; + progress_id?: string; + svg_label?: string; }; type InteractiveAsset = { kind: "allocator-memory-flow" | "uml-sequence"; @@ -47,6 +49,7 @@ type Task = { title: string; uuid?: string; criterion: string; + conclusion_html: string; prompt_html?: string; flow: FlowBlock[]; }; @@ -66,6 +69,7 @@ type Progress = { items: Record; summary: { total: number; counts: Record }; }; +type SetProgressStatus = (id: string, status: Status) => Promise; type CardModel = { schema: string; card: Record; @@ -396,12 +400,20 @@ function InteractiveMemoryFlow({ function InteractiveUmlSequence({ config, asset, + progress, + onSetStatus, }: { config: InteractiveAsset; asset: Asset; + progress: Progress | null; + onSetStatus: SetProgressStatus; }) { const stages = config.stages ?? []; const storageKey = `${config.storage_key ?? asset.label}-uml-stage`; + const canvasRef = useRef(null); + const [svgMarkup, setSvgMarkup] = useState(""); + const [svgFailed, setSvgFailed] = useState(false); + const [approvalBusy, setApprovalBusy] = useState(false); const [stageIndex, setStageIndex] = useState(() => Math.max( 0, @@ -415,6 +427,156 @@ function InteractiveUmlSequence({ () => localStorage.setItem(storageKey, String(stageIndex)), [stageIndex, storageKey], ); + useEffect(() => { + let current = true; + setSvgFailed(false); + fetch(asset.href, { cache: "no-store" }) + .then((response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.text(); + }) + .then((markup) => { + const document = new DOMParser().parseFromString( + markup, + "image/svg+xml", + ); + const svg = document.documentElement; + if (svg.nodeName.toLowerCase() !== "svg" || document.querySelector("parsererror")) { + throw new Error("Niepoprawny SVG"); + } + document.querySelectorAll("script, foreignObject").forEach((node) => node.remove()); + svg.removeAttribute("width"); + svg.removeAttribute("height"); + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + if (current) setSvgMarkup(svg.outerHTML); + }) + .catch(() => { + if (current) setSvgFailed(true); + }); + return () => { + current = false; + }; + }, [asset.href]); + useEffect(() => { + const svg = canvasRef.current?.querySelector("svg"); + if (!svg) return; + + svg.setAttribute("role", "img"); + svg.setAttribute("aria-label", asset.alt); + svg + .querySelectorAll("[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("text")); + const rects = Array.from(svg.querySelectorAll("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(".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) => { + if (event.key !== "Enter" && event.key !== " ") return; + const target = event.target as SVGRectElement; + const next = Number(target.dataset?.umlStage); + if (!Number.isInteger(next)) return; + event.preventDefault(); + setStageIndex(next); + }; const stage = stages[stageIndex]; return (
@@ -428,21 +590,45 @@ function InteractiveUmlSequence({ ))}
- selectStageAtPoint(event.clientX, event.clientY)} + onKeyDown={handleSvgKeyDown} > - {asset.alt} - + {svgMarkup ? ( +
+ ) : ( + {asset.alt} + )} + {svgFailed && ( + + Widok statyczny — nie udało się wczytać interaktywnego SVG. + + )} +
{stage && ( )} + {stage?.progress_id && ( +
+ + {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ęć."} + + +
+ )} ); } -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 isInteractive = Boolean(interactiveKind); return ( @@ -463,7 +691,12 @@ function AssetFigure({ asset, number }: { asset: Asset; number: number }) { id={asset.label.replace(":", "-")} > {interactiveKind === "uml-sequence" ? ( - + ) : interactiveKind === "allocator-memory-flow" ? ( <> void; + onSetStatus: SetProgressStatus; figureOffset: number; }) { return ( @@ -579,8 +814,19 @@ function SectionPage({ key={asset.label} asset={asset} number={figureOffset + index + 1} + progress={progress} + onSetStatus={onSetStatus} /> ))} + {section.tasks.map( + (task) => + task.conclusion_html && ( + + ), + )} ); } @@ -655,17 +901,12 @@ function App({ model }: { model: CardModel }) { window.addEventListener("wheel", wheel, { passive: false }); return () => window.removeEventListener("wheel", wheel); }, []); - const cycle = async (id: string, current: Status) => { - const next: Record = { - not_started: "discussed", - discussed: "completed", - completed: "not_started", - }; + const setStatus: SetProgressStatus = async (id, status) => { try { const response = await fetch(`/api/progress/${encodeURIComponent(id)}`, { method: "PUT", headers: { "content-type": "application/json" }, - body: JSON.stringify({ status: next[current] }), + body: JSON.stringify({ status }), }); if (!response.ok) throw new Error(await response.text()); setProgress((await response.json()).progress); @@ -676,6 +917,14 @@ function App({ model }: { model: CardModel }) { ); } }; + const cycle = (id: string, current: Status) => { + const next: Record = { + not_started: "discussed", + discussed: "completed", + completed: "not_started", + }; + void setStatus(id, next[current]); + }; let figureCount = 0; return ( <> @@ -691,6 +940,7 @@ function App({ model }: { model: CardModel }) { section={section} progress={progress} onCycle={cycle} + onSetStatus={setStatus} figureOffset={offset} /> ); diff --git a/react/src/react.css b/react/src/react.css index 6d4e860..bc70adb 100644 --- a/react/src/react.css +++ b/react/src/react.css @@ -167,8 +167,8 @@ display: flex; align-items: center; justify-content: space-between; - gap: 12px; - padding: 8px 10px; + gap: 8px; + padding: 6px 8px; border-bottom: 1px solid #bbb; background: #fafafa; } @@ -186,31 +186,93 @@ } .uml-actions { display: flex; - gap: 5px; + gap: 3px; flex-wrap: wrap; + justify-content: flex-end; } .uml-actions button { - border: 1px solid #777; - background: #fff; - padding: 4px 8px; + border: 1px solid #aaa; + border-radius: 2px; + background: #fcfcfc; + color: #444; + padding: 2px 6px; + font: + 600 10px/1.25 ui-monospace, + monospace; 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"] { - border-color: #315f78; - background: #eaf4fa; - font-weight: 700; + border-color: #287495; + background: #edf7fb; + color: #174d64; + box-shadow: inset 0 0 0 1px #b9dce9; } .uml-canvas { display: block; padding: 8px; text-align: center; } -.uml-canvas img { +.uml-canvas img, +.uml-svg-host svg { display: block; - width: 100%; + width: 100% !important; + height: auto !important; max-height: 760px; 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 { display: grid; grid-template-columns: auto 1fr; @@ -227,6 +289,50 @@ grid-column: 2; 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 { display: inline-block; margin: 5px 0; @@ -320,6 +426,7 @@ } .uml-actions, .uml-stage, + .uml-approval, .asset-source-link, .lesson-progress-dock, .lesson-progress-control { diff --git a/schemas/card-source.schema.json b/schemas/card-source.schema.json index 1f44432..9c0b010 100644 --- a/schemas/card-source.schema.json +++ b/schemas/card-source.schema.json @@ -663,7 +663,17 @@ "id": { "type": "string", "minLength": 1 }, "label": { "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 } @@ -724,6 +734,10 @@ "criterion": { "type": "string" }, + "conclusion_tex": { + "type": "string", + "description": "Końcowy wniosek renderowany po zasobach sekcji." + }, "title": { "type": "string", "minLength": 1 diff --git a/tools/render_card.py b/tools/render_card.py index 6dc35cf..fbd1c69 100644 --- a/tools/render_card.py +++ b/tools/render_card.py @@ -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{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{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]: tasks = data["tasks"] lines: list[str] = [] - legacy_refs: list[str] = [] for index, task_ref in enumerate(section["task_refs"]): if index: # 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"): lines.extend(render_task_flow_tex(task_ref, task)) else: - legacy_refs.append(task_ref) - if legacy_refs: - lines.append(r"\begin{enumerate}[label=\textbf{\arabic*.}]") - for task_ref in legacy_refs: - lines.append(rf" \item {tasks[task_ref]['prompt_tex']}") - lines.append(r"\end{enumerate}") + lines.extend( + [ + rf"\begin{{enumerate}}[label=\textbf{{\arabic*.}},start={index + 1}]", + rf" \item {task['prompt_tex']}", + 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 @@ -1844,6 +1854,7 @@ def render_sections(data: dict) -> list[str]: else: lines.append(section.get("content_tex", "")) lines.extend(render_section_assets_tex(section, data)) + lines.extend(render_task_conclusions_tex(section, data)) lines.append(r"\ESCSectionBlockEnd") lines.append("") return lines @@ -2801,6 +2812,24 @@ def render_tasks_html(section: dict, data: dict, equation_numbers: dict[str, int return f'
{"".join(items)}
' +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( + '" + ) + return "".join(items) + + def render_learning_tree_overview_html(data: dict) -> str: requirements = data.get("educational_requirements", {}) 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) ) 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) task_identity = section.get("task_identity", {}) or {} 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} .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} -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)} @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}} @@ -6974,6 +7004,9 @@ def react_task_model( "title": str(task.get("title") or task_ref), "uuid": str(task.get("uuid", "")), "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( str(task.get("prompt_tex", "")), equation_numbers, figure_numbers ),