diff --git a/docs/superpowers/plans/2026-07-09-guanghan-topology-presentation-db-implementation.md b/docs/superpowers/plans/2026-07-09-guanghan-topology-presentation-db-implementation.md new file mode 100644 index 0000000..b561af1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-guanghan-topology-presentation-db-implementation.md @@ -0,0 +1,1646 @@ +# Guanghan Topology Presentation DB 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:** Move the Guanghan engineering topology page from CSS/HTML-defined visual semantics to database-defined B1 presentation data for node styles, edge styles, decoration styles, and legends. + +**Architecture:** PostgreSQL remains the source of truth for topology facts and layout. New presentation tables store visual style mappings for the existing layout; the snapshot API returns a `layout.presentation` payload; the wiki demo page applies those styles at SVG element creation time while keeping the existing interaction shell. The current branch must be used directly; do not create or switch branches. + +**Tech Stack:** PostgreSQL, psycopg, Flask, vanilla HTML/CSS/JavaScript SVG, Python `unittest`, Playwright. + +--- + +## File structure + +Create: + +- `topology_api/migrations/006_presentation_styles.sql`: creates theme, node style, edge style, decoration style, and legend tables; adds `diagram_layouts.theme_id`. +- `docs/guanghan_topology_presentation_db_verification_2026-07-09.md`: final verification report with commands and screenshots. + +Modify: + +- `topology_api/seed/seed_layout.py`: seeds the default theme and current approved style values, then binds the default layout to the theme. +- `topology_api/queries/snapshot_queries.py`: fetches presentation styles and embeds them under `layout.presentation`. +- `data/wiki/guanghan_topology_demo.html`: renders nodes, edges, radiators, and legend items from `layout.presentation`. +- `tests/test_topology_api.py`: adds API assertions for presentation payload and DB-driven style semantics. +- `tests/test_guanghan_topology_demo.py`: adds source-level guardrails against reintroducing hardcoded business visual semantics. +- `tests/test_guanghan_topology_e2e.py`: adds browser assertions for API-driven colors and legend rendering. + +Do not modify unrelated untracked wiki files, handover files, scripts, or `user_input/.../Timeline.md`. + +## Existing behavior to preserve + +- `GET /guanghan_topology_demo.html?mission=GHC-05` still renders 14 visual nodes, 2 pressurized passages, and 4 radiators. +- Explicit `at` still wins over `mission`. +- If only `mission` exists, the snapshot uses the latest event of that mission. +- If neither exists, the snapshot uses the latest recorded occurred state. +- Future expansion markers remain source comments only; they are not visible on the page. +- 加压走道 remains a green solid line in seeded production data; the key change is that the color comes from the database. +- 散热器 remains non-clickable, non-modal, and non-overlapping. + +--- + +### Task 1: Add failing API tests for presentation payload + +**Files:** + +- Modify: `tests/test_topology_api.py` + +- [ ] **Step 1: Add a test that requires `layout.presentation`** + +Append this test to `TopologyApiQueryTests` after `test_layout_contains_members_labels_and_radiators`: + +```python + def test_layout_includes_database_presentation_payload(self) -> None: + response = self.client.get( + "/api/v1/bases/guanghan/snapshot?mission=GHC-05" + ) + self.assertEqual(response.status_code, 200) + layout = response.get_json()["layout"] + presentation = layout["presentation"] + + self.assertEqual( + presentation["theme"]["key"], + "guanghan_dark_engineering_v1", + ) + self.assertIn("nodeStyles", presentation) + self.assertIn("edgeStyles", presentation) + self.assertIn("decorationStyles", presentation) + self.assertIn("legendItems", presentation) + + node_style_keys = { + node["styleKey"] for node in layout["nodes"] + } + self.assertLessEqual( + node_style_keys, + set(presentation["nodeStyles"].keys()), + ) + + connection_types = { + connection["type"] + for connection in response.get_json()["connections"] + } + self.assertLessEqual( + connection_types, + set(presentation["edgeStyles"].keys()), + ) + + self.assertEqual( + presentation["edgeStyles"]["pressurized_passage"], + { + "displayName": "加压走道", + "strokeColor": "#51e89c", + "strokeWidth": 9.0, + "dashArray": None, + "lineCap": "square", + "legendGroup": "pressurized", + }, + ) + self.assertEqual( + presentation["decorationStyles"]["radiator"]["label"], + "散热器", + ) + self.assertEqual( + [ + item["key"] + for item in presentation["legendItems"] + ], + ["structural", "pressurized", "utility", "radiator"], + ) +``` + +- [ ] **Step 2: Add a test proving style changes are database-visible** + +Append this test to the same class. It commits one temporary style change so the API can read it through its own connection, then restores the original value in `finally`: + +```python + def test_presentation_styles_are_database_backed(self) -> None: + from topology_api.db import get_writer_connection + + with get_writer_connection() as conn: + conn.execute("SET search_path TO topology, public") + with conn.cursor() as cur: + cur.execute(""" + SELECT stroke_color + FROM diagram_edge_styles + WHERE connection_type = 'pressurized_passage' + """) + original_color = cur.fetchone()["stroke_color"] + cur.execute(""" + UPDATE diagram_edge_styles + SET stroke_color = '#00ffaa' + WHERE connection_type = 'pressurized_passage' + """) + conn.commit() + try: + response = self.client.get( + "/api/v1/bases/guanghan/snapshot?mission=GHC-05" + ) + self.assertEqual(response.status_code, 200) + presentation = response.get_json()["layout"]["presentation"] + self.assertEqual( + presentation["edgeStyles"]["pressurized_passage"] + ["strokeColor"], + "#00ffaa", + ) + finally: + with conn.cursor() as cur: + cur.execute(""" + UPDATE diagram_edge_styles + SET stroke_color = %(original)s + WHERE connection_type = 'pressurized_passage' + """, {"original": original_color}) + conn.commit() +``` + +- [ ] **Step 3: Run the focused API tests to verify they fail** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m unittest tests.test_topology_api.TopologyApiQueryTests.test_layout_includes_database_presentation_payload tests.test_topology_api.TopologyApiQueryTests.test_presentation_styles_are_database_backed -v +``` + +Expected result: + +```text +ERROR: test_layout_includes_database_presentation_payload +KeyError: 'presentation' + +ERROR: test_presentation_styles_are_database_backed +psycopg.errors.UndefinedTable: relation "diagram_edge_styles" does not exist +``` + +- [ ] **Step 4: Commit failing tests** + +Run: + +```powershell +git add tests/test_topology_api.py +git commit -m "test(topology): require database presentation payload" +``` + +--- + +### Task 2: Add presentation schema and seed data + +**Files:** + +- Create: `topology_api/migrations/006_presentation_styles.sql` +- Modify: `topology_api/seed/seed_layout.py` +- Test: `tests/test_topology_api.py` + +- [ ] **Step 1: Create the migration** + +Create `topology_api/migrations/006_presentation_styles.sql` with this content: + +```sql +-- topology_api/migrations/006_presentation_styles.sql +-- Database-driven presentation semantics for topology diagrams. + +CREATE TABLE IF NOT EXISTS topology.diagram_themes ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + base_id uuid NOT NULL REFERENCES topology.bases(id) ON DELETE CASCADE, + theme_key text NOT NULL, + name text NOT NULL, + description text, + tokens jsonb NOT NULL DEFAULT '{}'::jsonb, + is_default boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_diagram_themes_base_key UNIQUE (base_id, theme_key) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_diagram_themes_default_per_base + ON topology.diagram_themes(base_id) + WHERE is_default; + +CREATE TABLE IF NOT EXISTS topology.diagram_node_styles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + theme_id uuid NOT NULL REFERENCES topology.diagram_themes(id) ON DELETE CASCADE, + style_key text NOT NULL, + display_name text NOT NULL, + fill_color text NOT NULL, + stroke_color text NOT NULL, + stroke_width numeric NOT NULL, + corner_radius numeric NOT NULL DEFAULT 16, + title_color text, + meta_color text, + hint_color text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_diagram_node_styles_theme_key UNIQUE (theme_id, style_key), + CONSTRAINT ck_diagram_node_stroke_width CHECK (stroke_width >= 0), + CONSTRAINT ck_diagram_node_corner_radius CHECK (corner_radius >= 0) +); + +CREATE TABLE IF NOT EXISTS topology.diagram_edge_styles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + theme_id uuid NOT NULL REFERENCES topology.diagram_themes(id) ON DELETE CASCADE, + connection_type text NOT NULL, + display_name text NOT NULL, + stroke_color text NOT NULL, + stroke_width numeric NOT NULL, + dash_array text, + line_cap text NOT NULL DEFAULT 'square', + legend_group text, + legend_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_diagram_edge_styles_theme_type UNIQUE (theme_id, connection_type), + CONSTRAINT ck_diagram_edge_stroke_width CHECK (stroke_width >= 0), + CONSTRAINT ck_diagram_edge_line_cap CHECK (line_cap IN ('butt', 'round', 'square')), + CONSTRAINT ck_diagram_edge_dash_array CHECK ( + dash_array IS NULL OR dash_array ~ '^[0-9]+( [0-9]+)*$' + ) +); + +CREATE TABLE IF NOT EXISTS topology.diagram_decoration_styles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + theme_id uuid NOT NULL REFERENCES topology.diagram_themes(id) ON DELETE CASCADE, + decoration_key text NOT NULL, + display_name text NOT NULL, + fill_color text NOT NULL, + stroke_color text NOT NULL, + stroke_width numeric NOT NULL, + label text, + label_color text, + render_params jsonb NOT NULL DEFAULT '{}'::jsonb, + legend_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_diagram_decoration_styles_theme_key UNIQUE (theme_id, decoration_key), + CONSTRAINT ck_diagram_decoration_stroke_width CHECK (stroke_width >= 0) +); + +CREATE TABLE IF NOT EXISTS topology.diagram_legend_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + theme_id uuid NOT NULL REFERENCES topology.diagram_themes(id) ON DELETE CASCADE, + legend_key text NOT NULL, + label text NOT NULL, + item_type text NOT NULL, + style_ref text NOT NULL, + display_order integer NOT NULL DEFAULT 0, + is_visible boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_diagram_legend_items_theme_key UNIQUE (theme_id, legend_key), + CONSTRAINT ck_diagram_legend_item_type CHECK (item_type IN ('edge', 'decoration', 'node')) +); + +ALTER TABLE topology.diagram_layouts + ADD COLUMN IF NOT EXISTS theme_id uuid REFERENCES topology.diagram_themes(id); +``` + +- [ ] **Step 2: Add seed helpers and constants** + +Keep the existing imports in `topology_api/seed/seed_layout.py`. Add these constants near the top after imports: + +```python +THEME_KEY = "guanghan_dark_engineering_v1" + +THEME_TOKENS = { + "textColor": "#eaf2ff", + "mutedTextColor": "#91a3bd", + "metaColor": "#a6b5c9", + "hintColor": "#71849e", +} + +NODE_STYLES = [ + ("node-card", "普通舱段", "#10263a", "#52baff", 3, 16), + ("core-card", "核心舱", "#3b250d", "#ffad42", 5, 16), + ("power-card", "电力模块分支", "#2a2810", "#d5b642", 3, 16), + ("power-node-card", "电力模块中心", "#393412", "#d5b642", 5, 16), + ("vehicle-card", "车辆", "#291c34", "#c28cdf", 3, 16), +] + +EDGE_STYLES = [ + ("structural_mount", "结构直连", "#8fa1b8", 6, None, "square", "structural", 10), + ("pressurized_passage", "加压走道", "#51e89c", 9, None, "square", "pressurized", 20), + ("fuel", "燃料 / 供电", "#b178e6", 4, "13 10", "square", "utility", 30), + ("power", "燃料 / 供电", "#b178e6", 4, "13 10", "square", "utility", 30), + ("cooling", "冷却", "#78d6e6", 4, "10 8", "square", "cooling", 40), + ("docking", "对接口", "#c28cdf", 4, "10 8", "square", "docking", 50), +] + +DECORATION_STYLES = [ + ( + "radiator", + "大型折叠散热面板", + "#6b5814", + "#f0d869", + 2, + "散热器", + "#f4df7b", + { + "widthRatio": 0.55, + "maxWidth": 130, + "height": 16, + "offsetXRatio": 0.55, + "offsetY": -18, + "ribCount": 3, + }, + 40, + ), +] + +LEGEND_ITEMS = [ + ("structural", "结构直连", "edge", "structural_mount", 10), + ("pressurized", "加压走道", "edge", "pressurized_passage", 20), + ("utility", "燃料 / 供电", "edge", "fuel", 30), + ("radiator", "大型折叠散热面板", "decoration", "radiator", 40), +] +``` + +- [ ] **Step 3: Seed the theme before layout upsert** + +Inside `seed()`, after loading `base_id`, insert: + +```python + cur.execute(""" + INSERT INTO diagram_themes + (id, base_id, theme_key, name, description, tokens, is_default) + VALUES ( + gen_random_uuid(), + %(base)s, + %(theme_key)s, + '广寒深色工程图', + '广寒基地工程总图默认表现层主题', + %(tokens)s, + true + ) + ON CONFLICT (base_id, theme_key) DO UPDATE + SET name = EXCLUDED.name, + description = EXCLUDED.description, + tokens = EXCLUDED.tokens, + is_default = true, + updated_at = now() + RETURNING id + """, { + "base": base_id, + "theme_key": THEME_KEY, + "tokens": json.dumps(THEME_TOKENS), + }) + theme_id = cur.fetchone()["id"] + print(f"Theme: {theme_id}") +``` + +- [ ] **Step 4: Bind the layout to the theme** + +Change the layout upsert SQL in `seed_layout.py` from: + +```python + INSERT INTO diagram_layouts (id, base_id, layout_key, name, + canvas_width, canvas_height, is_default) + VALUES (gen_random_uuid(), %(base)s, 'engineering_overview_v1', + '工程总图 v1', 1500, 1900, true) + ON CONFLICT (base_id, layout_key) DO UPDATE + SET name = EXCLUDED.name, is_default = true +``` + +to: + +```python + INSERT INTO diagram_layouts (id, base_id, layout_key, name, + canvas_width, canvas_height, is_default, theme_id) + VALUES (gen_random_uuid(), %(base)s, 'engineering_overview_v1', + '工程总图 v1', 1500, 1900, true, %(theme)s) + ON CONFLICT (base_id, layout_key) DO UPDATE + SET name = EXCLUDED.name, + is_default = true, + theme_id = EXCLUDED.theme_id +``` + +and pass `"theme": theme_id` in the parameter dict. + +- [ ] **Step 5: Replace theme-dependent style rows** + +After the layout `RETURNING id` block and before component mapping, insert: + +```python + cur.execute("DELETE FROM diagram_legend_items WHERE theme_id = %(theme)s", {"theme": theme_id}) + cur.execute("DELETE FROM diagram_decoration_styles WHERE theme_id = %(theme)s", {"theme": theme_id}) + cur.execute("DELETE FROM diagram_edge_styles WHERE theme_id = %(theme)s", {"theme": theme_id}) + cur.execute("DELETE FROM diagram_node_styles WHERE theme_id = %(theme)s", {"theme": theme_id}) + + for style_key, display_name, fill, stroke, stroke_width, corner_radius in NODE_STYLES: + cur.execute(""" + INSERT INTO diagram_node_styles + (theme_id, style_key, display_name, fill_color, + stroke_color, stroke_width, corner_radius, + title_color, meta_color, hint_color) + VALUES + (%(theme)s, %(style_key)s, %(display_name)s, %(fill)s, + %(stroke)s, %(stroke_width)s, %(corner_radius)s, + %(title_color)s, %(meta_color)s, %(hint_color)s) + """, { + "theme": theme_id, + "style_key": style_key, + "display_name": display_name, + "fill": fill, + "stroke": stroke, + "stroke_width": stroke_width, + "corner_radius": corner_radius, + "title_color": THEME_TOKENS["textColor"], + "meta_color": THEME_TOKENS["metaColor"], + "hint_color": THEME_TOKENS["hintColor"], + }) + + for ( + connection_type, display_name, stroke, stroke_width, + dash_array, line_cap, legend_group, legend_order, + ) in EDGE_STYLES: + cur.execute(""" + INSERT INTO diagram_edge_styles + (theme_id, connection_type, display_name, stroke_color, + stroke_width, dash_array, line_cap, legend_group, + legend_order) + VALUES + (%(theme)s, %(connection_type)s, %(display_name)s, + %(stroke)s, %(stroke_width)s, %(dash_array)s, + %(line_cap)s, %(legend_group)s, %(legend_order)s) + """, { + "theme": theme_id, + "connection_type": connection_type, + "display_name": display_name, + "stroke": stroke, + "stroke_width": stroke_width, + "dash_array": dash_array, + "line_cap": line_cap, + "legend_group": legend_group, + "legend_order": legend_order, + }) + + for ( + decoration_key, display_name, fill, stroke, stroke_width, label, + label_color, render_params, legend_order, + ) in DECORATION_STYLES: + cur.execute(""" + INSERT INTO diagram_decoration_styles + (theme_id, decoration_key, display_name, fill_color, + stroke_color, stroke_width, label, label_color, + render_params, legend_order) + VALUES + (%(theme)s, %(decoration_key)s, %(display_name)s, + %(fill)s, %(stroke)s, %(stroke_width)s, %(label)s, + %(label_color)s, %(render_params)s, %(legend_order)s) + """, { + "theme": theme_id, + "decoration_key": decoration_key, + "display_name": display_name, + "fill": fill, + "stroke": stroke, + "stroke_width": stroke_width, + "label": label, + "label_color": label_color, + "render_params": json.dumps(render_params), + "legend_order": legend_order, + }) + + for legend_key, label, item_type, style_ref, display_order in LEGEND_ITEMS: + cur.execute(""" + INSERT INTO diagram_legend_items + (theme_id, legend_key, label, item_type, + style_ref, display_order, is_visible) + VALUES + (%(theme)s, %(legend_key)s, %(label)s, %(item_type)s, + %(style_ref)s, %(display_order)s, true) + """, { + "theme": theme_id, + "legend_key": legend_key, + "label": label, + "item_type": item_type, + "style_ref": style_ref, + "display_order": display_order, + }) +``` + +- [ ] **Step 6: Apply the migration and seed** + +Run a transactional migration application from PowerShell: + +```powershell +.\.venv\Scripts\python.exe -c "from pathlib import Path; from topology_api.db import get_writer_connection; sql = Path('topology_api/migrations/006_presentation_styles.sql').read_text(encoding='utf-8'); conn = get_writer_connection(); conn.execute('SET search_path TO topology, public'); conn.execute(sql); conn.commit(); conn.close(); print('applied 006_presentation_styles.sql')" +.\.venv\Scripts\python.exe -m topology_api.seed.seed_layout +``` + +Expected output includes: + +```text +applied 006_presentation_styles.sql +Theme: +Layout: +Nodes inserted: 14 +``` + +- [ ] **Step 7: Run the focused tests again** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m unittest tests.test_topology_api.TopologyApiQueryTests.test_layout_includes_database_presentation_payload tests.test_topology_api.TopologyApiQueryTests.test_presentation_styles_are_database_backed -v +``` + +Expected after this task: + +```text +ERROR: test_layout_includes_database_presentation_payload +KeyError: 'presentation' +``` + +The table should now exist, so the second test may still fail because the API does not read the table yet. If the second test also reports missing `presentation`, that is acceptable at this stage. + +- [ ] **Step 8: Commit schema and seed** + +Run: + +```powershell +git add topology_api/migrations/006_presentation_styles.sql topology_api/seed/seed_layout.py +git commit -m "feat(topology): seed diagram presentation styles" +``` + +--- + +### Task 3: Return presentation data from the snapshot API + +**Files:** + +- Modify: `topology_api/queries/snapshot_queries.py` +- Test: `tests/test_topology_api.py` + +- [ ] **Step 1: Add serialization helpers** + +In `topology_api/queries/snapshot_queries.py`, add these helpers after the import: + +```python +def _require_presentation(condition, message): + if not condition: + raise RuntimeError(f"presentation_config_error: {message}") +``` + +- [ ] **Step 2: Extend the layout query to include theme metadata** + +In `fetch_layout()`, extend the SQL `SELECT` and joins: + +```sql + COALESCE(layout_theme.id, default_theme.id) AS theme_id, + COALESCE(layout_theme.theme_key, default_theme.theme_key) AS theme_key, + COALESCE(layout_theme.name, default_theme.name) AS theme_name, + COALESCE(layout_theme.tokens, default_theme.tokens) AS theme_tokens, +``` + +Add joins before `WHERE`: + +```sql + LEFT JOIN diagram_themes layout_theme + ON layout_theme.id = dl.theme_id + LEFT JOIN diagram_themes default_theme + ON default_theme.base_id = dl.base_id + AND default_theme.is_default = true +``` + +Set `layout_info` to include the theme: + +```python + layout_info = { + "key": row["layout_key"], + "name": row["layout_name"], + "canvasWidth": float(row["canvas_width"]), + "canvasHeight": float(row["canvas_height"]), + "themeKey": row["theme_key"], + } + theme_info = { + "id": row["theme_id"], + "key": row["theme_key"], + "name": row["theme_name"], + "tokens": row["theme_tokens"] or {}, + } +``` + +Initialize `theme_info = None` before the loop. + +- [ ] **Step 3: Query presentation tables after nodes are loaded** + +At the end of `fetch_layout()`, before returning, add: + +```python + if layout_info is None: + return None + + _require_presentation(theme_info and theme_info["id"], "default theme missing") + theme_id = theme_info["id"] + + cur.execute(""" + SELECT style_key, display_name, fill_color, stroke_color, + stroke_width, corner_radius, title_color, meta_color, + hint_color + FROM diagram_node_styles + WHERE theme_id = %(theme)s + ORDER BY style_key + """, {"theme": theme_id}) + node_styles = { + row["style_key"]: { + "displayName": row["display_name"], + "fillColor": row["fill_color"], + "strokeColor": row["stroke_color"], + "strokeWidth": float(row["stroke_width"]), + "cornerRadius": float(row["corner_radius"]), + "titleColor": row["title_color"], + "metaColor": row["meta_color"], + "hintColor": row["hint_color"], + } + for row in cur.fetchall() + } + + cur.execute(""" + SELECT connection_type, display_name, stroke_color, stroke_width, + dash_array, line_cap, legend_group + FROM diagram_edge_styles + WHERE theme_id = %(theme)s + ORDER BY connection_type + """, {"theme": theme_id}) + edge_styles = { + row["connection_type"]: { + "displayName": row["display_name"], + "strokeColor": row["stroke_color"], + "strokeWidth": float(row["stroke_width"]), + "dashArray": row["dash_array"], + "lineCap": row["line_cap"], + "legendGroup": row["legend_group"], + } + for row in cur.fetchall() + } + + cur.execute(""" + SELECT decoration_key, display_name, fill_color, stroke_color, + stroke_width, label, label_color, render_params + FROM diagram_decoration_styles + WHERE theme_id = %(theme)s + ORDER BY decoration_key + """, {"theme": theme_id}) + decoration_styles = { + row["decoration_key"]: { + "displayName": row["display_name"], + "fillColor": row["fill_color"], + "strokeColor": row["stroke_color"], + "strokeWidth": float(row["stroke_width"]), + "label": row["label"], + "labelColor": row["label_color"], + "renderParams": row["render_params"] or {}, + } + for row in cur.fetchall() + } + + cur.execute(""" + SELECT legend_key, label, item_type, style_ref, display_order + FROM diagram_legend_items + WHERE theme_id = %(theme)s + AND is_visible = true + ORDER BY display_order, legend_key + """, {"theme": theme_id}) + legend_items = [ + { + "key": row["legend_key"], + "label": row["label"], + "itemType": row["item_type"], + "styleRef": row["style_ref"], + "displayOrder": row["display_order"], + } + for row in cur.fetchall() + ] + + missing_node_styles = { + node["styleKey"] for node in nodes + } - set(node_styles) + _require_presentation( + not missing_node_styles, + f"missing node styles: {sorted(missing_node_styles)}", + ) + + return { + "layout": layout_info, + "nodes": nodes, + "presentation": { + "theme": { + "key": theme_info["key"], + "name": theme_info["name"], + "tokens": theme_info["tokens"], + }, + "nodeStyles": node_styles, + "edgeStyles": edge_styles, + "decorationStyles": decoration_styles, + "legendItems": legend_items, + }, + } +``` + +Remove or replace the existing final line: + +```python + return {"layout": layout_info, "nodes": nodes} if layout_info else None +``` + +- [ ] **Step 4: Run focused tests** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m unittest tests.test_topology_api.TopologyApiQueryTests.test_layout_includes_database_presentation_payload tests.test_topology_api.TopologyApiQueryTests.test_presentation_styles_are_database_backed -v +``` + +Expected: + +```text +OK +``` + +- [ ] **Step 5: Run full API tests** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m unittest tests.test_topology_api -v +``` + +Expected: + +```text +OK +``` + +- [ ] **Step 6: Commit API presentation response** + +Run: + +```powershell +git add topology_api/queries/snapshot_queries.py tests/test_topology_api.py +git commit -m "feat(topology): return diagram presentation styles" +``` + +--- + +### Task 4: Add frontend source guardrails + +**Files:** + +- Modify: `tests/test_guanghan_topology_demo.py` +- Test target: `data/wiki/guanghan_topology_demo.html` + +- [ ] **Step 1: Add a source test for presentation usage** + +Append to `GuanghanTopologySourceTests`: + +```python + def test_page_uses_database_presentation_payload(self) -> None: + self.assertIn("currentData.layout.presentation", self.html) + self.assertIn("nodeStyles", self.html) + self.assertIn("edgeStyles", self.html) + self.assertIn("decorationStyles", self.html) + self.assertIn("legendItems", self.html) +``` + +- [ ] **Step 2: Add a source test against hardcoded business colors** + +Append: + +```python + def test_business_visual_semantics_are_not_css_hardcoded(self) -> None: + forbidden = [ + ".connection.structural", + ".connection.pressurized", + ".connection.utility", + ".node-card { fill:", + ".core-card { fill:", + ".power-card { fill:", + ".power-node-card { fill:", + ".vehicle-card { fill:", + "function connectionClass", + ] + for token in forbidden: + self.assertNotIn(token, self.html) +``` + +- [ ] **Step 3: Add a source test against hardcoded legend items** + +Append: + +```python + def test_legend_is_rendered_from_presentation_payload(self) -> None: + self.assertIn('id="legend"', self.html) + self.assertNotIn("结构直连", self.html) + self.assertNotIn("加压走道", self.html) + self.assertNotIn("燃料 / 供电", self.html) + self.assertNotIn("大型折叠散热面板", self.html) + self.assertIn("renderLegend", self.html) +``` + +- [ ] **Step 4: Run source tests to verify failure** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m unittest tests.test_guanghan_topology_demo -v +``` + +Expected: + +```text +FAIL: test_page_uses_database_presentation_payload +FAIL: test_business_visual_semantics_are_not_css_hardcoded +FAIL: test_legend_is_rendered_from_presentation_payload +``` + +- [ ] **Step 5: Commit failing source guardrails** + +Run: + +```powershell +git add tests/test_guanghan_topology_demo.py +git commit -m "test(topology): require database-driven visual semantics" +``` + +--- + +### Task 5: Switch the demo page to presentation-driven rendering + +**Files:** + +- Modify: `data/wiki/guanghan_topology_demo.html` +- Test: `tests/test_guanghan_topology_demo.py` +- Test: `tests/test_guanghan_topology_e2e.py` + +- [ ] **Step 1: Replace hardcoded legend markup with an empty container** + +Change the legend block from hardcoded items to: + +```html +
+``` + +- [ ] **Step 2: Remove business color CSS classes** + +Remove these CSS rules: + +```css + .legend-line.structural { border-color: var(--structural); } + .legend-line.pressurized { border-color: var(--pressurized); } + .legend-line.utility { border-color: var(--utility); border-top-style: dashed; } + .connection.structural { stroke: var(--structural); stroke-width: 6; } + .connection.pressurized { stroke: var(--pressurized); stroke-width: 9; } + .connection.utility { stroke: var(--utility); stroke-width: 4; stroke-dasharray: 13 10; } + .node-card { fill: #10263a; stroke: var(--habitat); stroke-width: 3; } + .core-card { fill: #3b250d; stroke: var(--core); stroke-width: 5; } + .power-card { fill: #2a2810; stroke: var(--power); stroke-width: 3; } + .power-node-card { fill: #393412; stroke: var(--power); stroke-width: 5; } + .vehicle-card { fill: #291c34; stroke: var(--vehicle); stroke-width: 3; } +``` + +Keep generic rules such as `.connection`, `.topology-node`, `.node-title`, `.node-meta`, `.node-hint`, `.radiator`, `.legend-line`, and `.legend-chip`. + +- [ ] **Step 3: Add presentation accessor helpers** + +Add these JS helpers near existing rendering helpers: + +```javascript +function getPresentation() { + const presentation = currentData?.layout?.presentation; + if (!presentation) throw new Error('拓扑表现层资料未记录'); + return presentation; +} + +function requireStyle(collection, key, label) { + const style = collection?.[key]; + if (!style) throw new Error(`${label} 未记录: ${key}`); + return style; +} + +function setOptionalAttr(element, name, value) { + if (value !== null && value !== undefined && value !== '') { + element.setAttribute(name, value); + } +} +``` + +- [ ] **Step 4: Remove `connectionClass`** + +Delete the existing function: + +```javascript +function connectionClass(type) { + if (type === 'pressurized_passage') return 'pressurized'; + if (type === 'fuel' || type === 'power') return 'utility'; + return 'structural'; +} +``` + +If the function body currently differs, remove the whole `function connectionClass(...)` block. + +- [ ] **Step 5: Add style application helpers** + +Add: + +```javascript +function applyNodeStyle(rect, nodeStyle) { + rect.setAttribute('fill', nodeStyle.fillColor); + rect.setAttribute('stroke', nodeStyle.strokeColor); + rect.setAttribute('stroke-width', nodeStyle.strokeWidth); + rect.setAttribute('rx', nodeStyle.cornerRadius); +} + +function applyTextStyle(textElement, color) { + if (color) textElement.setAttribute('fill', color); +} + +function applyEdgeStyle(line, edgeStyle) { + line.setAttribute('stroke', edgeStyle.strokeColor); + line.setAttribute('stroke-width', edgeStyle.strokeWidth); + setOptionalAttr(line, 'stroke-dasharray', edgeStyle.dashArray); + setOptionalAttr(line, 'stroke-linecap', edgeStyle.lineCap); +} +``` + +- [ ] **Step 6: Update `appendNodeText` to accept a color** + +Change: + +```javascript +function appendNodeText(group, node, className, text, y) { +``` + +to: + +```javascript +function appendNodeText(group, node, className, text, y, color) { +``` + +Add before `group.appendChild(element);`: + +```javascript + applyTextStyle(element, color); +``` + +- [ ] **Step 7: Update `createRadiator` to use database style** + +Change the function signature: + +```javascript +function createRadiator(node, style) { +``` + +Use these defaults and DB params: + +```javascript + const params = style.renderParams || {}; + const widthRatio = Number(params.widthRatio ?? 0.55); + const maxWidth = Number(params.maxWidth ?? 130); + const height = Number(params.height ?? 16); + const offsetXRatio = Number(params.offsetXRatio ?? 0.55); + const offsetY = Number(params.offsetY ?? -18); + const ribCount = Number(params.ribCount ?? 3); + const width = Math.min(maxWidth, node.width * widthRatio); + const x = node.x + node.width * offsetXRatio; + const y = node.y + offsetY; +``` + +Create the rectangle with DB style: + +```javascript + group.appendChild(svgElement('rect', { + x, y, width, height, rx: 3, + fill: style.fillColor, + stroke: style.strokeColor, + 'stroke-width': style.strokeWidth, + })); +``` + +Render ribs: + +```javascript + for (let index = 1; index <= ribCount; index += 1) { + const lineX = x + width * index / (ribCount + 1); + group.appendChild(svgElement('line', { + x1: lineX, + y1: y + 1, + x2: lineX, + y2: y + height - 1, + stroke: style.labelColor || style.strokeColor, + 'stroke-width': 1.5, + })); + } +``` + +Use DB label: + +```javascript + if (style.label) { + const label = svgElement('text', { + class: 'radiator-label', + x: x + width / 2, + y: y - 8, + fill: style.labelColor || style.strokeColor, + }); + label.textContent = style.label; + group.appendChild(label); + } +``` + +- [ ] **Step 8: Add dynamic legend rendering** + +Add this function: + +```javascript +function renderLegend() { + const legend = document.getElementById('legend'); + const presentation = getPresentation(); + const items = [...(presentation.legendItems || [])] + .sort((left, right) => left.displayOrder - right.displayOrder); + + const elements = items.map(item => { + const wrapper = document.createElement('span'); + wrapper.className = 'legend-item'; + + if (item.itemType === 'edge') { + const style = requireStyle( + presentation.edgeStyles, + item.styleRef, + '连接样式', + ); + const sample = document.createElement('i'); + sample.className = 'legend-line'; + sample.style.borderColor = style.strokeColor; + sample.style.borderTopWidth = `${Math.max(2, Number(style.strokeWidth || 4) / 2)}px`; + if (style.dashArray) sample.style.borderTopStyle = 'dashed'; + wrapper.appendChild(sample); + } else if (item.itemType === 'decoration') { + const style = requireStyle( + presentation.decorationStyles, + item.styleRef, + '装饰样式', + ); + const sample = document.createElement('i'); + sample.className = 'legend-chip'; + sample.style.background = style.fillColor; + sample.style.borderColor = style.strokeColor; + wrapper.appendChild(sample); + } else { + const style = requireStyle( + presentation.nodeStyles, + item.styleRef, + '节点样式', + ); + const sample = document.createElement('i'); + sample.className = 'legend-chip'; + sample.style.background = style.fillColor; + sample.style.borderColor = style.strokeColor; + wrapper.appendChild(sample); + } + + wrapper.append(document.createTextNode(item.label)); + return wrapper; + }); + + legend.replaceChildren(...elements); +} +``` + +- [ ] **Step 9: Update edge rendering to use DB styles** + +Inside `renderTopology()`, create: + +```javascript + const presentation = getPresentation(); +``` + +Replace: + +```javascript + const style = connectionClass(connection.type); + const pair = [fromNode.nodeKey, toNode.nodeKey].sort(); + const key = `${pair[0]}|${pair[1]}|${style}`; +``` + +with: + +```javascript + const edgeStyle = requireStyle( + presentation.edgeStyles, + connection.type, + '连接样式', + ); + const pair = [fromNode.nodeKey, toNode.nodeKey].sort(); + const key = `${pair[0]}|${pair[1]}|${connection.type}`; +``` + +Store `edgeStyle` in the visual connection: + +```javascript + visualConnections.set(key, { fromNode, toNode, edgeStyle, connection }); +``` + +Replace the render loop signature: + +```javascript + visualConnections.forEach(({ fromNode, toNode, edgeStyle, connection }) => { +``` + +Build the line without style classes: + +```javascript + const line = svgElement('line', { + class: 'connection', + x1: start.x, + y1: start.y, + x2: end.x, + y2: end.y, + 'data-type': connection.type, + 'data-from': fromNode.nodeKey, + 'data-to': toNode.nodeKey, + }); + applyEdgeStyle(line, edgeStyle); + connectionLayer.appendChild(line); +``` + +- [ ] **Step 10: Update decoration rendering** + +Replace: + +```javascript + if (node.decorations && node.decorations.radiator) { + decorationLayer.appendChild(createRadiator(node)); + } +``` + +with: + +```javascript + if (node.decorations && node.decorations.radiator) { + const radiatorStyle = requireStyle( + presentation.decorationStyles, + 'radiator', + '装饰样式', + ); + decorationLayer.appendChild(createRadiator(node, radiatorStyle)); + } +``` + +- [ ] **Step 11: Update node rectangle and text rendering** + +Before creating the group for each node: + +```javascript + const nodeStyle = requireStyle( + presentation.nodeStyles, + node.styleKey, + '节点样式', + ); +``` + +Replace direct `rect` append: + +```javascript + group.appendChild(svgElement('rect', { + class: node.styleKey, + x: node.x, + y: node.y, + width: node.width, + height: node.height, + rx: 16, + })); +``` + +with: + +```javascript + const rect = svgElement('rect', { + class: 'node-shape', + x: node.x, + y: node.y, + width: node.width, + height: node.height, + }); + applyNodeStyle(rect, nodeStyle); + group.appendChild(rect); +``` + +Update text calls: + +```javascript + appendNodeText( + group, + node, + 'node-title', + title, + node.y + node.height * (hasHint ? 0.34 : 0.42), + nodeStyle.titleColor || presentation.theme.tokens.textColor, + ); + appendNodeText( + group, + node, + 'node-meta', + node.displayMeta, + node.y + node.height * (hasHint ? 0.60 : 0.72), + nodeStyle.metaColor || presentation.theme.tokens.metaColor, + ); + appendNodeText( + group, + node, + 'node-hint', + node.displayHint, + node.y + node.height * 0.82, + nodeStyle.hintColor || presentation.theme.tokens.hintColor, + ); +``` + +- [ ] **Step 12: Call `renderLegend()` after data load** + +In the successful load path, immediately before or after `renderTopology()`, call: + +```javascript + renderLegend(); +``` + +The call must happen after `currentData` is assigned. + +- [ ] **Step 13: Run source tests** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m unittest tests.test_guanghan_topology_demo -v +``` + +Expected: + +```text +OK +``` + +- [ ] **Step 14: Run existing E2E and update pressurized selectors** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m unittest tests.test_guanghan_topology_e2e -v +``` + +Expected current failure: + +```text +FAIL: expected locator(".connection.pressurized").count() == 2 +``` + +Update selectors in `tests/test_guanghan_topology_e2e.py`: + +```python +await page.locator('.connection[data-type="pressurized_passage"]').count() +``` + +and: + +```python +strokes = await page.locator( + '.connection[data-type="pressurized_passage"]' +).evaluate_all( + "(els) => els.map((el) => getComputedStyle(el).stroke)" +) +``` + +- [ ] **Step 15: Run source and E2E tests** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m unittest tests.test_guanghan_topology_demo tests.test_guanghan_topology_e2e -v +``` + +Expected: + +```text +OK +``` + +- [ ] **Step 16: Commit frontend switch** + +Run: + +```powershell +git add data/wiki/guanghan_topology_demo.html tests/test_guanghan_topology_demo.py tests/test_guanghan_topology_e2e.py +git commit -m "feat(topology): render styles from presentation payload" +``` + +--- + +### Task 6: Add browser tests for DB style mutation and visual verification + +**Files:** + +- Modify: `tests/test_guanghan_topology_e2e.py` +- Create: `docs/guanghan_topology_presentation_db_verification_2026-07-09.md` +- Add screenshots under: `demo/topology_presentation_db/` + +- [ ] **Step 1: Add a test for database-driven edge color** + +Add this method to `GuanghanTopologyE2ETests`: + +```python + def test_database_edge_style_changes_browser_rendering(self) -> None: + async def scenario(base_url: str) -> None: + from topology_api.db import get_writer_connection + + with get_writer_connection() as conn: + conn.execute("SET search_path TO topology, public") + with conn.cursor() as cur: + cur.execute(""" + SELECT stroke_color + FROM diagram_edge_styles + WHERE connection_type = 'pressurized_passage' + """) + original_color = cur.fetchone()["stroke_color"] + cur.execute(""" + UPDATE diagram_edge_styles + SET stroke_color = '#00ffaa' + WHERE connection_type = 'pressurized_passage' + """) + conn.commit() + try: + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + page = await browser.new_page( + viewport={"width": 1440, "height": 1000} + ) + await page.goto( + f"{base_url}/guanghan_topology_demo.html" + "?mission=GHC-05", + wait_until="networkidle", + ) + await page.wait_for_function( + """() => document.querySelectorAll( + '.connection[data-type="pressurized_passage"]' + ).length === 2""" + ) + strokes = await page.locator( + '.connection[data-type="pressurized_passage"]' + ).evaluate_all( + "(els) => els.map((el) => getComputedStyle(el).stroke)" + ) + self.assertEqual( + strokes, + ["rgb(0, 255, 170)", "rgb(0, 255, 170)"], + ) + await browser.close() + finally: + with conn.cursor() as cur: + cur.execute(""" + UPDATE diagram_edge_styles + SET stroke_color = %(original)s + WHERE connection_type = 'pressurized_passage' + """, {"original": original_color}) + conn.commit() + + with topology_server() as base_url: + asyncio.run(scenario(base_url)) +``` + +- [ ] **Step 2: Add a test for database-driven node color** + +Add: + +```python + def test_database_node_style_changes_browser_rendering(self) -> None: + async def scenario(base_url: str) -> None: + from topology_api.db import get_writer_connection + + with get_writer_connection() as conn: + conn.execute("SET search_path TO topology, public") + with conn.cursor() as cur: + cur.execute(""" + SELECT fill_color + FROM diagram_node_styles + WHERE style_key = 'core-card' + """) + original_fill = cur.fetchone()["fill_color"] + cur.execute(""" + UPDATE diagram_node_styles + SET fill_color = '#123456' + WHERE style_key = 'core-card' + """) + conn.commit() + try: + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + page = await browser.new_page( + viewport={"width": 1440, "height": 1000} + ) + await page.goto( + f"{base_url}/guanghan_topology_demo.html" + "?mission=GHC-05", + wait_until="networkidle", + ) + core_fill = await page.locator( + '.topology-node[data-node-key="core_cabin"] rect' + ).evaluate( + "(el) => getComputedStyle(el).fill" + ) + self.assertEqual(core_fill, "rgb(18, 52, 86)") + await browser.close() + finally: + with conn.cursor() as cur: + cur.execute(""" + UPDATE diagram_node_styles + SET fill_color = %(original)s + WHERE style_key = 'core-card' + """, {"original": original_fill}) + conn.commit() + + with topology_server() as base_url: + asyncio.run(scenario(base_url)) +``` + +- [ ] **Step 3: Add a test that legend comes from presentation** + +Add: + +```python + def test_legend_renders_from_database_presentation(self) -> None: + async def scenario(base_url: str) -> None: + async with async_playwright() as pw: + browser = await pw.chromium.launch(headless=True) + page = await browser.new_page( + viewport={"width": 1440, "height": 1000} + ) + await page.goto( + f"{base_url}/guanghan_topology_demo.html?mission=GHC-05", + wait_until="networkidle", + ) + labels = await page.locator("#legend .legend-item").evaluate_all( + "(items) => items.map(item => item.textContent.trim())" + ) + self.assertEqual( + labels, + ["结构直连", "加压走道", "燃料 / 供电", "大型折叠散热面板"], + ) + await browser.close() + + with topology_server() as base_url: + asyncio.run(scenario(base_url)) +``` + +- [ ] **Step 4: Run full automated tests** + +Run: + +```powershell +.\.venv\Scripts\python.exe -m unittest tests.test_topology_api tests.test_guanghan_topology_demo tests.test_guanghan_topology_e2e -v +``` + +Expected: + +```text +OK +``` + +- [ ] **Step 5: Restart the local service** + +Stop any existing topology API process: + +```powershell +Get-CimInstance Win32_Process | + Where-Object { $_.CommandLine -match 'topology_api\.app' } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force } +``` + +Start the service: + +```powershell +Start-Process -WindowStyle Hidden -FilePath ".\.venv\Scripts\python.exe" -ArgumentList "-m", "topology_api.app" +``` + +Confirm health: + +```powershell +(Invoke-WebRequest -UseBasicParsing http://localhost:8080/health).StatusCode +``` + +Expected: + +```text +200 +``` + +- [ ] **Step 6: Capture required screenshots** + +Use Playwright or the in-app browser to capture these files: + +```text +demo/topology_presentation_db/ghc05_top.png +demo/topology_presentation_db/ghc05_mid.png +demo/topology_presentation_db/ghc05_bottom.png +demo/topology_presentation_db/ghc01_snapshot.png +demo/topology_presentation_db/legend.png +demo/topology_presentation_db/db_edge_color_override.png +demo/topology_presentation_db/db_node_color_override.png +``` + +Required visual checks: + +- `ghc05_top.png`: core cross remains correct; Wugang-greenhouse pressurized line is visible. +- `ghc05_mid.png`: habitat #1 to power north pressurized line is visible; radiators do not cover corridors. +- `ghc05_bottom.png`: Binglun vehicles remain west/south/east of the power module. +- `ghc01_snapshot.png`: early historical snapshot still shows only the expected early modules. +- `legend.png`: legend order and labels come from database. +- `db_edge_color_override.png`: temporary pressurized edge color override is visible. +- `db_node_color_override.png`: temporary core node fill override is visible. + +- [ ] **Step 7: Write the verification report** + +Create `docs/guanghan_topology_presentation_db_verification_2026-07-09.md`: + +```markdown +# 广寒基地拓扑图表现层数据库化验证报告 + +日期:2026-07-09 + +## 结论 + +页面已经切换为 B1 表现层数据库驱动:节点样式、连接样式、散热器样式和图例来自 `layout.presentation`。 + +## 自动化测试 + +命令: + +```powershell +.\.venv\Scripts\python.exe -m unittest tests.test_topology_api tests.test_guanghan_topology_demo tests.test_guanghan_topology_e2e -v +``` + +结果:通过。 + +## 服务验证 + +`http://localhost:8080/health` 返回 200。 + +## 截图 + +- `demo/topology_presentation_db/ghc05_top.png` +- `demo/topology_presentation_db/ghc05_mid.png` +- `demo/topology_presentation_db/ghc05_bottom.png` +- `demo/topology_presentation_db/ghc01_snapshot.png` +- `demo/topology_presentation_db/legend.png` +- `demo/topology_presentation_db/db_edge_color_override.png` +- `demo/topology_presentation_db/db_node_color_override.png` + +## 人工核查 + +- GHC-05 空间结构保持不变。 +- 两条加压走道仍为数据库默认绿色实线。 +- 散热器不覆盖加压走道和模块主体。 +- 图例由 API 返回并渲染。 +- 临时修改数据库连接线颜色后,浏览器渲染随之变化。 +- 临时修改数据库核心舱填充色后,浏览器渲染随之变化。 +``` + +- [ ] **Step 8: Run final checks** + +Run: + +```powershell +git diff --check +.\.venv\Scripts\python.exe -m unittest tests.test_topology_api tests.test_guanghan_topology_demo tests.test_guanghan_topology_e2e -v +``` + +Expected: + +```text +OK +``` + +- [ ] **Step 9: Commit final verification** + +Run: + +```powershell +git add tests/test_guanghan_topology_e2e.py docs/guanghan_topology_presentation_db_verification_2026-07-09.md demo/topology_presentation_db +git commit -m "test(topology): verify database-driven presentation rendering" +``` + +--- + +## Final delivery checklist + +Before reporting completion: + +- [ ] `git status --short` reviewed; unrelated user files remain untouched. +- [ ] `git diff --check` passes. +- [ ] Full unit/E2E command passes. +- [ ] `http://localhost:8080/health` returns 200 after service restart. +- [ ] GHC-05 screenshots reviewed manually. +- [ ] Verification report committed. +- [ ] Final response includes current branch, commit list, tests run, screenshot paths, and page URL. + +## Expected commit sequence + +1. `test(topology): require database presentation payload` +2. `feat(topology): seed diagram presentation styles` +3. `feat(topology): return diagram presentation styles` +4. `test(topology): require database-driven visual semantics` +5. `feat(topology): render styles from presentation payload` +6. `test(topology): verify database-driven presentation rendering`