arch-design-agent-skill-das.../docs/superpowers/plans/2026-03-24-v2-fix-gaps.md
openclaw ac5b7bccc7 docs: add v2 gap fix implementation plan (10 tasks)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:01:59 +00:00

29 KiB

V2 Gap Fix 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: Fix 7 gaps (P0+P1) so the graph visualization shows grouped layout, real status colors, working document edges, rich detail panel, legend, back button, and edit shortcut.

Architecture: Backend changes add parent field to GraphNode, status mapping from FileStatusEntry, and document nodes with proper edge resolution. Frontend changes replace the single-center D3 layout with per-group forceX/forceY, add compound document view toggle, enrich the detail panel with API calls, and add legend/back-button UI.

Tech Stack: Python 3.12 / FastAPI / dataclasses (backend), Vue 3 / TypeScript / D3.js v7 / Pinia (frontend), pytest (backend tests)

Spec: docs/superpowers/specs/2026-03-24-v2-fix-gaps-design.md


File Structure

Files to Modify

File Responsibility Tasks
backend/app/modules/graph/domain/entities.py GraphNode dataclass — add parent field 1
backend/app/modules/graph/application/services.py build_panorama — status mapping, document nodes, edge fix 2, 3
backend/app/modules/graph/interfaces/http/router.py Pass design_dir to build_panorama 4
backend/tests/test_graph_service.py Update existing tests + add new tests 1, 2, 3, 4
frontend/src/shared/types/api.ts Add parent to GraphNode interface 5
frontend/src/modules/graph/components/GraphPanorama.vue Group layout, compound layout, toggle, back button 6, 8
frontend/src/modules/graph/components/GraphDetail.vue Rich detail panel, edit button 7

Files to Create

File Responsibility Tasks
frontend/src/modules/graph/components/GraphLegend.vue Legend component 9

Task 1: Domain — Add parent field to GraphNode

Files:

  • Modify: backend/app/modules/graph/domain/entities.py:4-10

  • Test: backend/tests/test_graph_service.py

  • Step 1: Write the failing test

Add to backend/tests/test_graph_service.py:

def test_graph_node_has_parent_field(graph_service, scan_result):
    view = graph_service.build_panorama(scan_result)
    # All nodes should have a parent attribute (None for most, doc_id for some)
    for node in view.nodes:
        assert hasattr(node, 'parent')
  • Step 2: Run test to verify it fails

Run: cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_graph_node_has_parent_field -v Expected: FAIL — GraphNode has no parent attribute

  • Step 3: Add parent field to GraphNode

Edit backend/app/modules/graph/domain/entities.py — add to the GraphNode dataclass:

@dataclass
class GraphNode:
    id: str
    type: str           # capability, module, entity, runtime_component, document
    label: str
    status: str         # FileStatus value or "unknown"
    group_id: str
    parent: str | None = None  # doc_id of containing document, if any
  • Step 4: Run test to verify it passes

Run: cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_graph_node_has_parent_field -v Expected: PASS

  • Step 5: Run all existing graph tests to verify no regression

Run: cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py -v Expected: All 9 tests PASS (existing 8 + new 1)

  • Step 6: Commit
git add backend/app/modules/graph/domain/entities.py backend/tests/test_graph_service.py
git commit -m "feat(graph): add parent field to GraphNode domain entity"

Task 2: Application — Status mapping in build_panorama (GAP-B1)

Files:

  • Modify: backend/app/modules/graph/application/services.py:22-77

  • Test: backend/tests/test_graph_service.py

  • Step 1: Add design_dir fixture and write the failing tests

First, add a design_dir fixture to backend/tests/test_graph_service.py (will be used by all subsequent tests):

@pytest.fixture
def design_dir():
    return "/workspace/arch-design-agent-skill-dashboard/design"

Then add the new tests (note: all new tests from this point forward take design_dir and pass it to build_panorama):

def test_panorama_nodes_have_real_status(graph_service, scan_result, design_dir):
    view = graph_service.build_panorama(scan_result, design_dir=design_dir)
    statuses = {n.status for n in view.nodes}
    # At least some nodes should NOT be "unknown" since we have real file statuses
    assert statuses != {"unknown"}, "All nodes still have status='unknown' — status mapping not working"


def test_panorama_status_values_are_valid(graph_service, scan_result, design_dir):
    view = graph_service.build_panorama(scan_result, design_dir=design_dir)
    valid_statuses = {"ok", "sparse", "missing", "template-residue", "placeholder-heavy", "unknown"}
    for node in view.nodes:
        assert node.status in valid_statuses, f"Node {node.id} has invalid status '{node.status}'"
  • Step 2: Run tests to verify they fail

Run: cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_panorama_nodes_have_real_status tests/test_graph_service.py::test_panorama_status_values_are_valid -v Expected: test_panorama_nodes_have_real_status FAILS (all statuses are "unknown")

  • Step 3: Implement status mapping

Edit backend/app/modules/graph/application/services.py. Add the _SOURCE_FILES constant after _GROUPS and modify build_panorama to build the file status map:

_SOURCE_FILES: dict[str, str] = {
    "capability": "business-architecture/02-capability-map.csv",
    "module": "application-architecture/02-modules.csv",
    "entity": "data-architecture/01-entities.csv",
    "runtime_component": "technology-architecture/01-runtime-components.csv",
}

At the top of build_panorama(), add design_dir parameter with default so existing tests still work:

def build_panorama(self, scan_result: ScanResult, design_dir: str = "") -> GraphView:
    # Build file path -> status mapping
    file_status_map: dict[str, str] = {
        fs.path: fs.status.value for fs in scan_result.file_statuses
    }

Replace each status="unknown" with:

status=file_status_map.get(_SOURCE_FILES.get("capability", ""), "unknown"),  # for caps
status=file_status_map.get(_SOURCE_FILES.get("module", ""), "unknown"),      # for modules
status=file_status_map.get(_SOURCE_FILES.get("entity", ""), "unknown"),      # for entities
status=file_status_map.get(_SOURCE_FILES.get("runtime_component", ""), "unknown"),  # for runtime
  • Step 4: Run tests to verify they pass

Run: cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py -v Expected: All tests PASS including the two new status tests

  • Step 5: Commit
git add backend/app/modules/graph/application/services.py backend/tests/test_graph_service.py
git commit -m "feat(graph): map node status from FileStatus via source_file (GAP-B1)"

Task 3: Application — Document nodes + doc→doc edges (GAP-B3)

Files:

  • Modify: backend/app/modules/graph/application/services.py

  • Test: backend/tests/test_graph_service.py

  • Step 1: Write the failing tests

Add to backend/tests/test_graph_service.py:

def test_panorama_has_document_nodes(graph_service, scan_result, design_dir):
    view = graph_service.build_panorama(scan_result, design_dir=design_dir)
    doc_nodes = [n for n in view.nodes if n.type == "document"]
    assert len(doc_nodes) > 0, "No document nodes found"
    assert all(n.group_id == "cross-layer" for n in doc_nodes)


def test_panorama_document_edges(graph_service, scan_result, design_dir):
    view = graph_service.build_panorama(scan_result, design_dir=design_dir)
    doc_edges = [e for e in view.edges if e.relation == "documents"]
    assert len(doc_edges) > 0, "No document edges found"


def test_panorama_capability_nodes_have_parent(graph_service, scan_result, design_dir):
    view = graph_service.build_panorama(scan_result, design_dir=design_dir)
    cap_nodes = [n for n in view.nodes if n.type == "capability"]
    # Capability nodes should have parent pointing to a document
    nodes_with_parent = [n for n in cap_nodes if n.parent is not None]
    assert len(nodes_with_parent) > 0, "No capability nodes have a parent document"
  • Step 2: Run tests to verify they fail

Run: cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_panorama_has_document_nodes tests/test_graph_service.py::test_panorama_document_edges tests/test_graph_service.py::test_panorama_capability_nodes_have_parent -v Expected: All 3 FAIL

  • Step 3: Implement document nodes and edge resolution

Edit backend/app/modules/graph/application/services.py.

Add path helper functions before the class:

from pathlib import PurePosixPath


def _to_rel_path(doc_file_path: str, design_dir: str) -> str:
    """Convert absolute doc.file_path to design-dir-relative path."""
    try:
        return str(PurePosixPath(doc_file_path).relative_to(design_dir))
    except ValueError:
        return doc_file_path


def _resolve_ref_path(ref_path: str, doc_rel_path: str) -> str:
    """Resolve a relative upstream/downstream ref against the doc's directory."""
    doc_dir = str(PurePosixPath(doc_rel_path).parent)
    resolved = str(PurePosixPath(doc_dir) / ref_path)
    parts: list[str] = []
    for part in PurePosixPath(resolved).parts:
        if part == '..':
            if parts:
                parts.pop()
        else:
            parts.append(part)
    return str(PurePosixPath(*parts)) if parts else ""

Important ordering: Build file_to_doc mapping BEFORE Steps 2-5 so entity nodes can get their parent. Restructure build_panorama to:

  1. Build file_status_map (already done in Task 2)
  2. Build file_to_doc from scan_result.design_documents + create document nodes
  3. Then create entity nodes (Steps 2-5) with parent set via file_to_doc
  4. Then create edges (Steps 6-9) with fixed Step 9

Step 5.5 (now moved before Steps 2-5):

        # Build document node mapping first (needed for parent refs)
        file_to_doc: dict[str, str] = {}
        for doc in scan_result.design_documents:
            doc_rel = _to_rel_path(doc.file_path, design_dir)
            file_to_doc[doc_rel] = doc.doc_id
            nodes.append(GraphNode(
                id=doc.doc_id,
                type="document",
                label=doc.title or doc.doc_id,
                status=file_status_map.get(doc_rel, "unknown"),
                group_id="cross-layer",
            ))
            node_ids.add(doc.doc_id)

In each entity creation (Steps 2-5), add parent:

        # e.g. for capability:
        parent_doc_id = file_to_doc.get(_SOURCE_FILES.get("capability"))
        nodes.append(GraphNode(
            id=node_id, type="capability", label=cap.name,
            status=file_status_map.get(_SOURCE_FILES.get("capability", ""), "unknown"),
            group_id="business",
            parent=parent_doc_id,
        ))

Replace Step 9 with path resolution + deduplication:

        # Step 9: DesignDocument.downstream → doc-to-doc edges (deduplicated)
        path_to_doc: dict[str, str] = {}
        doc_rel_paths: dict[str, str] = {}
        for doc in scan_result.design_documents:
            doc_rel = _to_rel_path(doc.file_path, design_dir)
            path_to_doc[doc_rel] = doc.doc_id
            doc_rel_paths[doc.doc_id] = doc_rel

        seen_edges: set[tuple[str, str]] = set()
        for doc in scan_result.design_documents:
            doc_rel = doc_rel_paths[doc.doc_id]
            for down_path in doc.downstream:
                resolved = _resolve_ref_path(down_path, doc_rel)
                down_doc_id = path_to_doc.get(resolved)
                if down_doc_id and down_doc_id in node_ids:
                    edge_key = (doc.doc_id, down_doc_id)
                    if edge_key not in seen_edges:
                        seen_edges.add(edge_key)
                        edges.append(GraphEdge(
                            source=doc.doc_id, target=down_doc_id,
                            relation="documents",
                        ))
  • Step 4: Run new tests to verify they pass

Run: cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py::test_panorama_has_document_nodes tests/test_graph_service.py::test_panorama_document_edges tests/test_graph_service.py::test_panorama_capability_nodes_have_parent -v Expected: All 3 PASS

  • Step 5: Run ALL graph tests to check no regression

Run: cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/test_graph_service.py tests/test_api_graph.py -v Expected: All tests PASS

  • Step 6: Commit
git add backend/app/modules/graph/application/services.py backend/tests/test_graph_service.py
git commit -m "feat(graph): add document nodes, parent refs, and fixed doc edges (GAP-B3)"

Task 4: Interfaces — Pass design_dir from router to build_panorama

Files:

  • Modify: backend/app/modules/graph/interfaces/http/router.py:40-56

  • Test: backend/tests/test_api_graph.py

  • Step 1: Update the router to pass design_dir

Edit backend/app/modules/graph/interfaces/http/router.py:

In get_graph():

@router.get("")
def get_graph(project_id: str):
    """Build and return the full panorama graph for a project."""
    project = _project_service.get_project(project_id)
    scan_result = _get_or_trigger_scan(project_id)
    view = _graph_service.build_panorama(scan_result, design_dir=project.design_dir)
    return asdict(view)

In get_neighbors():

@router.get("/nodes/{node_id}/neighbors")
def get_neighbors(project_id: str, node_id: str):
    """Return the subgraph of neighbors for a given node."""
    project = _project_service.get_project(project_id)
    scan_result = _get_or_trigger_scan(project_id)
    view = _graph_service.build_panorama(scan_result, design_dir=project.design_dir)
    neighbors = _graph_service.get_neighbors(view, node_id)
    return asdict(neighbors)
  • Step 2: Update test_graph_service.py to pass design_dir

Add a design_dir fixture and update all build_panorama calls:

@pytest.fixture
def design_dir():
    return "/workspace/arch-design-agent-skill-dashboard/design"

Update all test function signatures to include design_dir parameter. Update all calls from:

view = graph_service.build_panorama(scan_result)

to:

view = graph_service.build_panorama(scan_result, design_dir=design_dir)
  • Step 3: Run ALL backend tests

Run: cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/ -v Expected: All tests PASS

  • Step 4: Commit
git add backend/app/modules/graph/interfaces/http/router.py backend/tests/test_graph_service.py
git commit -m "feat(graph): pass design_dir from router to build_panorama"

Task 5: Frontend types — Add parent to GraphNode interface

Files:

  • Modify: frontend/src/shared/types/api.ts:31-37

  • Step 1: Add parent field

Edit frontend/src/shared/types/api.ts:

export interface GraphNode {
  id: string
  type: string
  label: string
  status: string
  group_id: string
  parent: string | null
}
  • Step 2: Run type check

Run: cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit Expected: No errors

  • Step 3: Commit
git add frontend/src/shared/types/api.ts
git commit -m "feat(graph): add parent field to GraphNode TypeScript interface"

Task 6: Frontend — Group-partitioned layout + compound layout + toggle (GAP-F1)

Files:

  • Modify: frontend/src/modules/graph/components/GraphPanorama.vue

This is the largest task. The entire drawGraph() function needs rewriting.

  • Step 1: Add state variables and update constants

Add after existing refs in <script setup>:

const showDocumentView = ref(false)
const isDrillDown = ref(false)
const drillDownNodeLabel = ref('')

const GROUP_POSITIONS: Record<string, { x: number; y: number }> = {
  business:      { x: 0.50, y: 0.15 },
  application:   { x: 0.50, y: 0.38 },
  data:          { x: 0.30, y: 0.65 },
  technology:    { x: 0.70, y: 0.65 },
  'cross-layer': { x: 0.50, y: 0.85 },
}

const EDGE_COLORS: Record<string, string> = {
  traces_to: '#666',
  depends_on: '#999',
  documents: '#42A5F5',
  integrates_with: '#AB47BC',
}

Also update the existing EDGE_STYLES constant to fix documents from dashed to solid:

const EDGE_STYLES: Record<string, string> = {
  traces_to: '0', depends_on: '6,3', integrates_with: '4,2', documents: '0',
}
  • Step 2: Rewrite drawGraph() for default mode

Replace the drawGraph() function. Key changes:

  • Filter out type === "document" nodes and relation === "documents" edges when showDocumentView.value is false
  • Replace d3.forceCenter with d3.forceX/d3.forceY per group with strength 0.4
  • Correct node shapes: circle r=18 for capability, rect 28x28 for module, diamond 24x24 for entity, circle r=14 for runtime_component
  • Apply STATUS_COLORS for fill, EDGE_STYLES + EDGE_COLORS for edges

Core simulation setup:

const simulation = d3.forceSimulation(simNodes)
  .force('link', d3.forceLink(simEdges).id((d: any) => d.id).distance(60))
  .force('charge', d3.forceManyBody().strength(-150))
  .force('x', d3.forceX((d: any) =>
    (GROUP_POSITIONS[d.group_id] || GROUP_POSITIONS['cross-layer']).x * width
  ).strength(0.4))
  .force('y', d3.forceY((d: any) =>
    (GROUP_POSITIONS[d.group_id] || GROUP_POSITIONS['cross-layer']).y * height
  ).strength(0.4))
  .force('collide', d3.forceCollide(30))
  • Step 3: Add compound layout mode (document view)

When showDocumentView.value is true:

  • Include document nodes, render as large <rect> containers
  • Size by child count: width = Math.max(150, childCount * 60), height = Math.max(100, childCount * 40)
  • On each tick, clamp entity nodes with parent inside parent's bounds (pad=20):
if (n.parent) {
  const p = nodeMap[n.parent]
  const pad = 20
  n.x = Math.max(p.x - p.w/2 + pad, Math.min(p.x + p.w/2 - pad, n.x))
  n.y = Math.max(p.y - p.h/2 + pad, Math.min(p.y + p.h/2 - pad, n.y))
}
  • Step 4: Add toggle button and group labels in template

Add after scan-summary div:

<div class="toolbar">
  <button class="toggle-btn" :class="{ active: showDocumentView }" @click="toggleDocumentView">
    {{ showDocumentView ? '默认视图' : '文档视图' }}
  </button>
</div>

Add toggle function:

function toggleDocumentView() {
  showDocumentView.value = !showDocumentView.value
  drawGraph()
}

Render group labels at each group position as static text in the SVG.

  • Step 5: Run type check

Run: cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit Expected: No errors

  • Step 6: Commit
git add frontend/src/modules/graph/components/GraphPanorama.vue
git commit -m "feat(graph): group-partitioned layout with document view toggle (GAP-F1)"

Task 7: Frontend — Rich GraphDetail panel + edit button (GAP-F2 + GAP-F5)

Files:

  • Modify: frontend/src/modules/graph/components/GraphDetail.vue

  • Modify: frontend/src/modules/graph/components/GraphPanorama.vue (update props)

  • Step 1: Rewrite GraphDetail.vue

Full rewrite to:

  1. Accept additional props: graphView: GraphView | null, projectId: string
  2. Fetch detail API on node selection based on node.type:
    • capabilitygetCapabilityDetail(projectId, node.id)
    • modulegetModuleDetail(projectId, node.id)
    • entitygetEntityDetail(projectId, node.id)
    • others → show basic fields only
  3. Display attributes section (iterate over response fields dynamically)
  4. Display related entities section (from graphView.edges)
  5. Edit button (navigates to /projects/${projectId}/editor?file=${sourceFile})

Key imports:

import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { getCapabilityDetail, getModuleDetail, getEntityDetail } from '../api'
import type { GraphNode, GraphView } from '@/shared/types/api'

Define SOURCE_FILES constant:

const SOURCE_FILES: Record<string, string> = {
  capability: 'business-architecture/02-capability-map.csv',
  module: 'application-architecture/02-modules.csv',
  entity: 'data-architecture/01-entities.csv',
  runtime_component: 'technology-architecture/01-runtime-components.csv',
}

Emit a selectNode event when a related entity is clicked. The handler looks up the full GraphNode from graphView.nodes by ID before emitting:

const emit = defineEmits<{ close: []; selectNode: [node: GraphNode] }>()

function onRelatedEntityClick(nodeId: string) {
  const found = props.graphView?.nodes.find(n => n.id === nodeId)
  if (found) emit('selectNode', found)
}

Edit button click handler:

const router = useRouter()

function openEditor() {
  if (!sourceFile.value) return
  router.push({
    path: `/projects/${props.projectId}/editor`,
    query: { file: sourceFile.value }
  })
}
  • Step 2: Update GraphPanorama.vue to pass new props
<GraphDetail
  :node="selectedNode"
  :graphView="graphView"
  :projectId="route.params.id as string"
  @close="clearSelection"
  @selectNode="store.selectNode"
/>
  • Step 3: Run type check

Run: cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit Expected: No errors

  • Step 4: Commit
git add frontend/src/modules/graph/components/GraphDetail.vue frontend/src/modules/graph/components/GraphPanorama.vue
git commit -m "feat(graph): rich detail panel with API fetch and edit button (GAP-F2, GAP-F5)"

Task 8: Frontend — Back to panorama button (GAP-F4)

Files:

  • Modify: frontend/src/modules/graph/components/GraphPanorama.vue

  • Step 1: Wire up drill-down state

The isDrillDown and drillDownNodeLabel refs were added in Task 6. Now wire them:

Update double-click handler:

node.on('dblclick', (_event: any, d: any) => {
  const projectId = route.params.id as string
  isDrillDown.value = true
  drillDownNodeLabel.value = d.label
  store.loadNeighbors(projectId, d.id)
})

Add returnToPanorama:

async function returnToPanorama() {
  const projectId = route.params.id as string
  isDrillDown.value = false
  drillDownNodeLabel.value = ''
  await store.loadGraph(projectId)
}
  • Step 2: Add back button in template
<div v-if="isDrillDown" class="drill-down-bar">
  <button @click="returnToPanorama" class="back-btn">← 返回全景图</button>
  <span class="drill-down-label">当前: {{ drillDownNodeLabel }}</span>
</div>
  • Step 3: Add styles
.drill-down-bar {
  position: absolute; top: 12px; left: 12px;
  display: flex; align-items: center; gap: 12px;
  background: white; padding: 8px 16px; border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1); z-index: 10;
}
.back-btn {
  background: #1976D2; color: white; border: none;
  padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 13px;
}
.back-btn:hover { background: #1565C0; }
.drill-down-label { font-size: 13px; color: #666; }
  • Step 4: Run type check

Run: cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit Expected: No errors

  • Step 5: Commit
git add frontend/src/modules/graph/components/GraphPanorama.vue
git commit -m "feat(graph): add back-to-panorama button for drill-down mode (GAP-F4)"

Task 9: Frontend — Graph Legend component (GAP-F3)

Files:

  • Create: frontend/src/modules/graph/components/GraphLegend.vue

  • Modify: frontend/src/modules/graph/components/GraphPanorama.vue

  • Step 1: Create GraphLegend.vue

Create frontend/src/modules/graph/components/GraphLegend.vue with:

  • SVG shapes matching the graph (circle, rect, diamond, small-circle, container-rect)
  • Status color swatches with labels
  • Edge style samples with labels
  • Collapsible (expanded by default)
  • Positioned bottom-right, semi-transparent background
<template>
  <div class="graph-legend" :class="{ collapsed: !expanded }">
    <div class="legend-header" @click="expanded = !expanded">
      <span>图例</span>
      <span class="toggle">{{ expanded ? '▼' : '▶' }}</span>
    </div>
    <div v-if="expanded" class="legend-body">
      <div class="legend-section">
        <div class="legend-title">形状</div>
        <div class="legend-item">
          <svg width="20" height="20"><circle cx="10" cy="10" r="8" fill="#9E9E9E"/></svg>
          <span>Capability</span>
        </div>
        <div class="legend-item">
          <svg width="20" height="20"><rect x="2" y="2" width="16" height="16" rx="2" fill="#9E9E9E"/></svg>
          <span>Module</span>
        </div>
        <div class="legend-item">
          <svg width="20" height="20"><polygon points="10,2 18,10 10,18 2,10" fill="#9E9E9E"/></svg>
          <span>Entity</span>
        </div>
        <div class="legend-item">
          <svg width="20" height="20"><circle cx="10" cy="10" r="6" fill="#9E9E9E"/></svg>
          <span>Runtime</span>
        </div>
        <div class="legend-item">
          <svg width="20" height="20"><rect x="1" y="4" width="18" height="12" rx="2" fill="none" stroke="#9E9E9E" stroke-width="2"/></svg>
          <span>Document</span>
        </div>
      </div>
      <div class="legend-section">
        <div class="legend-title">状态</div>
        <div class="legend-item" v-for="s in statuses" :key="s.label">
          <svg width="20" height="20"><circle cx="10" cy="10" r="6" :fill="s.color"/></svg>
          <span>{{ s.label }}</span>
        </div>
      </div>
      <div class="legend-section">
        <div class="legend-title">边线</div>
        <div class="legend-item" v-for="e in edgeTypes" :key="e.label">
          <svg width="40" height="12">
            <line x1="0" y1="6" x2="40" y2="6" :stroke="e.color" stroke-width="2" :stroke-dasharray="e.dash" />
          </svg>
          <span>{{ e.label }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const expanded = ref(true)

const statuses = [
  { label: 'OK', color: '#4CAF50' },
  { label: 'Sparse', color: '#FFC107' },
  { label: 'Missing', color: '#F44336' },
  { label: 'Template Residue', color: '#FF9800' },
  { label: 'Placeholder Heavy', color: '#9C27B0' },
  { label: 'Unknown', color: '#9E9E9E' },
]

const edgeTypes = [
  { label: 'traces_to', color: '#666', dash: '0' },
  { label: 'depends_on', color: '#999', dash: '6,3' },
  { label: 'documents', color: '#42A5F5', dash: '0' },
  { label: 'integrates_with', color: '#AB47BC', dash: '4,2' },
]
</script>

<style scoped>
.graph-legend {
  position: absolute; bottom: 16px; right: 16px;
  background: rgba(255,255,255,0.92); border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.12);
  padding: 8px 12px; z-index: 10; min-width: 180px;
  font-size: 12px; pointer-events: auto;
}
.legend-header {
  display: flex; justify-content: space-between; align-items: center;
  cursor: pointer; font-weight: 600; font-size: 13px; padding-bottom: 4px;
}
.toggle { font-size: 10px; color: #999; }
.legend-body { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }
.legend-section { display: flex; flex-direction: column; gap: 2px; }
.legend-title { font-weight: 600; color: #666; margin-bottom: 2px; }
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-item span { color: #333; }
</style>
  • Step 2: Import GraphLegend in GraphPanorama.vue

Add import:

import GraphLegend from './GraphLegend.vue'

Add to template (inside .graph-panorama div, after <svg>):

<GraphLegend />
  • Step 3: Run type check

Run: cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit Expected: No errors

  • Step 4: Commit
git add frontend/src/modules/graph/components/GraphLegend.vue frontend/src/modules/graph/components/GraphPanorama.vue
git commit -m "feat(graph): add collapsible legend component (GAP-F3)"

Task 10: Final verification

  • Step 1: Run all backend tests

Run: cd /workspace/arch-design-agent-skill-dashboard/backend && python -m pytest tests/ -v Expected: All tests PASS

  • Step 2: Run frontend type check

Run: cd /workspace/arch-design-agent-skill-dashboard/frontend && npx vue-tsc --noEmit Expected: No errors

  • Step 3: Run frontend build

Run: cd /workspace/arch-design-agent-skill-dashboard/frontend && npm run build Expected: Build succeeds

  • Step 4: Verify git status is clean

Run: git status Expected: No uncommitted changes