fix(examples): stop label masks being clipped by node fills (#46)

Moves six clipped labels onto free connector segments and adds scripts/verify-geometry.py with tests, CI wiring, ADR 0005, and SKILL.md rule 6 so the defect cannot recur. Fixes #45.
This commit is contained in:
kira-ariaki
2026-08-12 21:50:16 -07:00
committed by GitHub
parent f928399e20
commit 2d4697446b
13 changed files with 365 additions and 22 deletions
+12
View File
@@ -151,6 +151,16 @@ jobs:
id: self_check
run: python scripts/test-self-check.py
- name: Verify label geometry
if: always()
id: geometry
run: python scripts/verify-geometry.py --all
- name: Verify label geometry checker
if: always()
id: geometry_tests
run: python scripts/test-verify-geometry.py
- name: Verify generated icon assets
if: always()
id: icons
@@ -183,4 +193,6 @@ jobs:
echo "| Optional Motion Contract | ${{ steps.motion.outcome == 'success' && '✅ Passed' || (steps.motion.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Docs & Routing Sync | ${{ steps.docs_sync.outcome == 'success' && '✅ Passed' || (steps.docs_sync.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Packaged Self-Check | ${{ steps.self_check.outcome == 'success' && '✅ Passed' || (steps.self_check.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Label Geometry | ${{ steps.geometry.outcome == 'success' && '✅ Passed' || (steps.geometry.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Label Geometry Checker | ${{ steps.geometry_tests.outcome == 'success' && '✅ Passed' || (steps.geometry_tests.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
echo "| Primitive Icons & Documentation | ${{ steps.icons.outcome == 'success' && '✅ Passed' || (steps.icons.outcome == 'skipped' && '⏭️ Skipped' || '❌ Failed') }} |" >> $GITHUB_STEP_SUMMARY
+7 -2
View File
@@ -54,6 +54,8 @@ The helper refuses to run if the Claude and Codex versions already differ. If an
| Every shipped motion template/example | `python3 scripts/verify-motion.py --shipped` |
| Docs/routing sync (description hooks, gallery reachability, README tree) | `python3 scripts/verify-docs-sync.py` |
| Packaged output self-check behaves (pass + adversarial cases) | `python3 scripts/test-self-check.py` |
| Label masks are never clipped by a node painted after them | `python3 scripts/verify-geometry.py --all` |
| Label geometry checker behaves (pass + adversarial cases) | `python3 scripts/test-verify-geometry.py` |
| Generated icon assets are up to date (`icons.html`, `primitive-icons.md`) | `python3 scripts/build-icons.py` then `git diff --exit-code` on the two generated files |
The semantic-pattern gate also caps `skills/diagram-design/SKILL.md` at 40,000 bytes so the installed skill remains practical to load. If that gate fails, reduce duplication or move detail into a routed reference; do not remove routing vocabulary from frontmatter.
@@ -74,7 +76,9 @@ python3 scripts/test-plugin-package.py \
&& python3 scripts/verify-mermaid-import.py \
&& python3 scripts/test-verify-motion.py \
&& python3 scripts/verify-docs-sync.py \
&& python3 scripts/test-self-check.py
&& python3 scripts/test-self-check.py \
&& python3 scripts/verify-geometry.py --all \
&& python3 scripts/test-verify-geometry.py
```
### If a gate fails
@@ -82,6 +86,7 @@ python3 scripts/test-plugin-package.py \
- **`verify-plugin-package.py`:** run the bump helper if the versions did not increase. If packaging validation fails, keep both marketplaces pointed at the repository root and keep the shared skill at `skills/diagram-design/SKILL.md`.
- **`lint-skin.py`:** the failure message names the file, line, and category (`color`, `font-family`, `a11y`, `external-asset`, `pure-black`, `script`). Colors must come from the palette in `skills/diagram-design/references/style-guide.md`; fonts from the allowed list; diagrams must satisfy the accessible SVG contract (see below). The linter also requires the SHA-pinned controller from `template-motion.html` verbatim and rejects remote resources, CSS `@import`, non-fragment CSS `url()`, event handlers, `srcdoc`, executable URLs, and extra scripts.
- **`verify-*.py`:** the extractor's real behavior no longer matches its fixture or the documentation, or the reference/command/prompt wiring drifted. Fix the source of truth — do not widen a test to avoid a failure.
- **`verify-geometry.py`:** a label mask overlaps a node declared later in the document, so the node fill clips the label at render time. Move the label to a free segment of its connector — keep the 6–10px gap from the stroke required by SKILL.md §6, and do not shrink the mask to sneak under the check.
- **Icon assets:** you changed `scripts/vendor/icons/` or `scripts/build-icons.py` and the generated files went stale. Rerun `python3 scripts/build-icons.py` and commit the regenerated files.
Do **not** add a file to `scripts/lint-skin-baseline.txt` to get your example through. The baseline exists only for legacy pre-2.0 examples that legitimately predate the current skin, and it still receives a11y checks.
@@ -121,7 +126,7 @@ Motion is opt-in. Start from `skills/diagram-design/assets/template-motion.html`
## Design decisions (ADRs)
Settled policies live as short records in `docs/adr/` — one pinned motion controller, semantic patterns never expanding the 27-type taxonomy, the reveal-only autoplay rule, and the SKILL.md byte cap with its trigger-rich description requirement. Read the relevant ADR before proposing a change that touches one; when a PR settles a new policy, add an ADR in the same PR.
Settled policies live as short records in `docs/adr/` — one pinned motion controller, semantic patterns never expanding the 27-type taxonomy, the reveal-only autoplay rule, the SKILL.md byte cap with its trigger-rich description requirement, and geometric label placement being verified rather than reviewed. Read the relevant ADR before proposing a change that touches one; when a PR settles a new policy, add an ADR in the same PR.
## Adding a new diagram type
+2 -1
View File
@@ -394,7 +394,8 @@ If you touch the Mermaid import path, `python3 scripts/verify-mermaid-import.py`
it covers all supported grammars, multi-block Markdown, adversarial labels, trust-boundary
behavior, resource caps, named failures, and reference/command wiring.
Docs and routing surfaces are themselves gated: `python3 scripts/verify-docs-sync.py` fails CI if the SKILL.md description loses a type's lexical hook, the gallery can't reach a shipped example, or the README tree names a file that doesn't exist. The skill also ships `skills/diagram-design/scripts/self_check.py` — a distilled output checker installed agents can run on their own generated diagrams; `python3 scripts/test-self-check.py` keeps it honest. Settled design decisions (why one pinned controller, why patterns never add types, the autoplay policy, the SKILL.md byte cap) live as short ADRs in `docs/adr/` — read them before relitigating one, add one when you settle a new policy.
Label placement is gated geometrically: `python3 scripts/verify-geometry.py --all` fails CI when a label mask overlaps a node declared later in the document, because the node fill would clip the text at render time. `python3 scripts/test-verify-geometry.py` keeps that checker honest in both directions.
Docs and routing surfaces are themselves gated: `python3 scripts/verify-docs-sync.py` fails CI if the SKILL.md description loses a type's lexical hook, the gallery can't reach a shipped example, or the README tree names a file that doesn't exist. The skill also ships `skills/diagram-design/scripts/self_check.py` — a distilled output checker installed agents can run on their own generated diagrams; `python3 scripts/test-self-check.py` keeps it honest. Settled design decisions (why one pinned controller, why patterns never add types, the autoplay policy, the SKILL.md byte cap, why label placement is verified geometrically) live as short ADRs in `docs/adr/` — read them before relitigating one, add one when you settle a new policy.
All pull requests and pushes are automatically validated across Linux, Windows, and macOS runners via GitHub Actions CI (`.github/workflows/ci.yml`).
@@ -0,0 +1,39 @@
# ADR 0005 — Label placement is verified geometrically, not by review
**Status:** accepted (v2.3)
## Context
SKILL.md §6 rule 2 keeps an arrow label 6–10px clear of its own connector, and rule 5 keeps
connectors from passing behind non-endpoint boxes. Neither rule constrains a label against a
*node*. Because §5 fixes the paint order as background → zones → arrows → labels → nodes, a
label mask that lands partly inside a node is covered by the node fill: the text renders as a
fragment sitting on the node border.
Nine shipped examples across two types (architecture, swimlane) had shipped this way. Every
existing gate passed on all of them — `lint-skin.py` checks colors, fonts, and the accessible
SVG contract; `self_check.py` checks DOM structure and the motion contract; neither reads
coordinates. The defect is only visible when the file is rendered, so it survived review in
three variants at a time.
## Decision
1. Label placement gets an explicit rule (SKILL.md §6 rule 6) rather than being left to the
author's eye.
2. The rule is enforced by `scripts/verify-geometry.py`, which parses `<rect>` coordinates and
reports a mask that overlaps a node **declared later in the document**. Document order is
the criterion, not overlap alone: a mask over an earlier-painted zone container stays on
top and is legal, and a mask fully inside a node is a badge chip.
3. The checker carries adversarial tests (`scripts/test-verify-geometry.py`) covering both
polarities — a clipped mask must be reported, and the legal cases must not be.
## Consequences
- Geometric contracts in this repo are expressed as checkers with fixtures, in line with how
the import extractors are verified. A rule that only lives in prose is a rule that ships
broken examples.
- The heuristics are shape-based (node ≥ 60×40, mask 20–120 × 8–14) and match the shipped
templates. A future type with markedly different proportions may need the thresholds
revisited — widen them in the checker, and add the case to the adversarial tests.
- The checker does not evaluate the 6–10px connector gap from rule 2, which needs stroke
geometry rather than rectangles. That rule remains a checklist item.
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Adversarial tests for the label geometry verifier (verify-geometry.py)."""
from __future__ import annotations
import importlib.util
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VERIFIER = ROOT / "scripts/verify-geometry.py"
ASSET_DIR = ROOT / "skills/diagram-design/assets"
ARCHITECTURE = ASSET_DIR / "example-architecture.html"
SWIMLANE = ASSET_DIR / "example-swimlane.html"
ZONED = ASSET_DIR / "example-dp-integration.html"
def load_verifier():
spec = importlib.util.spec_from_file_location("verify_geometry", VERIFIER)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
SVG_HEAD = (
'<svg viewBox="0 0 400 200" xmlns="http://www.w3.org/2000/svg" role="img" '
'aria-labelledby="t-title t-desc">'
"<title id=\"t-title\">T</title><desc id=\"t-desc\">T.</desc>"
)
def document(body: str) -> str:
return f"<!DOCTYPE html><html><body>{SVG_HEAD}{body}</svg></body></html>"
def main() -> int:
module = load_verifier()
failures: list[str] = []
def check(label: str, source: str, expect_findings: int) -> None:
with tempfile.TemporaryDirectory() as scratch:
candidate = Path(scratch) / "candidate.html"
candidate.write_text(source, encoding="utf-8")
findings = module.check(candidate)
if len(findings) != expect_findings:
failures.append(
f"{label}: expected {expect_findings} finding(s), got "
f"{len(findings)}: {findings}"
)
else:
print(f"OK: {label}")
def check_file(label: str, path: Path, expect_findings: int) -> None:
findings = module.check(path)
if len(findings) != expect_findings:
failures.append(
f"{label}: expected {expect_findings} finding(s), got "
f"{len(findings)}: {findings}"
)
else:
print(f"OK: {label}")
node = '<rect x="100" y="60" width="160" height="64" rx="6" fill="#f5f5f5"/>'
badge = '<rect x="108" y="68" width="32" height="12" rx="2" fill="#f5f5f5"/>'
zone = '<rect x="80" y="40" width="240" height="120" rx="8" fill="#eee"/>'
# A label mask straddling a node declared later is the defect being caught.
check(
"mask clipped by a later node",
document('<rect x="240" y="80" width="48" height="12" rx="2" fill="#f5f5f5"/>' + node),
1,
)
# Same geometry, but the node is painted first, so the label stays on top.
check(
"mask over an earlier node is legal",
document(node + '<rect x="240" y="80" width="48" height="12" rx="2" fill="#f5f5f5"/>'),
0,
)
# A badge chip fully inside its own node is legal regardless of order.
check("badge chip inside a node", document(badge + node), 0)
# A zone eyebrow overlaps the zone container, which is painted first.
check(
"zone eyebrow on a zone container",
document(zone + '<rect x="160" y="34" width="60" height="12" rx="2" fill="#f5f5f5"/>'),
0,
)
# A mask entirely in open canvas is legal.
check(
"mask clear of every node",
document('<rect x="10" y="10" width="48" height="12" rx="2" fill="#f5f5f5"/>' + node),
0,
)
# Touching within the 1px tolerance is not a finding.
check(
"mask abutting a node edge",
document('<rect x="52" y="80" width="48" height="12" rx="2" fill="#f5f5f5"/>' + node),
0,
)
check_file("shipped architecture example", ARCHITECTURE, 0)
check_file("shipped swimlane example", SWIMLANE, 0)
check_file("shipped zoned example", ZONED, 0)
if failures:
print("\nFAILURES:")
for failure in failures:
print(f" - {failure}")
return 1
print("All label geometry tests passed.")
return 0
if __name__ == "__main__":
sys.exit(main())
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Verify no diagram label mask is clipped by a node painted after it.
SKILL.md §6 keeps an arrow label 6-10px clear of its connector, and §5 fixes the
paint order as background -> zones -> arrows -> labels -> nodes. Nothing keeps a
label mask off a *node*, so a label whose mask lands partly inside a node
rectangle that is painted later gets covered by the node fill: the text renders
as a fragment sitting on the node border.
Paint order is what makes this a defect rather than a stylistic choice:
* A mask overlapping a zone container is fine - zones are painted before labels,
so the label stays on top. Zone eyebrows rely on this.
* A mask overlapping a node declared *later* in the document is clipped by that
node. That is the failure this check reports.
Shape heuristics follow the shipped templates:
* A node is a `<rect>` at least 60x40 - large enough for a title and sublabel.
* A label mask is a `<rect>` 20-120 wide and 8-14 tall - the masking plate that
SKILL.md prescribes for arrow labels and zone eyebrows.
* A mask fully contained in a node is a badge chip (`EXT`, `EDGE`, `ORIG`) and
is legal.
Usage:
python3 scripts/verify-geometry.py --all
python3 scripts/verify-geometry.py skills/diagram-design/assets/example-x.html
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
ASSET_DIR = ROOT / "skills/diagram-design/assets"
RECT_RE = re.compile(
r"<rect\b[^>]*?"
r'\bx="(?P<x>-?[\d.]+)"\s+'
r'y="(?P<y>-?[\d.]+)"\s+'
r'width="(?P<w>[\d.]+)"\s+'
r'height="(?P<h>[\d.]+)"',
re.IGNORECASE,
)
NODE_MIN_W = 60.0
NODE_MIN_H = 40.0
MASK_MIN_W = 20.0
MASK_MAX_W = 120.0
MASK_MIN_H = 8.0
MASK_MAX_H = 14.0
EPSILON = 0.5
class Rect:
__slots__ = ("x", "y", "w", "h", "line", "offset")
def __init__(self, x, y, w, h, line, offset) -> None:
self.x, self.y, self.w, self.h = x, y, w, h
self.line, self.offset = line, offset
@property
def right(self) -> float:
return self.x + self.w
@property
def bottom(self) -> float:
return self.y + self.h
def __repr__(self) -> str:
return f"({self.x:g},{self.y:g} {self.w:g}x{self.h:g})"
def parse_rects(source: str) -> list[Rect]:
rects: list[Rect] = []
for match in RECT_RE.finditer(source):
rects.append(
Rect(
float(match.group("x")),
float(match.group("y")),
float(match.group("w")),
float(match.group("h")),
source.count("\n", 0, match.start()) + 1,
match.start(),
)
)
return rects
def overlap(a: Rect, b: Rect) -> tuple[float, float]:
return (
min(a.right, b.right) - max(a.x, b.x),
min(a.bottom, b.bottom) - max(a.y, b.y),
)
def contained(inner: Rect, outer: Rect) -> bool:
return (
inner.x >= outer.x - EPSILON
and inner.y >= outer.y - EPSILON
and inner.right <= outer.right + EPSILON
and inner.bottom <= outer.bottom + EPSILON
)
def check(path: Path) -> list[str]:
source = path.read_text(encoding="utf-8")
rects = parse_rects(source)
nodes = [r for r in rects if r.w >= NODE_MIN_W and r.h >= NODE_MIN_H]
masks = [
r
for r in rects
if MASK_MIN_W <= r.w <= MASK_MAX_W and MASK_MIN_H <= r.h <= MASK_MAX_H
]
findings: list[str] = []
for mask in masks:
for node in nodes:
if node.offset <= mask.offset:
continue # painted before the label; the label stays on top
dx, dy = overlap(mask, node)
if dx <= 1.0 or dy <= 1.0 or contained(mask, node):
continue
findings.append(
f"{path.name}:{mask.line}: label mask {mask} is clipped by node "
f"{node} declared later at line {node.line} (overlap {dx:g}x{dy:g}px)"
f" - move the label onto a free segment of its connector"
)
break
return findings
def targets(args: argparse.Namespace) -> list[Path]:
if args.all:
return sorted(ASSET_DIR.glob("*.html"))
return [Path(p) for p in args.files]
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("files", nargs="*", help="HTML diagrams to check")
parser.add_argument("--all", action="store_true", help="check every shipped asset")
args = parser.parse_args()
paths = targets(args)
if not paths:
parser.error("pass one or more files, or --all")
findings: list[str] = []
for path in paths:
if not path.exists():
findings.append(f"{path}: file not found")
continue
findings.extend(check(path))
for finding in findings:
print(finding)
print(f"Summary: {len(paths)} file(s) checked, {len(findings)} finding(s).")
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())
+5 -1
View File
@@ -147,6 +147,7 @@ These mark "AI slop" schematics of any type:
| Reproducing Mermaid's renderer layout | Imports automatic spacing and routing instead of making an editorial layout |
| Diagonal / slanted connectors between off-axis nodes | Rounded right-angle (orthogonal) elbows are mandatory — see §6 Mandatory connector rules |
| Arrow label sitting on or touching its connector | Label must have a 6–10px gap above the line so the connector stays visible |
| Arrow label mask overlapping a node box | Nodes paint after labels — the fill clips the text into a fragment on the border. See §6 rule 6 |
| Two connectors overlapping or running on the same path | Each connection must be independently traceable — bridge crossings, offset parallels |
| Two connectors sharing a single attach point on a box | Fan attach points along the edge (≥12px apart) so every arrow is clearly distinct — see §6 rule 4 |
| Connector routed behind a non-endpoint box without need | Reroute around intervening boxes; the dashed-transit exception (§6 rule 5) only applies when an unavoidable intervening box sits on the direct path |
@@ -260,7 +261,7 @@ Don't use the dot pattern when the diagram sits inside a product page, slide, or
### Mandatory connector rules
These five rules are **non-negotiable**. Run the pre-output checklist (§9) to verify before producing any diagram.
These six rules are **non-negotiable**. Run the pre-output checklist (§9) to verify before producing any diagram.
1. **Rounded right-angle (orthogonal) connectors are mandatory.** Never use diagonal `<line>` or straight slanted paths between nodes that don't share an x or y axis. Every bend must be a quarter-arc with `r=8` (or `r=6` minimum for tight layouts). See `references/type-architecture.md` for the elbow-path formula. Reserve plain straight `<line>` only for connections whose endpoints share the same x or y coordinate. Diagonal connectors are an automatic fail.
@@ -282,6 +283,8 @@ These five rules are **non-negotiable**. Run the pre-output checklist (§9) to v
When in doubt, reroute. The exception exists for the narrow case where rerouting is geometrically impossible, not as a shortcut to avoid layout work.
6. **A label mask must not overlap a node drawn after it.** Rule 2 keeps the label off its own connector; this one keeps it off the boxes. Because nodes are painted after labels, a mask that lands partly inside a node is covered by the node fill and the text renders as a fragment sitting on the node border. Place the label on a segment of the connector that runs through open canvas — for a connector leaving a node's right edge, that means clearing the node's `x + width` before the mask starts. A mask fully *inside* a node is a badge chip and is fine; a mask overlapping a zone container is fine too, since zones are painted first. Verify with `python3 scripts/verify-geometry.py <file>`.
### Node box — full pattern
```svg
@@ -459,6 +462,7 @@ Run before producing any diagram.
- [ ] **No two connectors overlap, share a stroke path, or run on top of each other? Crossings use the bridge/hop primitive?**
- [ ] **When several connectors enter or exit the same edge of a box, each has its own attach point (≥12px apart)? No connector hides another?**
- [ ] **No connector passes behind a non-endpoint box, except the unavoidable-intervening-box case (§6 rule 5) — and in that case, the stroke is dashed and the label sits at the visible end?**
- [ ] **No label mask overlaps a node drawn after it? (Node fill would clip the text — §6 rule 6. In this repository, `python3 scripts/verify-geometry.py <file>`.)**
- [ ] Every arrow label has an opaque `fill="#f5f5f5"` rect behind it?
- [ ] Legend is a horizontal bottom strip, not floating?
- [ ] No vertical `writing-mode` text?
@@ -101,11 +101,11 @@
<rect x="368" y="252" width="48" height="12" rx="2" fill="#2d3142"/>
<text x="392" y="262" fill="#f08a59" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">SSR</text>
<rect x="564" y="234" width="60" height="12" rx="2" fill="#2d3142"/>
<text x="594" y="243" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">READ MDX</text>
<rect x="632" y="246" width="60" height="12" rx="2" fill="#2d3142"/>
<text x="662" y="255" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">READ MDX</text>
<rect x="568" y="298" width="44" height="12" rx="2" fill="#2d3142"/>
<text x="590" y="307" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">QUERY</text>
<rect x="632" y="286" width="44" height="12" rx="2" fill="#2d3142"/>
<text x="654" y="295" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">QUERY</text>
<!-- Node: Reader -->
<rect x="40" y="240" width="128" height="64" rx="6" fill="#2d3142"/>
@@ -91,11 +91,11 @@
<rect x="368" y="252" width="48" height="12" rx="2" fill="#f5f5f5"/>
<text x="392" y="262" fill="#eb6c36" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">SSR</text>
<rect x="564" y="234" width="60" height="12" rx="2" fill="#f5f5f5"/>
<text x="594" y="243" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">READ MDX</text>
<rect x="632" y="246" width="60" height="12" rx="2" fill="#f5f5f5"/>
<text x="662" y="255" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">READ MDX</text>
<rect x="568" y="298" width="44" height="12" rx="2" fill="#f5f5f5"/>
<text x="590" y="307" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">QUERY</text>
<rect x="632" y="286" width="44" height="12" rx="2" fill="#f5f5f5"/>
<text x="654" y="295" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">QUERY</text>
<!-- Node: Reader -->
<rect x="40" y="240" width="128" height="64" rx="6" fill="#f5f5f5"/>
@@ -102,11 +102,11 @@
<rect x="368" y="252" width="48" height="12" rx="2" fill="#f5f5f5"/>
<text x="392" y="262" fill="#eb6c36" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">SSR</text>
<rect x="564" y="234" width="60" height="12" rx="2" fill="#f5f5f5"/>
<text x="594" y="243" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">READ MDX</text>
<rect x="632" y="246" width="60" height="12" rx="2" fill="#f5f5f5"/>
<text x="662" y="255" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">READ MDX</text>
<rect x="568" y="298" width="44" height="12" rx="2" fill="#f5f5f5"/>
<text x="590" y="307" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">QUERY</text>
<rect x="632" y="286" width="44" height="12" rx="2" fill="#f5f5f5"/>
<text x="654" y="295" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.08em">QUERY</text>
<!-- Node: Reader -->
<rect x="40" y="240" width="128" height="64" rx="6" fill="#f5f5f5"/>
@@ -103,8 +103,8 @@
<line x1="760" y1="360" x2="800" y2="360" stroke="#bfc0c0" stroke-width="1.2" marker-end="url(#arrow)"/>
<!-- Arrow labels -->
<rect x="452" y="140" width="60" height="12" rx="2" fill="#2d3142"/>
<text x="482" y="150" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.12em">HANDOFF</text>
<rect x="478" y="140" width="60" height="12" rx="2" fill="#2d3142"/>
<text x="508" y="150" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.12em">HANDOFF</text>
<rect x="548" y="228" width="52" height="12" rx="2" fill="#393e53"/>
<text x="574" y="238" fill="#bfc0c0" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.12em">REVISE</text>
@@ -84,8 +84,8 @@
<line x1="760" y1="360" x2="800" y2="360" stroke="#4f5d75" stroke-width="1.2" marker-end="url(#arrow)"/>
<!-- Arrow labels -->
<rect x="452" y="140" width="60" height="12" rx="2" fill="#f5f5f5"/>
<text x="482" y="150" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.12em">HANDOFF</text>
<rect x="478" y="140" width="60" height="12" rx="2" fill="#f5f5f5"/>
<text x="508" y="150" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.12em">HANDOFF</text>
<rect x="548" y="228" width="52" height="12" rx="2" fill="#ececec"/>
<text x="574" y="238" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.12em">REVISE</text>
@@ -103,8 +103,8 @@
<line x1="760" y1="360" x2="800" y2="360" stroke="#4f5d75" stroke-width="1.2" marker-end="url(#arrow)"/>
<!-- Arrow labels -->
<rect x="452" y="140" width="60" height="12" rx="2" fill="#f5f5f5"/>
<text x="482" y="150" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.12em">HANDOFF</text>
<rect x="478" y="140" width="60" height="12" rx="2" fill="#f5f5f5"/>
<text x="508" y="150" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.12em">HANDOFF</text>
<rect x="548" y="228" width="52" height="12" rx="2" fill="#ececec"/>
<text x="574" y="238" fill="#4f5d75" font-size="8" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.12em">REVISE</text>