MakerFLOSS/notes/dev/plans/2026-06-24-rack-presentation.md

702 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Rack Presentation Improvements Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the generated rack elevation interactive (inline SVG, clickable boxes, hover tooltips) with status-encoded borders and a legend, and theme the mermaid power/network graphs (colour-by-kind + clickable nodes).
**Architecture:** Pure rendering upgrade in `scripts/gen_rack.py`. New module-level helpers (`_host_url`, `_status_stroke`, `_stroke_attrs`, `_placement`, `_tooltip`); `render_svg` wraps each device in `<a>`+`<title>`, encodes status via border, adds a legend, both-gutter U-numbers, and column frames; `render_page` inlines the SVG (via `md_in_html`) with a download link; `render_power`/`render_network` add per-node `style` + `click`. Each task regenerates the artifacts so the drift guard stays green.
**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest, MkDocs Material (`md_in_html`, mermaid), Forgejo Actions CI.
**Spec:** `notes/dev/specs/2026-06-24-rack-presentation-design.md`.
## Global Constraints
- Scripts use **stdlib + PyYAML only**; deterministic and offline. `re`/`yaml` already imported — no new imports.
- Links/clicks use **root-relative final URLs** `/hardware/<host>/` (mkdocs does not rewrite raw hrefs inside inline SVG or mermaid; `use_directory_urls` defaults true; site at domain root).
- Status border mapping (verbatim): in-use `#333333`/1.5/solid; staging `#333333`/1.5/dash `4 2`; broken `#e15759`/3/solid; spare `#bbbbbb`/1.5/solid; donated `#bbbbbb`/1.5/solid; other → `#333333`/1.5/solid. Fill stays the kind colour.
- The inline elevation block is `<div class="rack-elevation">` + the SVG markup (no blank lines inside) + `</div>`; **no** `markdown` attribute (raw passthrough). The standalone `rack01-elevation.svg` is still generated, plus a `[Download SVG](rack01-elevation.svg)` link.
- Tooltip text: `"<host> · <kind> · <status> · cluster: <cluster|—> · <placement>"`, `_esc`-applied; placement = U-range / `0U <face>` / `<shelf>/<face>/slot <slot>`.
- Reuse `item()`/`_write_item()`/`shelf()` test helpers; do not redefine them.
- Each task ends with regenerated `docs/infrastructure/racks/rack01.{md,svg}`, `make test` green, `mkdocs build --strict` passing, and `make docs-check` exit 0.
- **No edits** to `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or `scripts/overview_config.yml`.
---
### Task 1: Helpers + interactive elevation (inline, links, tooltips, status borders)
**Files:**
- Modify: `scripts/gen_rack.py` (helpers; `render_svg` `<a>`/`<title>`/status borders + `style=` on `<svg>`; `render_page` inline + download link)
- Modify: `tests/test_gen_rack.py` (append tests)
- Regenerate: `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg`
**Interfaces:**
- Produces (module-level, used by Task 2): `_host_url(host) -> str`, `_status_stroke(status) -> tuple[str, float, str]`, `_stroke_attrs(status) -> str`, `_placement(fm) -> str`, `_tooltip(fm) -> str`.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_svg_boxes_link_to_host_pages():
items = [item(hostname="srv04", rack_u=5, u_height=1, rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert '<a href="/hardware/srv04/">' in svg
assert "<title>" in svg
def test_svg_status_border_styles():
staging = gen_rack.render_svg("rack01", [
item(hostname="a", rack_u=1, u_height=1, rack_face="front",
status="staging")])
broken = gen_rack.render_svg("rack01", [
item(hostname="b", rack_u=1, u_height=1, rack_face="front",
status="broken")])
assert 'stroke-dasharray="4 2"' in staging
assert 'stroke="#e15759"' in broken and 'stroke-width="3"' in broken
def test_svg_tooltip_has_cluster_and_placement():
items = [item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
status="staging", cluster="tappaas")]
svg = gen_rack.render_svg("rack01", items)
assert "cluster: tappaas" in svg
assert "U1" in svg
def test_svg_has_responsive_style():
svg = gen_rack.render_svg("rack01", [])
assert "max-width:100%" in svg
def test_render_page_inlines_svg_with_download_link():
items = [item(hostname="srv04", rack_u=5, u_height=1, rack_face="front")]
page = gen_rack.render_page("rack01", items)
assert '<div class="rack-elevation">' in page
assert "<svg" in page
assert "[Download SVG](rack01-elevation.svg)" in page
assert "![Rack rack01 elevation]" not in page
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — the new assertions fail (`<a href` / `<title>` / `max-width` / inline `<div>` not present yet).
- [ ] **Step 3: Add the helper functions after `_esc` in `scripts/gen_rack.py`**
Insert immediately after the `_esc` function (before `_sorted_items`):
```python
STATUS_STROKE: dict[str, tuple[str, float, str]] = {
"in-use": ("#333333", 1.5, ""),
"staging": ("#333333", 1.5, "4 2"),
"broken": ("#e15759", 3, ""),
"spare": ("#bbbbbb", 1.5, ""),
"donated": ("#bbbbbb", 1.5, ""),
}
DEFAULT_STATUS_STROKE: tuple[str, float, str] = ("#333333", 1.5, "")
def _status_stroke(status: object) -> tuple[str, float, str]:
return STATUS_STROKE.get(status, DEFAULT_STATUS_STROKE)
def _stroke_attrs(status: object) -> str:
stroke, sw, dash = _status_stroke(status)
dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
return f'stroke="{stroke}" stroke-width="{sw}"{dash_attr}'
def _host_url(host: object) -> str:
return f"/hardware/{host}/"
def _placement(fm: dict) -> str:
if "mounted_on" in fm:
return (
f"{fm.get('mounted_on', '?')}/{fm.get('shelf_face', '')}/"
f"slot {fm.get('shelf_slot', '')}"
)
face = fm.get("rack_face")
if face in ZERO_U_FACES:
return f"0U {face}"
u = fm.get("rack_u")
h = fm.get("u_height")
if isinstance(u, int) and isinstance(h, int):
return f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
return "?"
def _tooltip(fm: dict) -> str:
host = fm.get("hostname", "?")
return _esc(
f"{host} · {fm.get('kind', '')} · {fm.get('status', '')} · "
f"cluster: {fm.get('cluster', '—')} · {_placement(fm)}"
)
```
- [ ] **Step 4: Add the responsive `style` to the `<svg>` tag in `render_svg`**
Replace:
```python
p.append(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
f'height="{height}" viewBox="0 0 {width} {height}" '
f'font-family="sans-serif" font-size="11">'
)
```
with:
```python
p.append(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
f'height="{height}" viewBox="0 0 {width} {height}" '
f'style="max-width:100%;height:auto" '
f'font-family="sans-serif" font-size="11">'
)
```
- [ ] **Step 5: Wrap `draw_device` in a link + title and use the status border**
Replace the whole `draw_device` function with:
```python
def draw_device(fm: dict, col_x: int) -> None:
u = fm["rack_u"]
h = fm["u_height"]
y = u_y(u)
box_h = h * U_H
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
name = fm.get("hostname", "?")
urange = f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
p.append(f'<a href="{_host_url(name)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
p.append(
f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" '
f'height="{box_h - 2}" rx="3" fill="{color}" '
f"{_stroke_attrs(fm.get('status'))}/>"
)
p.append(
f'<text x="{col_x + COL_W // 2}" y="{y + box_h // 2 + 4}" '
f'text-anchor="middle" fill="#ffffff">'
f"{_esc(name)} ({urange})</text>"
)
p.append("</a>")
```
- [ ] **Step 6: Wrap `draw_rail` in a link + title and use the status border**
Replace the whole `draw_rail` function with:
```python
def draw_rail(fm: dict, x: int) -> None:
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
name = fm.get("hostname", "?")
cx = x + RAIL_W // 2
cy = top + body_h // 2
p.append(f'<a href="{_host_url(name)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
p.append(
f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" '
f"fill=\"{color}\" {_stroke_attrs(fm.get('status'))}/>"
)
p.append(
f'<text x="{cx}" y="{cy}" text-anchor="middle" fill="#ffffff" '
f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>'
)
p.append("</a>")
```
- [ ] **Step 7: Make shelf occupants and the shelf strip links in `draw_shelf`**
Replace the whole `draw_shelf` function with this version (occupant boxes each link to their host; the two shelf strips + label are one link to the shelf host):
```python
def draw_shelf(fm: dict) -> None:
u = fm["rack_u"]
h = fm["u_height"]
y = u_y(u)
block_h = h * U_H
strip_y = y + block_h - SHELF_STRIP_H
avail_h = block_h - SHELF_STRIP_H
shelf_color = KIND_COLORS.get("shelf", DEFAULT_COLOR)
sname = fm.get("hostname", "?")
for col_x, sface in ((front_x, "front"), (rear_x, "rear")):
occ = sorted(
(m for m in mounted
if m.get("mounted_on") == sname
and m.get("shelf_face") == sface),
key=lambda m: (m.get("shelf_slot", 0), m.get("hostname", "")),
)
n = len(occ)
for idx, m in enumerate(occ):
sub_w = COL_W // n
bx = col_x + idx * sub_w
bw = (COL_W - idx * sub_w) if idx == n - 1 else sub_w
mcolor = KIND_COLORS.get(m.get("kind", ""), DEFAULT_COLOR)
mname = m.get("hostname", "?")
p.append(f'<a href="{_host_url(mname)}">')
p.append(f"<title>{_tooltip(m)}</title>")
p.append(
f'<rect x="{bx + 1}" y="{y + 1}" width="{bw - 2}" '
f'height="{avail_h - 2}" rx="3" fill="{mcolor}" '
f"{_stroke_attrs(m.get('status'))}/>"
)
p.append(
f'<text x="{bx + bw // 2}" y="{y + avail_h // 2 + 4}" '
f'text-anchor="middle" fill="#ffffff">{_esc(mname)}</text>'
)
p.append("</a>")
p.append(f'<a href="{_host_url(sname)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
for col_x in (front_x, rear_x):
p.append(
f'<rect x="{col_x}" y="{strip_y}" width="{COL_W}" '
f'height="{SHELF_STRIP_H}" fill="{shelf_color}" stroke="#333"/>'
)
p.append(
f'<text x="{front_x + COL_W // 2}" y="{strip_y + SHELF_STRIP_H - 1}" '
f'text-anchor="middle" fill="#333" font-size="9">{_esc(sname)}</text>'
)
p.append("</a>")
```
- [ ] **Step 8: Inline the SVG in `render_page` with a download link**
In `render_page`, replace:
```python
lines.append("## Elevation")
lines.append("")
lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)")
lines.append("")
```
with:
```python
lines.append("## Elevation")
lines.append("")
lines.append('<div class="rack-elevation">')
lines.append(render_svg(rack, items).rstrip())
lines.append("</div>")
lines.append("")
lines.append(f"[Download SVG]({rack}-elevation.svg)")
lines.append("")
```
- [ ] **Step 9: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 5 new).
- [ ] **Step 10: Regenerate and verify build + drift**
Run: `make docs-index`
Expected: `Wrote rack01.md + rack01-elevation.svg (10 item(s))`.
Run: `grep -c 'href="/hardware/' docs/infrastructure/racks/rack01.md`
Expected: ≥ 10 (one link per device box + the shelf).
Run: `make test`
Expected: PASS.
Run: `mkdocs build --strict` (or `python3 -m mkdocs build --strict`)
Expected: build succeeds, no warnings-as-errors.
Run: `grep -c '<svg' site/infrastructure/racks/rack01/index.html && grep -c 'href="/hardware/' site/infrastructure/racks/rack01/index.html`
Expected: both ≥ 1 — confirms the inline SVG and its links survived `md_in_html` into the built HTML (not escaped).
Run: `make docs-check`
Expected: exit 0.
- [ ] **Step 11: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py docs/infrastructure/racks/
git commit -m "feat(rack): inline interactive elevation with links, tooltips, status borders"
```
---
### Task 2: Legend, both-gutter U-numbers, column frames
**Files:**
- Modify: `scripts/gen_rack.py` (`render_svg` layout + legend)
- Modify: `tests/test_gen_rack.py` (append tests)
- Regenerate: `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg`
**Interfaces:**
- Consumes: `_status_stroke` (Task 1), `KIND_COLORS`, `DEFAULT_COLOR`, `_esc`.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_svg_legend_shows_present_kinds():
items = [item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert ">Legend<" in svg
assert ">switch<" in svg
def test_svg_legend_omits_absent_kinds():
items = [item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert ">ups<" not in svg
def test_svg_u_numbers_in_both_gutters():
svg = gen_rack.render_svg("rack01", [])
assert 'text-anchor="end"' in svg # left gutter
assert 'text-anchor="start"' in svg # right gutter
def test_svg_has_column_frames():
svg = gen_rack.render_svg("rack01", [])
assert svg.count('fill="none"') >= 2 # one frame per column
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `>Legend<`, right-gutter `text-anchor="start"`, and `fill="none"` frames are not present yet.
- [ ] **Step 3: Add the `LEGEND_H` constant and grow the canvas**
In `render_svg`, the constants block currently ends with `TITLE_H = 28`. Add a legend-band constant right after it:
```python
TITLE_H = 28
LEGEND_H = 56
```
Then replace the height line:
```python
height = PAD + TITLE_H + body_h + PAD
```
with:
```python
height = PAD + TITLE_H + body_h + PAD + LEGEND_H
```
- [ ] **Step 4: Add a right-side U-number gutter to the layout**
Replace:
```python
front_x = PAD + len(left_items) * RAIL_W + LABEL_W
rear_x = front_x + COL_W + GAP
width = rear_x + COL_W + len(right_items) * RAIL_W + PAD
```
with:
```python
front_x = PAD + len(left_items) * RAIL_W + LABEL_W
rear_x = front_x + COL_W + GAP
right_gutter_x = rear_x + COL_W
width = right_gutter_x + LABEL_W + len(right_items) * RAIL_W + PAD
```
And replace the right-rail drawing loop:
```python
for idx, fm in enumerate(right_items):
draw_rail(fm, rear_x + COL_W + idx * RAIL_W)
```
with:
```python
for idx, fm in enumerate(right_items):
draw_rail(fm, right_gutter_x + LABEL_W + idx * RAIL_W)
```
- [ ] **Step 5: Draw the column frames and the right-gutter U-numbers**
The left-gutter U-number loop currently reads:
```python
# U numbers in the gutter left of the front column.
for u in range(1, RACK_UNITS + 1):
y = u_y(u)
p.append(
f'<text x="{front_x - 4}" y="{y + 14}" text-anchor="end" '
f'fill="#999">{u}</text>'
)
```
Immediately after that loop, add the right-gutter numbers and the two column frames:
```python
for u in range(1, RACK_UNITS + 1):
y = u_y(u)
p.append(
f'<text x="{right_gutter_x + 4}" y="{y + 14}" text-anchor="start" '
f'fill="#999">{u}</text>'
)
for col_x in (front_x, rear_x):
p.append(
f'<rect x="{col_x}" y="{top}" width="{COL_W}" height="{body_h}" '
f'fill="none" stroke="#999" stroke-width="1.5"/>'
)
```
- [ ] **Step 6: Draw the legend before `</svg>`**
The function currently ends:
```python
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
draw_shelf(fm)
p.append("</svg>")
return "\n".join(p) + "\n"
```
Insert the legend between the shelf loop and `p.append("</svg>")`:
```python
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
draw_shelf(fm)
legend_y = top + body_h + PAD + 8
p.append(
f'<text x="{front_x}" y="{legend_y}" font-weight="bold">Legend</text>'
)
present_kinds = sorted({i.get("kind", "") for i in items if i.get("kind")})
kx = front_x
ky = legend_y + 18
for kind in present_kinds:
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
p.append(
f'<rect x="{kx}" y="{ky - 10}" width="12" height="12" '
f'fill="{color}" stroke="#333"/>'
)
p.append(f'<text x="{kx + 16}" y="{ky}">{_esc(kind)}</text>')
kx += 28 + 7 * len(kind)
sx = front_x
sy = ky + 18
for label in ("in-use", "staging", "broken", "spare"):
stroke, sw, dash = _status_stroke(label)
dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
p.append(
f'<rect x="{sx}" y="{sy - 10}" width="12" height="12" '
f'fill="#ffffff" stroke="{stroke}" stroke-width="{sw}"{dash_attr}/>'
)
p.append(f'<text x="{sx + 16}" y="{sy}">{_esc(label)}</text>')
sx += 28 + 7 * len(label)
p.append("</svg>")
return "\n".join(p) + "\n"
```
- [ ] **Step 7: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 4 new).
- [ ] **Step 8: Regenerate and verify build + drift**
Run: `make docs-index`
Expected: `Wrote rack01.md + rack01-elevation.svg (10 item(s))`.
Run: `grep -c ">Legend<" docs/infrastructure/racks/rack01-elevation.svg`
Expected: `1`.
Run: `make test`
Expected: PASS.
Run: `mkdocs build --strict` (or `python3 -m mkdocs build --strict`)
Expected: build succeeds.
Run: `make docs-check`
Expected: exit 0.
- [ ] **Step 9: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py docs/infrastructure/racks/
git commit -m "feat(rack): add elevation legend, both-gutter U-numbers, column frames"
```
---
### Task 3: Mermaid node colours + clickable nodes
**Files:**
- Modify: `scripts/gen_rack.py` (`render_power`, `render_network`)
- Modify: `tests/test_gen_rack.py` (append tests)
- Regenerate: `docs/infrastructure/racks/rack01.md`
**Interfaces:**
- Consumes: `_node_id`, `_host_url` (Task 1), `KIND_COLORS`, `DEFAULT_COLOR`.
> **Note on mermaid `click` interactivity.** Node **colouring** (`style …`) always
> works. Whether a `click` actually navigates depends on Material's mermaid
> `securityLevel` (default `strict` may render the link inert) — and this phase
> makes **no `mkdocs.yml` change**. The `click` directive still parses and the
> graph still renders either way; the tests only assert the directives are
> emitted. In Step 6, confirm the graphs still render as **diagrams** (not broken
> code blocks). If clicks turn out inert in the built site, that is acceptable for
> this phase (the SVG elevation already provides clickable navigation) — note it
> in the report rather than adding config.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_power_graph_colors_and_links_nodes():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1}]),
]
out = gen_rack.render_power("rack01", items)
assert "style srv01 fill:" in out
assert "style pdu01 fill:" in out
assert 'click srv01 "/hardware/srv01/"' in out
def test_network_graph_colors_and_links_nodes():
items = [
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front", ports=24),
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01", "peer_port": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "style sw01 fill:" in out
assert 'click sw01 "/hardware/sw01/"' in out
assert 'click srv01 "/hardware/srv01/"' in out
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `style …`/`click …` lines are not emitted yet.
- [ ] **Step 3: Emit node colours + clicks in `render_power`**
In `render_power`, the function currently ends:
```python
for pdu, outlet, device in edges:
lines.append(
f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}"
)
lines.append("```")
return "\n".join(lines) + "\n"
```
Insert a styling/click block between the edge loop and `lines.append("```")`:
```python
for pdu, outlet, device in edges:
lines.append(
f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}"
)
by_host = {fm.get("hostname"): fm for fm in items}
node_hosts = sorted(set(pdus) | {fm.get("hostname", "?") for fm in powered})
for host in node_hosts:
kind = by_host.get(host, {}).get("kind", "")
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
nid = _node_id(host)
lines.append(
f" style {nid} fill:{color},stroke:#333,color:#ffffff"
)
lines.append(f' click {nid} "{_host_url(host)}"')
lines.append("```")
return "\n".join(lines) + "\n"
```
- [ ] **Step 4: Emit node colours + clicks in `render_network`**
In `render_network`, the function currently ends:
```python
for source, local, peer, peer_port, speed in edges:
label = f"{local} → p{peer_port}"
if speed is not None:
label += f" · {speed}G"
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}")
lines.append("```")
return "\n".join(lines) + "\n"
```
Insert a styling/click block between the edge loop and `lines.append("```")`:
```python
for source, local, peer, peer_port, speed in edges:
label = f"{local} → p{peer_port}"
if speed is not None:
label += f" · {speed}G"
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}")
for host in sorted(nodes):
kind = by_host.get(host, {}).get("kind", "")
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
nid = _node_id(host)
lines.append(
f" style {nid} fill:{color},stroke:#333,color:#ffffff"
)
if host in by_host:
lines.append(f' click {nid} "{_host_url(host)}"')
lines.append("```")
return "\n".join(lines) + "\n"
```
(`by_host` already exists in `render_network`; off-rack peers — not in `by_host` — get a default-coloured node and no click, per the spec.)
- [ ] **Step 5: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 2 new).
- [ ] **Step 6: Regenerate and verify build + drift**
Run: `make docs-index`
Expected: `Wrote rack01.md + rack01-elevation.svg (10 item(s))`.
Run: `grep -c "click srv01" docs/infrastructure/racks/rack01.md`
Expected: `2` (one in the power graph, one in the network graph).
Run: `make test`
Expected: PASS.
Run: `mkdocs build --strict` (or `python3 -m mkdocs build --strict`)
Expected: build succeeds.
Run: `grep -c 'class="mermaid"' site/infrastructure/racks/rack01/index.html`
Expected: `≥ 2` — confirms both graphs still render as **diagrams** after adding `style`/`click` (i.e. the directives did not break mermaid parsing).
Run: `make docs-check`
Expected: exit 0.
- [ ] **Step 7: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py docs/infrastructure/racks/
git commit -m "feat(rack): colour and link mermaid power/network nodes by kind"
```
---
## Self-Review
**Spec coverage (`2026-06-24-rack-presentation-design.md`):**
- A. Inline SVG + clickable boxes + tooltips + standalone .svg + download link + responsive style — Task 1. ✔
- B. Status border encoding (table verbatim) — Task 1 (`_status_stroke`/`_stroke_attrs`, applied in draw_device/draw_rail/draw_shelf). ✔
- C. Legend (present kinds + status key) — Task 2. ✔
- D. U-numbers both gutters, column frame — Task 2; mermaid node colours + clicks — Task 3. ✔
- Root-relative `/hardware/<host>/` URLs — Task 1 (`_host_url`), used in SVG links and mermaid clicks. ✔
- md_in_html raw passthrough (`<div class="rack-elevation">`, no `markdown` attr) — Task 1 Step 8, verified Task 1 Step 10 (built HTML contains `<svg`/`href`). ✔
- No mkdocs/Makefile/CI/overview_config changes; occupancy + validation unchanged — honored. ✔
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". Legend spacing uses a deterministic width heuristic (`28 + 7*len(label)`), not a placeholder.
**Type consistency:** `_status_stroke -> tuple[str, float, str]`; `_stroke_attrs`/`_host_url`/`_placement`/`_tooltip` -> `str`; `render_svg`/`render_page`/`render_power`/`render_network` -> `str`. `_status_stroke` defined in Task 1 is reused by Task 2's legend; `_host_url` defined in Task 1 is reused by Task 3's clicks. `by_host`/`nodes`/`pdus`/`powered` names in the mermaid edits match the existing function locals. Each task regenerates the artifacts so drift stays clean at every boundary.