From d98e4517c6aaed0f013ef481062efdf6509a4a03 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 17:38:16 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(issue-tracker):=20BOARD.html=20?= =?UTF-8?q?=EB=A6=AC=EB=94=94=EC=9E=90=EC=9D=B8=20=E2=80=94=20agent-harnes?= =?UTF-8?q?s=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8/=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EC=B1=84?= =?UTF-8?q?=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 가독성이 낮던 라이트 SVG 보드를 agent-harness director 대시보드의 시각 언어로 교체: 다크 테마, div 카드(168×74) + SVG 베지어 엣지, 상태별 CSS 변수 팔레트(테두리/배경/글로우), in-progress 펄스 + 토큰바, ELIGIBLE 블루 글로우, 헤더 바(진행도 done/total·라이브 카운트·팔레트 칩 필터·줌 컨트롤), 드릴 스타일 디테일 패널, 푸터 엣지 범례. 의존성은 이제 좌→우로 흐른다(blocker가 왼쪽): 레이어링·교차 최소화·셸프 팩킹은 추상 (slot, layer) 단위 그대로 두고 최종 pos 방출에서만 전치 — 교차 수는 축 교환에 불변이므로 결과 보존. 트리아지/상태 체계는 그대로: 9개 시각 상태, ELIGIBLE 계산, block/spawned/relates 엣지 종류, 에픽 박스+콜랩스, done 기본 숨김. 활성 블록 엣지는 blocker가 작업 중이면 초록(진행 중인 크리티컬 패스), 아니면 앰버. 빈 보드(티켓 0개) 렌더가 max() 빈 시퀀스로 죽던 것 가드 — Pages 워크플로가 첫 티켓 등록 전에 스케줄 렌더를 돌리는 실제 경로. 검증: mock-gh 데모 보드 13티켓 Playwright 스크린샷(기본/디테일/에픽 콜랩스, 콘솔 에러 0), tests/issue-tracker 90 assertions green. --- skills/issue-tracker/scripts/board-map.sh | 83 ++-- .../scripts/board-map.template.html | 458 ++++++++++++------ 2 files changed, 350 insertions(+), 191 deletions(-) diff --git a/skills/issue-tracker/scripts/board-map.sh b/skills/issue-tracker/scripts/board-map.sh index 0ba04a6703..88dbdd4788 100755 --- a/skills/issue-tracker/scripts/board-map.sh +++ b/skills/issue-tracker/scripts/board-map.sh @@ -11,10 +11,12 @@ # collapse epics), opened in a browser; and BOARD.md — a minimal # node/state table. # -# Reading BOARD.html: node color = state; ELIGIBLE (ready-for-agent + all blockers -# done, not an epic) gets a thick green border; a solid arrow is an ACTIVE block, -# a dotted one a satisfied dependency; labeled dotted lines carry spawned/relates -# lineage; each epic is a labeled box around its members (click to collapse). +# Reading BOARD.html: dependencies flow left→right (a blocker sits left of its +# dependents). Card color = state; ELIGIBLE (ready-for-agent + all blockers done, +# not an epic) glows blue; in-progress pulses green. An amber arrow is an ACTIVE +# block (green while its blocker is itself being worked), a dim dashed one a +# satisfied dependency; labeled dashed lines carry spawned/relates lineage; each +# epic is a labeled box around its members (click the epic's card to collapse). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=_lib.sh @@ -191,13 +193,18 @@ def order_cluster(members): break return best -# Coordinates: give each top-level cluster (epic tree or lone node) its own -# disjoint column band, so an epic's bounding box can only ever enclose its own -# members. Band width is the max nodes-per-layer (order-independent). Two levers -# cut crossings without ever breaking that containment: WITHIN a band, the slot a -# node takes (order_cluster, above); and the left-to-right ORDER of the bands -# themselves (below), which cuts crossings among the edges that span clusters. -COL, ROW = 210, 110 +# Coordinates: the board renders as a left→right layered DAG (the agent-harness +# orientation: layer = x, so a blocker sits LEFT of its dependents). Each +# top-level cluster (epic tree or lone node) gets its own disjoint band on the +# slot axis, so an epic's bounding box can only ever enclose its own members. +# Band width is the max nodes-per-layer (order-independent). Two levers cut +# crossings without ever breaking that containment: WITHIN a band, the slot a +# node takes (order_cluster, above); and the ORDER of the bands themselves +# (below), which cuts crossings among the edges that span clusters. All of the +# ordering math runs in abstract (slot, layer) units — the transpose to screen +# px happens only at the final pos emit (segment crossings are invariant under +# swapping axes, so the minimization is orientation-blind). +XCOL, YROW = 240, 100 # px per layer step (card 168 + edge gap) / per slot step (card 74 + gap) clusters = {} for t in order: clusters.setdefault(root(t), []).append(t) @@ -215,8 +222,8 @@ for rt in clusters: slot[t] = i local[rt], width[rt] = slot, w -# Band order: reorder the swimlanes left-to-right to cut crossings among the -# edges that span two clusters. Same discipline as the within-band pass — pure, +# Band order: reorder the swimlanes along the slot axis to cut crossings among +# the edges that span two clusters. Same discipline as the within-band pass — pure, # deterministic, never worse than the id-stable baseline (id-order is the first # candidate and we only ever keep a strictly-fewer-crossings order). The classic # Sugiyama recipe: a barycenter sweep for a good global order, then a transpose @@ -297,17 +304,18 @@ if any(cl_nbr.values()): if not moved: break -# Shelf-pack the top-level bands into rows instead of one long ribbon, so the -# board fills vertical space. Laid out in a single row, a board of N mostly -# independent tickets is an N-wide, ~1-tall strip that auto-fits to a thin -# sliver (huge empty margins above and below); wrapping it into an ~M-wide grid -# fills the frame and multiplies the readable zoom. Each cluster stays a -# contiguous block placed at one shelf origin, so the disjoint-column guarantee -# now also holds across rows (different shelves occupy different row bands) — -# an epic's bounding box still encloses only its own members. best_seq's -# crossing-minimized order is preserved; we only wrap it. Shelf width = the one -# whose packed bounding box best matches a landscape aspect (fitView in the -# template does the final viewport scaling). +# Shelf-pack the top-level bands into a grid instead of one long strip, so the +# board fills the frame. In the transposed (left→right) orientation a band is a +# ROW of the screen: N mostly independent tickets laid in a single column of +# bands is an N-tall, ~1-wide sliver that auto-fits tiny; wrapping the bands +# into shelves (stacked vertically, shelves side by side) fills the frame and +# multiplies the readable zoom. Each cluster stays a contiguous block placed at +# one shelf origin, so the disjoint-band guarantee still holds — an epic's +# bounding box encloses only its own members. best_seq's crossing-minimized +# order is preserved; we only wrap it. Shelf capacity = the one whose packed +# bounding box best matches a landscape aspect (fitView in the template does +# the final viewport scaling). The packer itself works in abstract +# (slot, layer) units; only the score and the pos emit apply the transpose. cheight = {rt: 1 + max(LAYER[t] for t in clusters[rt]) for rt in clusters} TARGET_ASPECT = 2.4 # packed width:height — landscape, near a typical viewport @@ -321,20 +329,23 @@ def _pack(shelf_cols): maxx = max(maxx, cx + w); cx += w; sh = max(sh, cheight[rt]) return place, maxx, top + sh -_widest, _total = max(width.values()), sum(width.values()) -_best = None -for _sc in range(_widest, _total + 1): # widest single cluster … full single row - _place, _cols, _rows = _pack(_sc) - _score = abs((_cols * COL) / (_rows * ROW) - TARGET_ASPECT) - if _best is None or _score < _best[0]: - _best = (_score, _place) -shelf = _best[1] - -pos = {} +shelf = {} +if clusters: # an empty board still renders (the Pages workflow runs pre-first-ticket) + _widest, _total = max(width.values()), sum(width.values()) + _best = None + for _sc in range(_widest, _total + 1): # widest single cluster … one full shelf + _place, _slots, _layers = _pack(_sc) + # transposed screen extents: layers run along x, slots along y + _score = abs((_layers * XCOL) / (_slots * YROW) - TARGET_ASPECT) + if _best is None or _score < _best[0]: + _best = (_score, _place) + shelf = _best[1] + +pos = {} # the one transpose point: x ← layer axis, y ← slot axis for rt in best_seq: - bx, by = shelf[rt] + b_slot, b_layer = shelf[rt] for t in clusters[rt]: - pos[t] = ((bx + local[rt][t]) * COL, (by + LAYER[t]) * ROW) + pos[t] = ((b_layer + LAYER[t]) * XCOL, (b_slot + local[rt][t]) * YROW) # Payload ids are display ids ("#42") — nodes, edges, and epics use them # consistently, so the template needs no notion of the raw number. diff --git a/skills/issue-tracker/scripts/board-map.template.html b/skills/issue-tracker/scripts/board-map.template.html index fbcf85234c..c4b53ef908 100644 --- a/skills/issue-tracker/scripts/board-map.template.html +++ b/skills/issue-tracker/scripts/board-map.template.html @@ -3,122 +3,175 @@ -Issue Board Map +Issue Board -
- +
+ issue-board +
+ + - -
+ + + + + +
- - - - - - +
-
+ From 58d6205a03ab3a041e2e2684ba19ef6adfe08a34 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 17:38:16 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(issue-tracker):=20board-pages.yml=20?= =?UTF-8?q?=E2=80=94=20=EC=9D=B4=EC=8A=88=20=EB=B3=B4=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?GH=20Actions=EB=A1=9C=20GitHub=20Pages=EC=97=90=20=EC=83=81?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=B0=ED=8F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent-harness가 Linear 원격 보드를 다루는 방식(오케스트레이터가 매 틱 폴링 → board.json 원자 스냅샷 → 대시보드가 읽기 전용 렌더)의 GitHub 등가물: 이슈 이벤트 + cron 안전망(서브이슈/의존성 엣지 변경은 issues 웹훅을 안 쏨) + workflow_dispatch로 러너에서 board-map.sh --write를 실행해 BOARD.html을 Pages로 배포하는 복사용 워크플로 템플릿을 references/에 동봉, SKILL.md에 설치법 문서화. 비공개 저장소의 Pages는 Free/Pro에서 공개라는 경고를 템플릿 헤더에 명시; 플러그인 체크아웃은 PLUGIN_REPO/PLUGIN_REF로 핀. --- skills/issue-tracker/SKILL.md | 9 +++ .../issue-tracker/references/board-pages.yml | 79 +++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 skills/issue-tracker/references/board-pages.yml diff --git a/skills/issue-tracker/SKILL.md b/skills/issue-tracker/SKILL.md index 85777de11a..fc31ab2c3d 100644 --- a/skills/issue-tracker/SKILL.md +++ b/skills/issue-tracker/SKILL.md @@ -91,6 +91,15 @@ checkout's repo. | `board-lint.sh` | schema invariants over the live board: one status label per open issue, none on closed, notes where required, no dependency cycles. `FAIL … FIX: …` lines, exit 1 | | `board-migrate-gh.sh [--board FILE] [--apply]` | one-shot v6→v7 migration: push a legacy `board.json` into GitHub (dry-run by default) | +## Remote board (GitHub Pages) + +`board-map.sh --write` renders locally on demand. For an always-current hosted +view, copy `references/board-pages.yml` into the consumer repo's +`.github/workflows/` and set Pages → Source to "GitHub Actions": every issue +event (plus a cron safety net — sub-issue/dependency edits fire no webhook) +re-renders BOARD.html on a runner and deploys it. Read the template's header +first — on non-Enterprise plans a Pages site is public even for a private repo. + ## The dispatch loop 1. `board-list.sh` → pick an `ELIGIBLE` ticket. diff --git a/skills/issue-tracker/references/board-pages.yml b/skills/issue-tracker/references/board-pages.yml new file mode 100644 index 0000000000..82da28fb4d --- /dev/null +++ b/skills/issue-tracker/references/board-pages.yml @@ -0,0 +1,79 @@ +# board-pages.yml — publish the live issue board (BOARD.html) to GitHub Pages. +# +# The v7 board's single source of truth is the repo's GitHub Issues; +# `board-map.sh --write` renders it locally on demand. This workflow is the +# hosted-visibility analog (the same shape agent-harness uses for its Linear +# board: poll the remote tracker → persist a snapshot → serve a read-only +# view): every issue event re-renders the board on a runner and deploys it to +# the repo's Pages site, with a cron safety net because sub-issue/dependency +# edge mutations fire no `issues` webhook. +# +# Install (consumer repo): +# 1. copy this file to .github/workflows/board-pages.yml +# 2. repo Settings → Pages → Source: "GitHub Actions" +# 3. pin PLUGIN_REPO/PLUGIN_REF below to the doperpowers checkout you trust +# (a private plugin repo needs a token with read access passed to the +# second checkout step — the default github.token only reads THIS repo) +# +# PRIVACY: on Free/Pro plans a Pages site is PUBLIC even when the repo is +# private — ticket titles and notes become world-readable at the Pages URL. +# Only GitHub Enterprise Cloud can restrict Pages visibility. If that is not +# acceptable, drop the deploy job and download the artifact instead. + +name: issue-board-pages + +on: + issues: + types: [opened, edited, deleted, transferred, closed, reopened, labeled, unlabeled] + schedule: + - cron: "23 */6 * * *" # edge (sub-issue/dependency) changes emit no issues event + workflow_dispatch: + +concurrency: + group: issue-board-pages + cancel-in-progress: true + +permissions: + contents: read + issues: read + pages: write + id-token: write + +env: + PLUGIN_REPO: SSFSKIM/doperpowers + PLUGIN_REF: main + +jobs: + render-and-deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + # The board scripts anchor their render cache at a repo root, so check + # out the consumer repo even though the board itself lives on GitHub. + - uses: actions/checkout@v4 + + - name: Check out the issue-tracker toolkit + uses: actions/checkout@v4 + with: + repository: ${{ env.PLUGIN_REPO }} + ref: ${{ env.PLUGIN_REF }} + path: .board-plugin + + - name: Render BOARD.html from the live issue board + env: + GH_TOKEN: ${{ github.token }} + BOARD_REPO: ${{ github.repository }} + run: | + .board-plugin/skills/issue-tracker/scripts/board-map.sh --write + mkdir -p _site + cp doperpowers/issue-tracker/BOARD.html _site/index.html + cp doperpowers/issue-tracker/BOARD.md _site/BOARD.md + + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + - id: deploy + uses: actions/deploy-pages@v4 From 8f9de32ba3aeaacfbac4fdc2f4ef44534ac5ff06 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 6 Jul 2026 17:54:56 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat(issue-tracker):=20BOARD.html=EC=97=90?= =?UTF-8?q?=20=EC=B9=B8=EB=B0=98=20=EB=B7=B0=20=ED=86=A0=EA=B8=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 같은 페이로드를 상태 컬럼으로 피벗한 칸반 뷰를 graph ↔ kanban 헤더 토글로 동봉 — 별도 렌더 파일 없이 BOARD.html 하나로 두 뷰 제공(Pages 배포도 동일). 컬럼은 해피패스 순서(ready → in-progress → in-review) + 일시정지/터미널 상태(빈 컬럼은 코어 4개만 유지), ready 컬럼은 ELIGIBLE 카드가 상단으로 떠오르고 헤더에 eligible 카운트 표기. 카드는 그래프와 동일한 상태 팔레트 (in-progress 펄스 포함), 클릭 → 동일 디테일 패널, 상태 칩 필터·ELIGIBLE only·done 기본 숨김이 두 뷰에 똑같이 적용. 에픽 콜랩스는 그래프 전용. 검증: mock-gh 데모 13티켓 Playwright — 칸반 기본/디테일 스크린샷, graph 라운드트립 후 카드 가시성 확인, 콘솔 에러 0; 테스트 스위트 green. --- skills/issue-tracker/SKILL.md | 2 +- skills/issue-tracker/scripts/board-map.sh | 5 +- .../scripts/board-map.template.html | 92 ++++++++++++++++++- 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/skills/issue-tracker/SKILL.md b/skills/issue-tracker/SKILL.md index fc31ab2c3d..cf56d4b786 100644 --- a/skills/issue-tracker/SKILL.md +++ b/skills/issue-tracker/SKILL.md @@ -84,7 +84,7 @@ checkout's repo. | `board-edge.sh --block N \| --unblock N \| --parent N \| --orphan` | re-cut edges after birth (one op per call): add/cut a dependency, move under another epic, or leave one. Rejects self-edges, cycles, ancestor-epic blockers; runs the same epic sweeps as transition | | `board-relate.sh [--cut]` | symmetric relates annotation (board:meta) — rendered by board-map, no effect on eligibility | | `board-list.sh [state]` | board view; `ELIGIBLE` tag = dispatchable | -| `board-map.sh [--write]` | human telemetry. `--write` renders **`BOARD.html`** (interactive layered-DAG: pan/zoom, node detail, state filter, epic collapse) and **`BOARD.md`** (table) into the gitignored render dir. No argument prints the table | +| `board-map.sh [--write]` | human telemetry. `--write` renders **`BOARD.html`** (interactive layered-DAG: pan/zoom, node detail, state filter, epic collapse — plus a kanban view toggle) and **`BOARD.md`** (table) into the gitignored render dir. No argument prints the table | | `board-show.sh ` | node + issue URL + bound daemon | | `board-bind.sh ` | record which daemon owns the ticket (in the daemon registry) | | `board-reconcile.sh` | read-only catch-up: unapplied proposals, orphaned tickets, dispatchables, then a lint pass | diff --git a/skills/issue-tracker/scripts/board-map.sh b/skills/issue-tracker/scripts/board-map.sh index 88dbdd4788..3dd987251a 100755 --- a/skills/issue-tracker/scripts/board-map.sh +++ b/skills/issue-tracker/scripts/board-map.sh @@ -8,8 +8,9 @@ # doperpowers/issue-tracker/ (gitignored — render caches never # commit): BOARD.html — the primary view: an interactive # layered-DAG (pan/zoom, click a node for detail, filter by state, -# collapse epics), opened in a browser; and BOARD.md — a minimal -# node/state table. +# collapse epics) with a kanban toggle (the same tickets pivoted +# into state columns), opened in a browser; and BOARD.md — a +# minimal node/state table. # # Reading BOARD.html: dependencies flow left→right (a blocker sits left of its # dependents). Card color = state; ELIGIBLE (ready-for-agent + all blockers done, diff --git a/skills/issue-tracker/scripts/board-map.template.html b/skills/issue-tracker/scripts/board-map.template.html index c4b53ef908..142de43776 100644 --- a/skills/issue-tracker/scripts/board-map.template.html +++ b/skills/issue-tracker/scripts/board-map.template.html @@ -98,6 +98,19 @@ border-top: 1px solid #141a24; font-size: 12px; } .drow span { color: #556170; } .drow b { font-weight: 500; word-break: break-word; color: #aeb9c6; } .drow a { color: #93c5fd; text-decoration: none; } .drow a:hover { text-decoration: underline; } + /* ---- kanban view: state columns sharing the card palette ---- */ + #kb { position: absolute; inset: 0; overflow: auto; padding: .8rem; + display: flex; gap: .7rem; align-items: flex-start; } + #kb[hidden] { display: none; } + .kbcol { flex: 0 0 232px; background: #0a0e16; border: 1px solid #18202c; + border-top: 2px solid var(--bd, #232c3a); border-radius: .6rem; + padding: .45rem .45rem .1rem; } + .kbhead { display: flex; justify-content: space-between; align-items: baseline; gap: .4rem; + padding: .1rem .3rem .4rem; font-size: 11px; text-transform: uppercase; + letter-spacing: .08em; color: var(--tx, #7d8896); } + .kbcount { color: #556170; font-size: 10px; letter-spacing: 0; text-transform: none; } + #kb .node { position: static; width: 100%; min-height: 0; margin-bottom: .45rem; } + button.act.on { background: rgba(59,130,246,.16); border-color: #3b82f6; color: #93c5fd; } /* ---- footer: edge legend + meta ---- */ #foot { display: flex; gap: .9rem; align-items: center; flex-wrap: wrap; padding: .25rem .85rem; border-top: 1px solid #18202c; background: #0a0a14; font-size: 11px; color: #556170; } @@ -114,13 +127,18 @@ - - - + + + + + + +
+
@@ -170,11 +178,13 @@ // state class -> the short badge shown on the card (the full label goes to detail). var BADGE = { s_done: "done", s_prog: "running", s_rev: "review", s_elig: "ELIGIBLE", s_wait: "waiting", s_blk: "blocked", s_info: "needs-info", - s_def: "deferred", s_wf: "wontfix" }; + s_def: "deferred", s_wf: "wontfix", + s_conflict: "CONFLICT", s_untracked: "untracked" }; // state name (chip vocabulary) -> palette class, for coloring the filter chips. var STATE_CLS = { "done": "s_done", "in-progress": "s_prog", "in-review": "s_rev", "ready-for-agent": "s_elig", "blocked": "s_blk", "needs-info": "s_info", - "deferred": "s_def", "wontfix": "s_wf" }; + "deferred": "s_def", "wontfix": "s_wf", + "conflict": "s_conflict", "untracked": "s_untracked" }; function el(tag, text, cls) { var n = document.createElement(tag); @@ -221,7 +231,7 @@ // eye can trace the moving critical path (the agent-harness edge idiom). function edgeClass(e) { if (e.kind === "block-active") { - var s = nodeById[resolve(e.from)]; + var s = nodeById[e.from]; // the real blocker, not the epic it may collapse into return (s && (s.cls === "s_prog" || s.cls === "s_rev")) ? "block-live" : "block-wait"; } return e.kind; // block-done / spawned / relates map straight to CSS @@ -289,6 +299,9 @@ svg.setAttribute("width", maxx + PAD); svg.setAttribute("height", maxy + PAD); svg.style.width = (maxx + PAD) + "px"; svg.style.height = (maxy + PAD) + "px"; BOARD.edges.forEach(function (e) { + // A state/eligible-filtered endpoint hides its edge — even when it would + // otherwise re-route to a visible collapsed epic (don't resurrect it). + if (hiddenBy(nodeById[e.from]) || hiddenBy(nodeById[e.to])) return; var a = resolve(e.from), b = resolve(e.to); if (a === b || !vis[a] || !vis[b]) return; var key = a + ">" + b + ":" + e.kind; @@ -315,7 +328,11 @@ // Chips keep working (a hidden state's column drops out — done is hidden by // default here too); epic collapse is a graph concept and is ignored. var KB_STATES = ["ready-for-agent", "in-progress", "in-review", "blocked", - "needs-info", "deferred", "done", "wontfix"]; + "needs-info", "deferred", "conflict", "untracked", "done", "wontfix"]; + // The board is GitHub-derived, so a snapshot can carry states beyond the + // machine's own (conflict/untracked, or anything a future label adds). Give + // every state present a column rather than silently dropping its tickets. + BOARD.nodes.forEach(function (n) { if (KB_STATES.indexOf(n.state) < 0) KB_STATES.push(n.state); }); var KB_CORE = { "ready-for-agent": 1, "in-progress": 1, "in-review": 1, "done": 1 }; function kbCard(n) { var card = el("div", null, "node " + n.cls); @@ -364,6 +381,7 @@ host.style.display = v === "graph" ? "" : "none"; document.getElementById("kb").hidden = v !== "kanban"; document.getElementById("gctls").style.display = v === "graph" ? "" : "none"; + document.getElementById("edgelegend").hidden = v !== "graph"; // no edges in kanban document.getElementById("vgraph").classList.toggle("on", v === "graph"); document.getElementById("vkanban").classList.toggle("on", v === "kanban"); renderAll();