diff --git a/skills/htmlit/SKILL.md b/skills/htmlit/SKILL.md
index e40ceac..59299a7 100644
--- a/skills/htmlit/SKILL.md
+++ b/skills/htmlit/SKILL.md
@@ -244,6 +244,13 @@ and an invalid diagram shows its source instead of an error graphic), and `htmli
poll` reports a `no-artifact-css` or `mermaid-syntax` warning when it happens - fix
the source when you see one rather than shipping the fallback.
+htmlit also re-reads the raw artifact and reports an `unbalanced-html` warning when
+the markup has a stray or unclosed block tag (a `
`/``/... imbalance).
+The browser silently repairs such markup, so the collapse it causes - an early
+container close that drops the following sections out of your layout wrapper, making
+them render full-width - is invisible in the page but obvious in the source. The
+warning's `offenders` name each stray/unclosed tag with its line; balance them.
+
Prefer **interactive** artifacts so the user can discover content - selectable
option cards that expand a section, a dropdown that switches views, ``
accordions, tabs. Keep interactions **morph-safe** so they survive the live
@@ -361,6 +368,9 @@ small floating menu offers two actions:
such marker, just the browser's own selection. Once **you answer** that comment
(tagging your `data-htmlit-answer` block with its `commentId`), the anchor turns
into a solid underline and **clicking it scrolls to and flashes your answer**.
+ The jump is two-way: clicking the answer block's **"You asked:" header** scrolls
+ back to (and flashes) the anchored spot, so the reader can hop between the question
+ and its answer in either direction.
Before it is answered, clicking the anchor offers **Ask agent / Remove**. Anchors
are saved with the review but are a live-review marker only - stripped from your
snapshot and from the HTML/PDF exports.
diff --git a/skills/htmlit/client/annotate.js b/skills/htmlit/client/annotate.js
index 9f2502d..aafd553 100644
--- a/skills/htmlit/client/annotate.js
+++ b/skills/htmlit/client/annotate.js
@@ -4,7 +4,7 @@ import { api, cssPath, inChrome } from "./util.js";
import { highlightNew } from "./content.js";
import { renderPills } from "./chat.js";
import { clipRectsToFrame, rectsToArray, renderHighlights } from "./overlays.js";
-import { ceClear, ceText, rebuildRail } from "./rail.js";
+import { ceClear, ceText, rebuildRail, anchorFlashEl, jumpTo } from "./rail.js";
var pending = null;
var selMenuOpen = false;
@@ -468,6 +468,17 @@ export function onArtifactMouseUp(target) {
if (buildHlPending(hl)) openSelMenu();
return;
}
+ // Symmetric to the anchor -> answer jump above: a plain click on an agent answer
+ // block's "You asked:" header (the block element itself, not its body content)
+ // jumps back to the spot the user asked about and flashes it. Clicks on the answer
+ // body target a child element, so the answer text stays selectable and commentable.
+ var ansBlock = el && el.matches && el.matches("[data-htmlit-answer]") ? el : null;
+ if (ansBlock && !inChrome(ansBlock)) {
+ var askId = ansBlock.getAttribute("data-htmlit-answer-for");
+ var askHl = askId ? findHighlight(askId) : null;
+ var askEl = askHl ? anchorFlashEl(askHl) : null;
+ if (askEl) { jumpTo(askEl); clearPending(); return; }
+ }
if (pending || selMenuOpen) clearPending();
}
diff --git a/skills/htmlit/client/diagram.js b/skills/htmlit/client/diagram.js
index 6380222..8da225f 100644
--- a/skills/htmlit/client/diagram.js
+++ b/skills/htmlit/client/diagram.js
@@ -35,6 +35,54 @@ export function mEndpoints(d) {
end: { x: parseFloat(n[n.length - 2]), y: parseFloat(n[n.length - 1]) },
};
}
+// Parse an SVG path into its coordinate pairs plus a replay sequence, so the path
+// can be re-emitted with new coordinates but the SAME commands - which is how we
+// preserve an edge's original curve while moving its endpoints. Every command we
+// support (M/L/C/Q/S/T) takes coordinate pairs, so points map one-to-one onto the
+// numbers. Relative commands, H/V (single coordinate) and arcs (A, mixed params)
+// are unsupported: return null so the caller falls back to a straight rebuild.
+export function mParsePath(d) {
+ if (!d) return null;
+ var toks = d.match(/[a-zA-Z]|-?\d*\.?\d+(?:[eE][-+]?\d+)?/g);
+ if (!toks) return null;
+ var pts = [], seq = [], i = 0;
+ while (i < toks.length) {
+ var t = toks[i];
+ if (/[a-zA-Z]/.test(t)) {
+ if ("MLCQST".indexOf(t) === -1) return null; // relative / H / V / A / Z: bail
+ seq.push({ c: t }); i++;
+ } else {
+ if (i + 1 >= toks.length) return null;
+ pts.push({ x: parseFloat(toks[i]), y: parseFloat(toks[i + 1]) });
+ seq.push({ p: pts.length - 1 }); i += 2;
+ }
+ }
+ return pts.length >= 2 ? { pts: pts, seq: seq } : null;
+}
+// Re-emit a parsed path (from mParsePath) with a fresh set of points, shifting into
+// the element's own frame by subtracting its frame offset (ox, oy).
+export function mPathFromSeq(seq, pts, ox, oy) {
+ var out = "";
+ for (var i = 0; i < seq.length; i++) {
+ if (seq[i].c != null) out += seq[i].c;
+ else { var p = pts[seq[i].p]; out += (p.x - ox) + "," + (p.y - oy) + " "; }
+ }
+ return out;
+}
+// The similarity transform (translate + rotate + uniform scale) that maps the segment
+// p0->p1 onto aS->aT. Applied to every point of a path it rigidly carries the whole
+// shape - its curvature and, for parallel edges, their separation - onto the moved
+// endpoints, so nothing collapses to a straight line. Null when p0==p1 (degenerate).
+export function mFitTransform(p0, p1, aS, aT) {
+ var d0x = p1.x - p0.x, d0y = p1.y - p0.y, l2 = d0x * d0x + d0y * d0y;
+ if (l2 < 1e-6) return null;
+ var d1x = aT.x - aS.x, d1y = aT.y - aS.y;
+ var a = (d1x * d0x + d1y * d0y) / l2, b = (d1y * d0x - d1x * d0y) / l2;
+ return function (p) {
+ var vx = p.x - p0.x, vy = p.y - p0.y;
+ return { x: aS.x + a * vx - b * vy, y: aS.y + b * vx + a * vy };
+ };
+}
export function mRectDist(pt, r) {
// 0 when pt is inside the box; otherwise the straight-line gap to its border.
var dx = Math.max(r.left - pt.x, 0, pt.x - r.right);
@@ -308,23 +356,45 @@ if (box && box.classList && box.classList.contains("mermaid")) {
// g.edgeLabels in a different order than g.edgePaths when self-loops are present,
// and (in the mermaid.run path) the inner label ids are blank. So match every
// non-empty label to the edge whose midpoint it sits closest to.
- function pathMid(el) {
- try { var L = el.getTotalLength(); if (L) { var pt = el.getPointAtLength(L / 2); return { x: pt.x, y: pt.y }; } } catch (e) {}
- var ep = mEndpoints(el.getAttribute("d"));
- return ep ? { x: (ep.start.x + ep.end.x) / 2, y: (ep.start.y + ep.end.y) / 2 } : null;
+ // Sample points along a path, lifted into the common `space` frame. Mermaid places
+ // an edge label ON its edge, so a label's MINIMUM distance to the right path is near
+ // zero - far more reliable for matching than distance to the arc-midpoint, which on
+ // a curved edge sits nowhere near where the label was placed (up by the hub node).
+ function pathSamples(el) {
+ var o = frameOffset(el), pts = [];
+ try {
+ var L = el.getTotalLength();
+ if (L) { for (var i = 0; i <= 24; i++) { var q = el.getPointAtLength((L * i) / 24); pts.push({ x: q.x + o.x, y: q.y + o.y }); } }
+ } catch (e) {}
+ if (!pts.length) { var ep = mEndpoints(el.getAttribute("d")); if (ep) { pts.push({ x: ep.start.x + o.x, y: ep.start.y + o.y }, { x: ep.end.x + o.x, y: ep.end.y + o.y }); } }
+ return pts;
}
var pathEls = Array.prototype.slice.call(svg.querySelectorAll("g.edgePaths > path"));
- var pathMids = pathEls.map(function (el) { return { el: el, mid: pathMid(el), taken: false }; });
+ var pathPts = pathEls.map(pathSamples);
+ var labelEls = Array.prototype.filter.call(svg.querySelectorAll("g.edgeLabels > g.edgeLabel"), function (g) {
+ return (g.textContent || "").trim() && /translate/.test(g.getAttribute("transform") || "");
+ });
+ var labelPos = labelEls.map(function (g) { var o = frameOffset(g), t = mTranslate(g); return { x: t.x + o.x, y: t.y + o.y }; });
+ // Pair labels to paths by GLOBALLY nearest first, not greedily in DOM order: rank
+ // every (label, path) pair by the label's minimum distance to that path, then lock in
+ // each closest pair whose label and path are both still free. Greedy-by-DOM-order (or
+ // matching on the arc-midpoint) mis-bound a label to the wrong edge, so a node drag
+ // then flung that label across the diagram onto an unrelated arrow.
var labelForPath = new Map();
- Array.prototype.forEach.call(svg.querySelectorAll("g.edgeLabels > g.edgeLabel"), function (g) {
- if (!(g.textContent || "").trim() || !/translate/.test(g.getAttribute("transform") || "")) return;
- var pos = mTranslate(g), best = null, bd = Infinity;
- pathMids.forEach(function (pm) {
- if (pm.taken || !pm.mid) return;
- var dx = pm.mid.x - pos.x, dy = pm.mid.y - pos.y, dd = dx * dx + dy * dy;
- if (dd < bd) { bd = dd; best = pm; }
+ var pairs = [];
+ labelPos.forEach(function (lp, li) {
+ pathPts.forEach(function (pts, pi) {
+ var best = Infinity;
+ for (var k = 0; k < pts.length; k++) { var dx = pts[k].x - lp.x, dy = pts[k].y - lp.y, d = dx * dx + dy * dy; if (d < best) best = d; }
+ if (best < Infinity) pairs.push({ li: li, pi: pi, d: best });
});
- if (best) { best.taken = true; labelForPath.set(best.el, g); }
+ });
+ pairs.sort(function (a, b) { return a.d - b.d; });
+ var labelUsed = [], pathUsed = [];
+ pairs.forEach(function (pr) {
+ if (labelUsed[pr.li] || pathUsed[pr.pi]) return;
+ labelUsed[pr.li] = pathUsed[pr.pi] = true;
+ labelForPath.set(pathEls[pr.pi], labelEls[pr.li]);
});
var edges = [];
@@ -338,10 +408,18 @@ if (box && box.classList && box.classList.contains("mermaid")) {
var ep = mEndpoints(el.getAttribute("d"));
var src = ep ? bindEndpoint({ x: ep.start.x + eo.x, y: ep.start.y + eo.y }) : null;
var tgt = ep ? bindEndpoint({ x: ep.end.x + eo.x, y: ep.end.y + eo.y }) : null;
+ // Keep the original path (lifted into the common `space` frame) so a rebuild can
+ // carry its real shape onto the moved endpoints instead of straightening it.
+ var parsed = mParsePath(el.getAttribute("d"));
+ var origPts = parsed ? parsed.pts.map(function (p) { return { x: p.x + eo.x, y: p.y + eo.y }; }) : null;
+ var origLabelTr = lg ? mTranslate(lg) : { x: 0, y: 0 };
var edge = {
el: el, id: strip(el.id), src: src, tgt: tgt, label: lg, selfLoop: !!(src && src === tgt),
origD: el.getAttribute("d") || "", origLabelT: lg ? (lg.getAttribute("transform") || "") : "",
- origLabelTr: lg ? mTranslate(lg) : { x: 0, y: 0 },
+ origLabelTr: origLabelTr,
+ origSeq: parsed ? parsed.seq : null, origPts: origPts,
+ origStart: origPts ? origPts[0] : null, origEnd: origPts ? origPts[origPts.length - 1] : null,
+ origLabelAnchor: lg ? { x: origLabelTr.x + lo.x, y: origLabelTr.y + lo.y } : null,
eo: eo, lo: lo, bend: null,
};
edges.push(edge);
@@ -354,6 +432,17 @@ if (box && box.classList && box.classList.contains("mermaid")) {
if (ti >= 0) nodeEdges[ti].push(edge);
});
+ // Each edge endpoint is pinned to its node's original attach point and moved by how
+ // far that node has been dragged (its delta), so parallel edges keep their fanned-out
+ // starts and arrowheads instead of every one snapping to the single toward-centre
+ // border point. Falls back to that border point only when the path was unparseable.
+ function edgeEnds(e) {
+ var dS = e.src.delta(), dT = e.tgt.delta();
+ return {
+ s: e.origStart ? { x: e.origStart.x + dS.x, y: e.origStart.y + dS.y } : e.src.border(e.tgt.center()),
+ t: e.origEnd ? { x: e.origEnd.x + dT.x, y: e.origEnd.y + dT.y } : e.tgt.border(e.src.center()),
+ };
+ }
function rebuildEdge(e) {
if (!e.src || !e.tgt) return;
if (e.selfLoop) {
@@ -365,12 +454,13 @@ if (box && box.classList && box.classList.contains("mermaid")) {
if (e.label) mSetTranslate(e.label, e.origLabelTr.x + d.x, e.origLabelTr.y + d.y);
return;
}
- var cS = e.src.center(), cT = e.tgt.center();
- var aS = e.src.border(cT), aT = e.tgt.border(cS);
- // Anchors are computed in the common `space` frame; write the path in its own
- // frame (subtract eo) and the label in its frame (subtract lo).
+ var ends = edgeEnds(e), aS = ends.s, aT = ends.t;
+ // Endpoints are in the common `space` frame; write the path in its own frame
+ // (subtract eo) and the label in its frame (subtract lo).
var eo = e.eo, lo = e.lo, lp;
if (e.bend) {
+ // Bow the edge toward the drag point, but keep its pinned endpoints so the
+ // arrowhead stays on its own fanned spot rather than snapping to the centre.
var mx = (aS.x + aT.x) / 2, my = (aS.y + aT.y) / 2;
var vx = aT.x - aS.x, vy = aT.y - aS.y, len = Math.hypot(vx, vy) || 1;
var ux = vx / len, uy = vy / len;
@@ -378,13 +468,43 @@ if (box && box.classList && box.classList.contains("mermaid")) {
e.el.setAttribute("d", "M" + (aS.x - eo.x) + "," + (aS.y - eo.y) + "Q" + (cx - eo.x) + "," + (cy - eo.y) + " " + (aT.x - eo.x) + "," + (aT.y - eo.y));
lp = { x: 0.25 * aS.x + 0.5 * cx + 0.25 * aT.x, y: 0.25 * aS.y + 0.5 * cy + 0.25 * aT.y };
} else {
- e.el.setAttribute("d", "M" + (aS.x - eo.x) + "," + (aS.y - eo.y) + "L" + (aT.x - eo.x) + "," + (aT.y - eo.y));
- lp = { x: (aS.x + aT.x) / 2, y: (aS.y + aT.y) / 2 };
+ // No manual bend: rubber-band the recorded path between the pinned endpoints,
+ // preserving its curve (and parallel-edge separation).
+ var fit = (e.origSeq && e.origStart && e.origEnd) ? mFitTransform(e.origStart, e.origEnd, aS, aT) : null;
+ if (fit) {
+ e.el.setAttribute("d", mPathFromSeq(e.origSeq, e.origPts.map(fit), eo.x, eo.y));
+ lp = e.origLabelAnchor ? fit(e.origLabelAnchor) : { x: (aS.x + aT.x) / 2, y: (aS.y + aT.y) / 2 };
+ } else {
+ e.el.setAttribute("d", "M" + (aS.x - eo.x) + "," + (aS.y - eo.y) + "L" + (aT.x - eo.x) + "," + (aT.y - eo.y));
+ lp = { x: (aS.x + aT.x) / 2, y: (aS.y + aT.y) / 2 };
+ }
}
if (e.label) mSetTranslate(e.label, lp.x - lo.x, lp.y - lo.y);
}
function rebuildNode(node) { var i = nodes.indexOf(node); if (i >= 0) nodeEdges[i].forEach(rebuildEdge); }
+ // On a fresh render, pull every edge label onto the point of its own edge nearest to
+ // where Mermaid dropped it. Mermaid sometimes places a label a little off a curved
+ // edge (e.g. two edges into the same node), and htmlit otherwise only snaps a label
+ // onto its line when that edge is dragged - so a label could sit off its arrow until
+ // touched. This lands them on their lines from the start and updates the label's
+ // recorded origin so later drags stay consistent.
+ function snapLabelsToEdges() {
+ edges.forEach(function (e) {
+ if (!e.label || !e.origLabelAnchor || e.selfLoop) return;
+ var pts = pathSamples(e.el);
+ if (!pts.length) return;
+ var c = e.origLabelAnchor, best = null, bd = Infinity;
+ for (var k = 0; k < pts.length; k++) { var dx = pts[k].x - c.x, dy = pts[k].y - c.y, d = dx * dx + dy * dy; if (d < bd) { bd = d; best = pts[k]; } }
+ if (!best) return;
+ var tx = best.x - e.lo.x, ty = best.y - e.lo.y;
+ e.origLabelAnchor = { x: best.x, y: best.y };
+ e.origLabelTr = { x: tx, y: ty };
+ e.origLabelT = "translate(" + tx + "," + ty + ")";
+ mSetTranslate(e.label, tx, ty);
+ });
+ }
+
function save() {
var snap = { vb: [state.x, state.y, state.w, state.h], nodes: {}, edges: {}, edgeT: {}, labels: {}, bends: {} };
nodes.forEach(function (n) { snap.nodes[n.id] = n.el.getAttribute("transform") || ""; });
@@ -477,9 +597,11 @@ if (box && box.classList && box.classList.contains("mermaid")) {
mSetTranslate(drag.node.el, drag.t0.x + (u.x - drag.start.x), drag.t0.y + (u.y - drag.start.y));
rebuildNode(drag.node);
} else if (drag.type === "edge" && drag.edge.src && drag.edge.tgt && drag.edge.src !== drag.edge.tgt) {
- var e2 = drag.edge, cS = e2.src.center(), cT = e2.tgt.center();
- var mx = (cS.x + cT.x) / 2, my = (cS.y + cT.y) / 2;
- var vx = cT.x - cS.x, vy = cT.y - cS.y, len = Math.hypot(vx, vy) || 1;
+ // Bend relative to the same pinned endpoints rebuildEdge draws between, so the
+ // grabbed label/edge tracks the cursor and its arrowhead never jumps to centre.
+ var e2 = drag.edge, ends2 = edgeEnds(e2), s2 = ends2.s, t2 = ends2.t;
+ var mx = (s2.x + t2.x) / 2, my = (s2.y + t2.y) / 2;
+ var vx = t2.x - s2.x, vy = t2.y - s2.y, len = Math.hypot(vx, vy) || 1;
var ux = vx / len, uy = vy / len;
e2.bend = { along: (u.x - mx) * ux + (u.y - my) * uy, perp: (u.x - mx) * -uy + (u.y - my) * ux };
rebuildEdge(e2);
@@ -613,4 +735,5 @@ if (box && box.classList && box.classList.contains("mermaid")) {
diagramRegistry.push({ svg: svg, fit: fitForExport });
if (diagramLayouts[sourceKey]) applySnap(diagramLayouts[sourceKey]);
+ else snapLabelsToEdges();
}
diff --git a/skills/htmlit/client/exporting.js b/skills/htmlit/client/exporting.js
index 6f8e686..0862609 100644
--- a/skills/htmlit/client/exporting.js
+++ b/skills/htmlit/client/exporting.js
@@ -26,6 +26,10 @@ var ARTIFACT_CSS =
'[data-htmlit-answer]{position:relative;margin:6px 0 16px;padding:12px 15px;border:1px solid rgba(120,120,135,.28);border-left:3px solid #6b5cff;border-radius:0 10px 10px 10px;background:rgba(107,92,255,.055);}' +
'[data-htmlit-answer]::before{content:"\\21B3 You asked: " attr(data-question);display:block;margin:0 0 10px;padding:0 0 8px;font:600 12.5px/1.45 system-ui,-apple-system,sans-serif;color:#5647d6;border-bottom:1px dashed rgba(120,120,135,.32);white-space:pre-wrap;}' +
'[data-htmlit-answer]:not([data-question])::before,[data-htmlit-answer][data-question=""]::before{content:"\\21B3 Answer";}' +
+ // The "You asked:" header of an answered comment is a back-link to where the user
+ // asked (a plain click on it jumps there), so give it a pointer + hover underline.
+ '[data-htmlit-answer][data-htmlit-answer-for]::before{cursor:pointer;}' +
+ '[data-htmlit-answer][data-htmlit-answer-for]:hover::before{text-decoration:underline;}' +
'html.dark [data-htmlit-answer]{background:rgba(124,111,255,.1);border-color:rgba(140,140,160,.28);border-left-color:#8b7cff;}' +
'html.dark [data-htmlit-answer]::before{color:#b7adff;border-bottom-color:rgba(150,150,170,.35);}' +
'@keyframes htmlit-new-pulse{0%{box-shadow:0 0 0 3px rgba(107,92,255,.5);background-color:rgba(107,92,255,.14);}70%{box-shadow:0 0 0 3px rgba(107,92,255,.18);}100%{box-shadow:0 0 0 3px rgba(107,92,255,0);background-color:transparent;}}' +
diff --git a/skills/htmlit/client/rail.js b/skills/htmlit/client/rail.js
index 00dd166..107b094 100644
--- a/skills/htmlit/client/rail.js
+++ b/skills/htmlit/client/rail.js
@@ -41,7 +41,7 @@ export function anchorTopResolved(h) {
// Bring an anchor (or its answer) into view even when it sits in a collapsed
// section: reveal the section, re-place the cards for the new layout, then flash and
// scroll to the target on the next frame (once the reveal has laid out).
-function jumpTo(el) {
+export function jumpTo(el) {
if (!el) return;
revealElement(el);
positionRail();
diff --git a/skills/htmlit/htmlit_core/models.py b/skills/htmlit/htmlit_core/models.py
index db94fe5..16e49b2 100644
--- a/skills/htmlit/htmlit_core/models.py
+++ b/skills/htmlit/htmlit_core/models.py
@@ -61,6 +61,7 @@ class LayoutWarning(TypedDict, total=False):
severity: str
kind: str
+ message: str
offenders: list[str]
diff --git a/skills/htmlit/htmlit_core/server.py b/skills/htmlit/htmlit_core/server.py
index c90d001..17aec86 100644
--- a/skills/htmlit/htmlit_core/server.py
+++ b/skills/htmlit/htmlit_core/server.py
@@ -23,6 +23,7 @@
from .page import build_page_config, inject_client
from .session import Hub, Session
from .sidecar import delete_artifact, remove_sidecar
+from .validation import html_structure_warnings
VERSION = config.source_version()
HUB = Hub()
@@ -341,10 +342,22 @@ def _record_layout_warnings(self, key: str) -> None:
return
raw = self._read_json().get("layout_warnings", [])
warnings: list[LayoutWarning] = raw if isinstance(raw, list) else []
+ # The browser repairs malformed markup before the client runs, so it cannot
+ # report unbalanced tags. Re-read the raw file here (this fires on load and
+ # after every morph) and fold in any structural problems, so the authoring
+ # agent is told to fix the source instead of shipping a collapsed layout.
+ warnings = warnings + self._artifact_structure_warnings(session)
if warnings:
session.push_feedback({"type": FeedbackKind.LAYOUT_WARNINGS.value, "layout_warnings": warnings})
self._json(200, {"ok": True})
+ def _artifact_structure_warnings(self, session: Session) -> list[LayoutWarning]:
+ try:
+ html = Path(session.path).read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ return []
+ return html_structure_warnings(html)
+
def _agent_reply(self) -> None:
data = self._read_json()
session = self._require_session(data.get("key", ""))
diff --git a/skills/htmlit/htmlit_core/validation.py b/skills/htmlit/htmlit_core/validation.py
new file mode 100644
index 0000000..c49035e
--- /dev/null
+++ b/skills/htmlit/htmlit_core/validation.py
@@ -0,0 +1,104 @@
+"""Structural validation of an artifact's raw HTML.
+
+htmlit serves the artifact verbatim, so a malformed document - a stray or unbalanced
+block-container tag - collapses the author's layout instead of htmlit misrendering it:
+an extra ``
`` closes the centring wrapper early and every following section
+escapes it. The browser silently repairs the DOM, so the injected client cannot see
+the original mistake. This stdlib check reads the raw file and reports the imbalance
+back to the authoring agent over the same layout-warnings channel as overflow and
+Mermaid errors, so the fix lands in the source rather than being papered over.
+"""
+
+from __future__ import annotations
+
+from html.parser import HTMLParser
+
+from .models import LayoutWarning
+
+# Block containers that require an explicit end tag (HTML defines no optional-end-tag
+# rule for them). Tracking only these keeps the check free of false positives from
+# elements that legitimately omit their close (