Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-27 14:00:46 +05:00
commit 53ffcc2918
677 changed files with 33091 additions and 6980 deletions

View file

@ -0,0 +1,59 @@
---
name: navigation-graph
description: Refresh the app's navigation/dependency graph from live code and rebuild the interactive visualization. Scans features/domain Gradle project dependencies and every AppRoute usage, updates the generated regions of .claude/docs/navigation-graph.md (preserving hand-written prose and the curated config), then renders .claude/docs/module-connectivity.html (Area / Module / Screens views with team overlays). Use when the user asks to update/regenerate/refresh the navigation graph, screen map, module connectivity diagram or module-connectivity.html, after adding/removing AppRoute screens or feature/domain modules, or to add/recolor a team. Triggers: "update the navigation graph", "regenerate module-connectivity.html", "refresh the screen map", "rebuild the module connectivity diagram", "add a team to the graph".
allowed-tools: Bash, Read, Edit, Grep, Glob
---
Refresh and rebuild the app's connectivity visualization. The data flows **code → doc → HTML**:
```
features/domain/data build.gradle.kts deps ─┐
AppRoute.kt + every AppRoute.X usage ─┴─▶ build_graph.py ─▶ .claude/docs/navigation-graph.md
│ (data + curated config blocks)
render_html.py ─▶ .claude/docs/module-connectivity.html
```
`navigation-graph.md` is the source of truth. Its hand-written prose (sections 14: route tables, edges-with-triggers, nested routes, deep links) is **owned by humans and never overwritten**. The skill manages only the region between `<!-- NAVGRAPH:BEGIN -->` and `<!-- NAVGRAPH:END -->`, which holds:
- an **editable CONFIG json block** — functional groups (label/color/grid anchor), per-screen group + owner, and teams. Preserved across refreshes (human edits win).
- **auto data blocks**`areaGraph`, `moduleGraph`, `screensGraph`, `screensMeta`. Overwritten from code every run.
## To refresh after a code change (the common case)
Run both scripts from anywhere in the repo (they locate the root via `settings.gradle.kts`). The scan walks the whole tree — expect ~1020s.
```bash
python3 .claude/skills/navigation-graph/scripts/build_graph.py
python3 .claude/skills/navigation-graph/scripts/render_html.py
```
Then report to the user: the printed counts, **any `⚠ NEW screen` warnings**, and that `.claude/docs/module-connectivity.html` is a self-contained file they can open in a browser.
**If `build_graph.py` prints `⚠ NEW screen …` warnings:** a route was added to `AppRoute.kt` but isn't classified. The new screen was defaulted to group `misc` / owner `app`. Edit the CONFIG block in `.claude/docs/navigation-graph.md` to set its real `screenGroups[Name]` and `screenOwners[Name]`, then re-run both scripts. Pick the group/owner by reading where the route lives and is pushed from. Removed routes are reported as "no longer in code" and are harmless (kept in config for when they return).
## To change grouping, owners, colors, or teams
Edit the **CONFIG** json block inside `.claude/docs/navigation-graph.md` (between `<!-- NAVGRAPH:config:BEGIN -->` and `:END`), then run `render_html.py` (no need to re-scan code unless code changed):
- **`groups`** — add/rename a functional group or change its `color` / `label` / grid `anchor` (`x`,`y` are 01 fractions of the canvas).
- **`screenGroups` / `screenOwners`** — reassign a screen.
- **`teams`** — add a team object `{ "id", "name", "color", "roots": [...module path prefixes...], "screens": [...AppRoute names...] }`. `roots` drive the overlay in Area/Module views (e.g. `"features:onramp"`, `"domain:staking"`, `"data:swap"`); `screens` drive it in the Screens view (e.g. `"Send"`). A module/screen may be matched by either. The overlay (hull + rings, optional cluster force) and per-view member counts wire up automatically.
After editing CONFIG, re-run `render_html.py`. If you changed code-derived facts, run `build_graph.py` first.
## What gets extracted (and what's inferred)
- **Dependency edges** (Area/Module): only project (`projects.*`) deps among `features/*`, `domain/*` and `data/*` — external libraries excluded. Area view merges each area's `api`/`impl`/`models`. The Data-layer toggle in the HTML appears only when data modules are present.
- **Screen edges** (Screens): the **target** is exact — the `AppRoute.X` argument of a `push`/`replaceCurrent`/`replaceAll`/`popTo` call. The **source** is the navigating file's feature module collapsed to that feature's main screen (eponymous screen if one exists, else most-referenced), so intra-feature hops are merged. Calls from shared UI / app root attach to a synthetic **App shell** node. Group/owner come from the curated CONFIG.
## Files
```
.claude/skills/navigation-graph/
SKILL.md
assets/template.html # parameterized HTML (8 inject points: 4 data blocks + teams + group colors/labels/anchors)
assets/config.seed.json # initial curation; used only when the doc has no CONFIG block yet
scripts/build_graph.py # code -> navigation-graph.md (managed region)
scripts/render_html.py # navigation-graph.md -> module-connectivity.html
```
Outputs live in `.claude/docs/`. The scripts are idempotent — re-running never duplicates the managed region. Do not hand-edit the auto data blocks (they're regenerated); edit CONFIG instead. After regenerating, sanity-check the HTML by extracting its `<script>` and running `node --check` if Node is available.

View file

@ -0,0 +1,280 @@
{
"hubs": [
"domain:models",
"domain:core",
"domain:app-currency",
"domain:settings",
"domain:balance-hiding",
"domain:feedback",
"domain:demo"
],
"groups": {
"shell": {
"label": "app shell",
"color": "#5A6677",
"anchor": {
"x": 0.5,
"y": 0.07
}
},
"entry": {
"label": "entry",
"color": "#7DA2FF",
"anchor": {
"x": 0.18,
"y": 0.24
}
},
"onboarding": {
"label": "onboarding",
"color": "#34D8C4",
"anchor": {
"x": 0.5,
"y": 0.24
}
},
"wallet": {
"label": "wallet",
"color": "#E8A33D",
"anchor": {
"x": 0.82,
"y": 0.24
}
},
"portfolio": {
"label": "portfolio",
"color": "#B69CFF",
"anchor": {
"x": 0.18,
"y": 0.52
}
},
"tokenaction": {
"label": "token actions",
"color": "#FF8F6B",
"anchor": {
"x": 0.5,
"y": 0.52
}
},
"markets": {
"label": "markets / news",
"color": "#5BD08A",
"anchor": {
"x": 0.82,
"y": 0.52
}
},
"settings": {
"label": "settings",
"color": "#9AA7B8",
"anchor": {
"x": 0.18,
"y": 0.8
}
},
"tangempay": {
"label": "tangem pay",
"color": "#F2C14E",
"anchor": {
"x": 0.5,
"y": 0.8
}
},
"misc": {
"label": "misc",
"color": "#C7CFDA",
"anchor": {
"x": 0.82,
"y": 0.8
}
}
},
"groupOrder": [
"shell",
"entry",
"onboarding",
"wallet",
"portfolio",
"tokenaction",
"markets",
"settings",
"tangempay",
"misc"
],
"defaultGroup": "misc",
"defaultOwner": "app",
"screenGroups": {
"Initial": "entry",
"Home": "entry",
"Welcome": "entry",
"Disclaimer": "entry",
"CreateWalletSelection": "onboarding",
"CreateWalletStart": "onboarding",
"CreateHardwareWallet": "onboarding",
"CreateMobileWallet": "onboarding",
"AddExistingWallet": "onboarding",
"Onboarding": "onboarding",
"CreateWalletBackup": "onboarding",
"WalletBackup": "onboarding",
"WalletHardwareBackup": "onboarding",
"UpgradeWallet": "onboarding",
"WalletActivation": "onboarding",
"AccessCodeRecovery": "onboarding",
"UpdateAccessCode": "onboarding",
"ViewPhrase": "onboarding",
"Wallet": "wallet",
"Stories": "wallet",
"NFT": "wallet",
"NFTSend": "wallet",
"PushNotification": "wallet",
"PushNotificationSettings": "wallet",
"CurrencyDetails": "portfolio",
"ManageTokens": "portfolio",
"ChooseManagedTokens": "portfolio",
"CreateAccount": "portfolio",
"EditAccount": "portfolio",
"AccountDetails": "portfolio",
"ArchivedAccountList": "portfolio",
"Send": "tokenaction",
"SendEntryPoint": "tokenaction",
"Swap": "tokenaction",
"SwapCrypto": "tokenaction",
"Onramp": "tokenaction",
"OnrampSuccess": "tokenaction",
"BuyCrypto": "tokenaction",
"SellCrypto": "tokenaction",
"Staking": "tokenaction",
"YieldSupplyEntry": "tokenaction",
"Earn": "tokenaction",
"Markets": "markets",
"MarketsTokenDetails": "markets",
"News": "markets",
"NewsDetails": "markets",
"Details": "settings",
"DetailsSecurity": "settings",
"CardSettings": "settings",
"AppSettings": "settings",
"ResetToFactory": "settings",
"WalletSettings": "settings",
"ForgetWallet": "settings",
"AppCurrencySelector": "settings",
"ReferralProgram": "settings",
"AddressBook": "settings",
"WalletConnectSessions": "settings",
"TangemPayDetails": "tangempay",
"TangemPayHotWalletOnboarding": "tangempay",
"TangemPayOnboarding": "tangempay",
"Kyc": "tangempay",
"QrScanning": "misc",
"Usedesk": "misc",
"Survey": "misc"
},
"screenOwners": {
"Initial": "app",
"Home": "features:home",
"Welcome": "features:welcome",
"Disclaimer": "features:disclaimer",
"Wallet": "features:wallet",
"CurrencyDetails": "features:tokendetails",
"Send": "features:send",
"Details": "features:details",
"DetailsSecurity": "features:details",
"Usedesk": "features:usedesk",
"CardSettings": "features:details",
"AppSettings": "features:details",
"ResetToFactory": "features:details",
"AccessCodeRecovery": "features:onboarding-v2",
"ManageTokens": "features:manage-tokens",
"ChooseManagedTokens": "features:manage-tokens",
"WalletConnectSessions": "features:walletconnect",
"AddressBook": "features:address-book",
"QrScanning": "features:qr-scanning",
"ReferralProgram": "features:referral",
"Swap": "features:swap",
"AppCurrencySelector": "features:wallet-settings",
"Staking": "features:staking",
"PushNotification": "features:push-notifications",
"WalletSettings": "features:wallet-settings",
"PushNotificationSettings": "features:push-notification-settings",
"WalletBackup": "features:onboarding-v2",
"WalletHardwareBackup": "features:onboarding-v2",
"Markets": "features:markets",
"MarketsTokenDetails": "features:markets",
"Onramp": "features:onramp",
"OnrampSuccess": "features:onramp",
"BuyCrypto": "features:onramp",
"SellCrypto": "features:onramp",
"SwapCrypto": "features:swap-v2",
"Onboarding": "features:onboarding-v2",
"Stories": "features:stories",
"NFT": "features:nft",
"NFTSend": "features:nft",
"CreateWalletSelection": "features:create-wallet-selection",
"CreateWalletStart": "features:create-wallet-start",
"CreateHardwareWallet": "features:onboarding-v2",
"CreateMobileWallet": "features:hot-wallet",
"UpgradeWallet": "features:hot-wallet",
"AddExistingWallet": "features:onboarding-v2",
"WalletActivation": "features:tangempay",
"CreateWalletBackup": "features:onboarding-v2",
"UpdateAccessCode": "features:onboarding-v2",
"ViewPhrase": "features:onboarding-v2",
"ForgetWallet": "features:wallet-settings",
"SendEntryPoint": "features:send",
"CreateAccount": "features:account",
"EditAccount": "features:account",
"AccountDetails": "features:account",
"ArchivedAccountList": "features:account",
"TangemPayDetails": "features:tangempay",
"TangemPayHotWalletOnboarding": "features:tangempay",
"TangemPayOnboarding": "features:tangempay",
"Kyc": "features:kyc",
"Survey": "features:survey",
"YieldSupplyEntry": "features:yield-supply",
"NewsDetails": "features:feed",
"News": "features:feed",
"Earn": "features:feed"
},
"teams": [
{
"id": "grow",
"name": "Grow",
"color": "#FF6FD8",
"roots": [
"features:approval",
"features:onramp",
"features:send",
"features:staking",
"features:swap",
"features:swap-v2",
"features:yield-supply",
"domain:express",
"domain:offramp",
"domain:onramp",
"domain:staking",
"domain:swap",
"domain:yield-supply",
"domain:transaction",
"data:express",
"data:onramp",
"data:staking",
"data:swap",
"data:yield-supply"
],
"screens": [
"Send",
"Swap",
"Staking",
"Onramp",
"OnrampSuccess",
"BuyCrypto",
"SellCrypto",
"SwapCrypto",
"NFTSend",
"SendEntryPoint",
"YieldSupplyEntry"
]
}
]
}

View file

@ -0,0 +1,718 @@
<title>Tangem — features ↔ domain module connectivity</title>
<style>
:root{
--ground:#0E1116;--panel:#161B22;--panel-2:#1B222B;--line:#222B36;
--text:#C9D4E0;--muted:#74859A;--faint:#3A4757;
--features:#E8A33D;--domain:#34D8C4;--data:#B69CFF;--inverted:#FF5C6C;
--mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
--ui:"Helvetica Neue",Inter,system-ui,-apple-system,sans-serif;
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;background:var(--ground);color:var(--text);font-family:var(--ui);
overflow:hidden;-webkit-font-smoothing:antialiased}
.app{display:flex;flex-direction:column;height:100vh;width:100vw}
header{flex:0 0 auto;border-bottom:1px solid var(--line);padding:14px 22px 12px;
display:flex;align-items:flex-end;justify-content:space-between;gap:24px;flex-wrap:wrap;
background:linear-gradient(180deg,#11161D,#0E1116)}
.titleblock{min-width:0}
.eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.34em;text-transform:uppercase;
color:var(--muted);margin:0 0 6px}
.eyebrow b{color:var(--domain);font-weight:600}
h1{margin:0;font-size:clamp(20px,3.2vw,30px);font-weight:800;letter-spacing:-.022em;line-height:1}
h1 .arrow{color:var(--muted);font-weight:400;padding:0 .15em}
.features-ink{color:var(--features)}.domain-ink{color:var(--domain)}
.stats{display:flex;gap:0;font-family:var(--mono)}
.stat{padding:0 18px;border-left:1px solid var(--line);text-align:right}
.stat:first-child{border-left:0}
.stat .num{font-size:22px;font-weight:700;line-height:1.05}
.stat .lbl{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted);margin-top:3px}
.stat.warn .num{color:var(--inverted)}
.body{flex:1 1 auto;display:flex;min-height:0}
.rail{flex:0 0 270px;border-right:1px solid var(--line);background:var(--panel);
overflow-y:auto;padding:16px 16px 28px}
.stage{flex:1 1 auto;position:relative;min-width:0;
background:radial-gradient(120% 120% at 30% 0%,#12171F 0%,#0E1116 60%)}
svg{width:100%;height:100%;display:block;cursor:grab;touch-action:none}
svg.grabbing{cursor:grabbing}
/* view switch */
.viewswitch{display:flex;background:var(--ground);border:1px solid var(--line);
border-radius:9px;padding:3px;gap:3px;margin-bottom:20px}
.viewswitch button{flex:1;border:0;background:transparent;color:var(--muted);
font-family:var(--mono);font-size:12px;font-weight:600;padding:8px 6px;border-radius:6px;
cursor:pointer;letter-spacing:.04em;transition:.13s;display:flex;flex-direction:column;gap:2px;align-items:center}
.viewswitch button .sub{font-size:9.5px;font-weight:500;letter-spacing:.02em;opacity:.7}
.viewswitch button:hover{color:var(--text)}
.viewswitch button.on{background:var(--panel-2);color:var(--text);box-shadow:inset 0 0 0 1px var(--line)}
.grp{margin-bottom:20px}
.grp h2{font-family:var(--mono);font-size:10px;letter-spacing:.2em;text-transform:uppercase;
color:var(--muted);margin:0 0 9px;font-weight:600}
.search{width:100%;background:var(--ground);border:1px solid var(--line);color:var(--text);
font-family:var(--mono);font-size:13px;padding:9px 10px;border-radius:7px;outline:none}
.search:focus{border-color:var(--domain);box-shadow:0 0 0 2px rgba(52,216,196,.16)}
.search::placeholder{color:var(--faint)}
.toggles{display:flex;flex-direction:column;gap:2px}
.tog{display:flex;align-items:center;gap:9px;cursor:pointer;padding:7px 8px;border-radius:7px;
font-size:13px;user-select:none}
.tog:hover{background:var(--panel-2)}
.tog .box{width:15px;height:15px;border-radius:4px;border:1.5px solid var(--faint);flex:0 0 auto;position:relative;transition:.12s}
.tog.on .box{background:var(--domain);border-color:var(--domain)}
.tog.on .box::after{content:"";position:absolute;left:4px;top:1px;width:4px;height:8px;
border:solid var(--ground);border-width:0 2px 2px 0;transform:rotate(45deg)}
.tog .dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto}
.tog .dot.f{background:var(--features)}.tog .dot.d{background:var(--domain)}.tog .dot.da{background:var(--data)}
.tog .meta{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--muted)}
.btn{width:100%;text-align:left;background:var(--ground);border:1px solid var(--line);color:var(--text);
font-family:var(--mono);font-size:12px;padding:9px 11px;border-radius:7px;cursor:pointer;
display:flex;align-items:center;gap:8px;transition:.12s}
.btn:hover{border-color:var(--inverted);color:#fff}
.btn .swatch{width:18px;height:0;border-top:2px solid var(--inverted);flex:0 0 auto}
.btn.active{border-color:var(--inverted);background:rgba(255,92,108,.08)}
.hubtext{font-size:11px;color:var(--muted);margin:8px 2px 0;line-height:1.5}
.hubtext span{font-family:var(--mono);color:var(--text)}
.insp{border-top:1px solid var(--line);padding-top:16px}
.insp .hint{color:var(--muted);font-size:12.5px;line-height:1.55}
.insp-id{font-family:var(--mono);font-size:14.5px;font-weight:700;word-break:break-all;line-height:1.25}
.chip{display:inline-block;font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-transform:uppercase;
padding:3px 8px;border-radius:20px;margin-top:8px;font-weight:600}
.chip.f{background:rgba(232,163,61,.15);color:var(--features)}
.chip.d{background:rgba(52,216,196,.15);color:var(--domain)}
.chip.da{background:rgba(182,156,255,.15);color:var(--data)}
.degline{display:flex;gap:14px;font-family:var(--mono);font-size:12px;margin:12px 0 4px}
.degline b{color:var(--text);font-weight:700}.degline .k{color:var(--muted)}
.deplist h3{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;
color:var(--muted);margin:14px 0 7px;font-weight:600;display:flex;justify-content:space-between}
.deplist ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:1px}
.deplist li{font-family:var(--mono);font-size:12px;padding:4px 7px;border-radius:5px;cursor:pointer;
display:flex;justify-content:space-between;gap:8px;color:var(--text)}
.deplist li:hover{background:var(--panel-2)}
.deplist li .w{color:var(--muted);font-size:11px;white-space:nowrap}
.deplist li .dot{width:7px;height:7px;border-radius:50%;margin-right:7px;flex:0 0 auto;align-self:center}
.tog.act{background:var(--panel-2);box-shadow:inset 0 0 0 1px var(--line)}
.deplist li span.nm{display:flex;align-items:center;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.deplist li.f span.nm::before,.deplist li.d span.nm::before,.deplist li.da span.nm::before{content:"";display:inline-block;width:7px;height:7px;
border-radius:50%;margin-right:7px;flex:0 0 auto}
.deplist li.f span.nm::before{background:var(--features)}
.deplist li.d span.nm::before{background:var(--domain)}
.deplist li.da span.nm::before{background:var(--data)}
.edge{stroke:var(--faint);stroke-opacity:.10;fill:none;transition:stroke-opacity .15s}
.edge.inv{stroke:var(--inverted);stroke-opacity:.22}
.node-label{font-family:var(--mono);font-size:9.5px;fill:var(--text);pointer-events:none;opacity:0;
transition:opacity .15s;paint-order:stroke;stroke:var(--ground);stroke-width:2.4px;stroke-linejoin:round}
.node-label.show{opacity:.92}
.node-dot{cursor:pointer}
.node-dot circle{transition:fill-opacity .15s,stroke .12s}
.dim{opacity:.12 !important}
.halo{fill:none;stroke-width:2.6;opacity:0;transition:opacity .15s;pointer-events:none}
.hull{stroke-width:1.6;fill-opacity:.08;stroke-opacity:.6;pointer-events:none}
.hull-label{font-family:var(--mono);font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;
pointer-events:none;paint-order:stroke;stroke:var(--ground);stroke-width:3.2px;stroke-linejoin:round;opacity:.92}
.legend{position:absolute;left:16px;bottom:14px;display:flex;gap:18px;align-items:center;
font-family:var(--mono);font-size:11px;color:var(--muted);background:rgba(14,17,22,.72);
backdrop-filter:blur(4px);border:1px solid var(--line);border-radius:9px;padding:8px 13px;
flex-wrap:wrap;max-width:calc(100% - 32px)}
.legend .it{display:flex;align-items:center;gap:7px}
.legend .sw{width:11px;height:11px;border-radius:50%}
.legend .sw.f{background:var(--features)}.legend .sw.d{background:var(--domain)}.legend .sw.da{background:var(--data)}
.legend .ln{width:18px;border-top:2px solid var(--inverted)}
.legend .sz{display:flex;align-items:center;gap:4px}
.legend .sz i{display:inline-block;border-radius:50%;background:var(--muted)}
.hud{position:absolute;right:16px;top:14px;font-family:var(--mono);font-size:11px;color:var(--muted);
text-align:right;line-height:1.6;pointer-events:none}
.hud b{color:var(--text);font-weight:600}
.reset{position:absolute;right:16px;bottom:14px;font-family:var(--mono);font-size:11px;
background:rgba(14,17,22,.72);backdrop-filter:blur(4px);border:1px solid var(--line);
color:var(--muted);border-radius:8px;padding:7px 11px;cursor:pointer}
.reset:hover{color:var(--text);border-color:var(--faint)}
@media (max-width:880px){
body{overflow:auto}.app{height:auto}.body{flex-direction:column}
.rail{flex-basis:auto;border-right:0;border-bottom:1px solid var(--line)}
.stage{height:72vh}.stats{flex-wrap:wrap}
}
@media (prefers-reduced-motion:reduce){.edge,.node-label,.node-dot circle{transition:none}}
</style>
<div class="app">
<header>
<div class="titleblock">
<p class="eyebrow"><b>Tangem</b> · android · <span id="eyebrowKind">gradle dependency graph</span></p>
<h1><span class="features-ink">features</span><span class="arrow"></span><span class="domain-ink">domain</span> connectivity</h1>
</div>
<div class="stats">
<div class="stat"><div class="num" id="statNodes">88</div><div class="lbl" id="statNodesLbl">areas</div></div>
<div class="stat"><div class="num" id="statEdges">716</div><div class="lbl" id="statEdgesLbl">dep links</div></div>
<div class="stat warn"><div class="num" id="invCount">6</div><div class="lbl">inverted</div></div>
</div>
</header>
<div class="body">
<aside class="rail">
<div class="viewswitch" id="viewswitch">
<button data-view="area" class="on">Area<span class="sub">api+impl merged</span></button>
<button data-view="module">Module<span class="sub">every module</span></button>
<button data-view="screens">Screens<span class="sub">AppRoute map</span></button>
</div>
<div class="grp">
<h2>Find a module</h2>
<input class="search" id="search" placeholder="e.g. staking, wallet, tokens" autocomplete="off" spellcheck="false">
</div>
<div class="grp" id="grpLayers">
<h2>Layers</h2>
<div class="toggles">
<label class="tog on" data-layer="features"><span class="box"></span><span class="dot f"></span>features<span class="meta" id="cntF"></span></label>
<label class="tog on" data-layer="domain"><span class="box"></span><span class="dot d"></span>domain<span class="meta" id="cntD"></span></label>
<label class="tog on" data-layer="data" id="togDataRow" style="display:none"><span class="box"></span><span class="dot da"></span>data<span class="meta" id="cntData"></span></label>
</div>
</div>
<div class="grp" id="grpTeams">
<h2>Teams</h2>
<div class="toggles" id="teamList"></div>
<label class="tog" id="togCluster"><span class="box"></span>Cluster enabled teams</label>
<p class="hubtext">Outlines &amp; rings mark a team's owned modules (in Area / Module) or screens (in Screens). <span>More teams can be added in the config.</span></p>
</div>
<div class="grp" id="grpDeclutter">
<h2>Declutter</h2>
<div class="toggles">
<label class="tog" id="togHubs"><span class="box"></span>Hide utility hubs<span class="meta" id="hubCount"></span></label>
<label class="tog" id="togApi"><span class="box"></span>Only <code>api</code> links</label>
</div>
<p class="hubtext">Hubs hidden: <span id="hubList"></span> — depended on near-universally.</p>
</div>
<div class="grp" id="grpLayering">
<h2>Layering check</h2>
<button class="btn" id="btnInv"><span class="swatch"></span>Show inverted deps</button>
<p class="hubtext">A lower-layer <span style="color:var(--inverted)">domain / data</span> module importing a <span style="color:var(--features)">feature</span> <code>api</code> — reaching up across the layer boundary.</p>
</div>
<div class="grp" id="grpGroups" style="display:none">
<h2>Functional groups</h2>
<div class="toggles" id="groupList"></div>
<p class="hubtext">Click a group to isolate its screens. Node size = navigation links. Arrow = navigates to.</p>
</div>
<div class="insp" id="insp">
<p class="hint">Hover a node to trace its links. <b style="color:var(--text)">Click</b> to pin it and list its dependencies. Drag nodes to rearrange · scroll to zoom · drag canvas to pan.</p>
</div>
</aside>
<div class="stage">
<svg id="svg" viewBox="0 0 1200 820" preserveAspectRatio="xMidYMid meet" aria-label="Module dependency graph"></svg>
<div class="hud" id="hud">consumers <span style="color:var(--features)">left</span> · foundations <span style="color:var(--domain)">right</span><br>arrow points to the dependency<br><b>scroll</b> zoom · <b>drag</b> pan</div>
<button class="reset" id="resetView">reset view</button>
<div class="legend" id="legendBox">
<div class="it"><span class="sw f"></span>features</div>
<div class="it"><span class="sw d"></span>domain</div>
<div class="it" id="legendData" style="display:none"><span class="sw da"></span>data</div>
<div class="it"><span class="ln"></span>inverted dep</div>
<div class="it sz">size&nbsp;<i style="width:6px;height:6px"></i><i style="width:11px;height:11px"></i><i style="width:16px;height:16px"></i>&nbsp;= total degree</div>
</div>
</div>
</div>
</div>
<script>
const RAW = { area: /*__AREA_DATA__*/null, module: /*__MODULE_DATA__*/null, screens: /*__SCREENS_DATA__*/null };
const SCREENMETA = /*__SCREENS_META__*/null;
const BANDS2={features:0.28,domain:0.72};
const VIEWCFG = {
area: {W:1200,H:820, labelFloor:13,rScale:1.85,rep:14000,ky:0.006, prewarm:340,kind:'areas', bandX:BANDS2},
module: {W:1640,H:1120,labelFloor:20,rScale:1.45,rep:23000,ky:0.0035,prewarm:480,kind:'modules',bandX:BANDS2},
screens:{W:1500,H:1040,labelFloor:0, rScale:1.7, rep:20000,ky:0.02, prewarm:460,kind:'screens',
anchors:/*__GROUP_ANCHORS__*/{}}
};
const GROUP_COLORS=/*__GROUP_COLORS__*/{};
const GROUP_LABELS=/*__GROUP_LABELS__*/{};
const SVGNS="http://www.w3.org/2000/svg";
const css=getComputedStyle(document.documentElement);
const FEAT=css.getPropertyValue('--features').trim();
const DOM=css.getPropertyValue('--domain').trim();
const DATAC=css.getPropertyValue('--data').trim();
const INV=css.getPropertyValue('--inverted').trim();
function colorFor(l){if(GROUP_COLORS[l])return GROUP_COLORS[l];return l==='features'?FEAT:l==='data'?DATAC:DOM;}
function layerCls(l){return l==='features'?'f':l==='data'?'da':'d';}
function hexA(h,a){const n=parseInt(h.slice(1),16);return `rgba(${(n>>16)&255},${(n>>8)&255},${n&255},${a})`;}
// ---- team ownership (extensible: add one entry per team) ----
const TEAMS=/*__TEAMS__*/[];
function normId(id){return id.charAt(0)===':'?id.slice(1):id;}
function teamsOf(id){const p=normId(id);return TEAMS.filter(t=>
(t.roots&&t.roots.some(r=>p===r||p.startsWith(r+':'))) || (t.screens&&t.screens.includes(id)));}
const enabledTeams=new Set();
let clusterOn=false;
const reduced=matchMedia('(prefers-reduced-motion:reduce)').matches;
// ---- persistent SVG scaffold ----
const svg=document.getElementById('svg');
const defs=document.createElementNS(SVGNS,'defs');
defs.innerHTML='<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse"><path d="M40 0H0V40" fill="none" stroke="#19212B" stroke-width="1"/></pattern>';
svg.appendChild(defs);
const gRoot=document.createElementNS(SVGNS,'g'); svg.appendChild(gRoot);
const bg=document.createElementNS(SVGNS,'rect');
bg.setAttribute('x',-2000);bg.setAttribute('y',-2000);bg.setAttribute('fill','url(#grid)');bg.setAttribute('opacity','0.5');
const gHulls=document.createElementNS(SVGNS,'g');
const gEdges=document.createElementNS(SVGNS,'g');
const gNodes=document.createElementNS(SVGNS,'g');
gRoot.appendChild(bg);gRoot.appendChild(gHulls);gRoot.appendChild(gEdges);gRoot.appendChild(gNodes);
// ---- mutable graph state ----
let nodes,byId,edges,outAdj,inAdj,HUBS,W,H,labelFloor;
let CURRENT='area',selected=null,alpha=1,raf=null,REP,KY;
// ---- model ----
function buildModel(name){
const d=RAW[name],cfg=VIEWCFG[name];
W=cfg.W;H=cfg.H;labelFloor=cfg.labelFloor;REP=cfg.rep;KY=cfg.ky;
nodes=d.n.map(([id,label,layer,inD,outD])=>({id,label,layer,inD,outD,deg:inD+outD,
r:3.8+Math.sqrt(inD+outD+1)*cfg.rScale, color:colorFor(layer), teams:teamsOf(id)}));
byId=new Map(nodes.map(n=>[n.id,n]));
edges=d.e.map(([s,t,w,api])=>{const sn=byId.get(s),tn=byId.get(t);
return {s:sn,t:tn,w,api:!!api,inv:tn.layer==='features'&&sn.layer!=='features'};});
outAdj=new Map(nodes.map(n=>[n.id,[]]));
inAdj=new Map(nodes.map(n=>[n.id,[]]));
edges.forEach(e=>{outAdj.get(e.s.id).push(e);inAdj.get(e.t.id).push(e);});
HUBS=new Set(nodes.filter(n=>n.inD>=12&&n.outD<=3).map(n=>n.id));
}
// ---- layout ----
let seed=1337;
function rnd(){seed=(seed*1664525+1013904223)&0x7fffffff;return seed/0x7fffffff;}
function initLayout(){
seed=1337;
const cfg=VIEWCFG[CURRENT];
for(const n of nodes){
if(cfg.anchors&&cfg.anchors[n.layer]){
n.tx=W*cfg.anchors[n.layer].x;n.ty=H*cfg.anchors[n.layer].y;
n.x=n.tx+(rnd()-0.5)*220;n.y=n.ty+(rnd()-0.5)*220;
}else{
const bx=cfg.bandX?cfg.bandX[n.layer]:undefined;n.tx=W*(bx===undefined?0.5:bx);n.ty=H*0.5;
n.x=n.tx+(rnd()-0.5)*240;n.y=H*0.5+(rnd()-0.5)*H*0.86;
}
n.vx=0;n.vy=0;n.fixed=false;
}
let a=1;
for(let i=0;i<VIEWCFG[CURRENT].prewarm;i++){tick(a);a*=0.992;}
}
function tick(a){
const n=nodes.length;
for(let i=0;i<n;i++){nodes[i].fx=0;nodes[i].fy=0;}
for(let i=0;i<n;i++){const A=nodes[i];
for(let j=i+1;j<n;j++){const B=nodes[j];
let dx=A.x-B.x,dy=A.y-B.y,d2=dx*dx+dy*dy;if(d2<0.01)d2=0.01;
const d=Math.sqrt(d2);let f=REP/d2;if(f>42)f=42;
const ux=dx/d,uy=dy/d;A.fx+=ux*f;A.fy+=uy*f;B.fx-=ux*f;B.fy-=uy*f;}}
for(const e of edges){const A=e.s,B=e.t;
let dx=B.x-A.x,dy=B.y-A.y,d=Math.sqrt(dx*dx+dy*dy)||0.01;
const f=(d-96)*0.018,ux=dx/d,uy=dy/d;
A.fx+=ux*f;A.fy+=uy*f;B.fx-=ux*f;B.fy-=uy*f;}
if(clusterOn&&enabledTeams.size)computeTeamCentroids();
for(const A of nodes){
const ct=A._cteam;
if(clusterOn&&ct&&ct.cx!==undefined){
A.fx+=(ct.cx-A.x)*0.04;A.fy+=(ct.cy-A.y)*0.04;
A.fx+=(A.tx-A.x)*0.005;A.fy+=(A.ty-A.y)*KY;
}else{A.fx+=(A.tx-A.x)*0.018;A.fy+=(A.ty-A.y)*KY;}
}
for(const A of nodes){if(A.fixed)continue;
A.vx=(A.vx+A.fx)*0.84;A.vy=(A.vy+A.fy)*0.84;A.x+=A.vx*a;A.y+=A.vy*a;
A.x=Math.max(30,Math.min(W-30,A.x));A.y=Math.max(30,Math.min(H-30,A.y));}
}
// ---- DOM build ----
function buildDOM(){
svg.setAttribute('viewBox',`0 0 ${W} ${H}`);
bg.setAttribute('width',W+4000);bg.setAttribute('height',H+4000);
gEdges.textContent='';gNodes.textContent='';
edges.forEach(e=>{const ln=document.createElementNS(SVGNS,'line');
ln.setAttribute('class',e.inv?'edge inv':'edge');e.el=ln;gEdges.appendChild(ln);});
nodes.forEach(n=>{
const g=document.createElementNS(SVGNS,'g');g.setAttribute('class','node-dot');
const c=document.createElementNS(SVGNS,'circle');
c.setAttribute('r',n.r);c.setAttribute('fill',n.color);c.setAttribute('fill-opacity','0.92');
c.setAttribute('stroke','#0E1116');c.setAttribute('stroke-width','1.5');
const halo=document.createElementNS(SVGNS,'circle');halo.setAttribute('class','halo');halo.setAttribute('r',n.r+5);
const t=document.createElementNS(SVGNS,'text');t.setAttribute('class','node-label');
t.setAttribute('x',n.r+4);t.setAttribute('y',3.2);t.textContent=n.label;
g.appendChild(halo);g.appendChild(c);g.appendChild(t);n.gEl=g;n.cEl=c;n.tEl=t;n.haloEl=halo;gNodes.appendChild(g);
g.addEventListener('pointerenter',()=>{if(!selected)focusNode(n.id);});
g.addEventListener('pointerleave',()=>{if(!selected)clearFocus();});
g.addEventListener('click',ev=>{ev.stopPropagation();selectNode(n.id);});
g.addEventListener('pointerdown',startDrag);
});
}
function render(){
for(const e of edges){const A=e.s,B=e.t;
let dx=B.x-A.x,dy=B.y-A.y,d=Math.sqrt(dx*dx+dy*dy)||1;const ux=dx/d,uy=dy/d;
e.el.setAttribute('x1',(A.x+ux*A.r).toFixed(1));e.el.setAttribute('y1',(A.y+uy*A.r).toFixed(1));
e.el.setAttribute('x2',(B.x-ux*(B.r+2.5)).toFixed(1));e.el.setAttribute('y2',(B.y-uy*(B.r+2.5)).toFixed(1));}
for(const n of nodes)n.gEl.setAttribute('transform',`translate(${n.x.toFixed(1)},${n.y.toFixed(1)})`);
renderTeamOverlay();
}
function applyBaseLabels(){
for(const n of nodes){
if(n.deg>=labelFloor&&!n.hidden)n.tEl.classList.add('show');
else n.tEl.classList.remove('show');}
}
// ---- animation ----
function animate(){tick(alpha);alpha*=0.99;render();
if(alpha>0.015||dragging)raf=requestAnimationFrame(animate);else raf=null;}
function reheat(v){alpha=Math.max(alpha,v);if(!raf&&!reduced)raf=requestAnimationFrame(animate);}
// ---- highlight ----
function setEdge(e,on){
if(on){e.el.style.stroke=e.inv?INV:e.s.color;e.el.style.strokeOpacity='0.85';
e.el.style.strokeWidth=Math.min(0.7+e.w*0.55,4.2);}
else{e.el.style.stroke='';e.el.style.strokeOpacity='';e.el.style.strokeWidth='';}
}
function dimAll(bright){
for(const n of nodes){if(n.hidden)continue;
if(bright.has(n.id)){n.gEl.classList.remove('dim');n.tEl.classList.add('show');}
else{n.gEl.classList.add('dim');if(n.deg<labelFloor)n.tEl.classList.remove('show');}}
}
function focusNode(id){
const bright=new Set([id]),incident=[];
for(const e of outAdj.get(id)){if(e.s.hidden||e.t.hidden)continue;bright.add(e.t.id);incident.push(e);}
for(const e of inAdj.get(id)){if(e.s.hidden||e.t.hidden)continue;bright.add(e.s.id);incident.push(e);}
edges.forEach(e=>{setEdge(e,false);if(!e.s.hidden&&!e.t.hidden)e.el.style.strokeOpacity='0.03';});
incident.forEach(e=>setEdge(e,true));
dimAll(bright);
const n=byId.get(id);n.cEl.setAttribute('stroke','#fff');n.cEl.setAttribute('stroke-width','2');
}
function clearFocus(){
edges.forEach(e=>{setEdge(e,false);e.el.style.strokeOpacity='';});
for(const n of nodes){if(n.hidden)continue;n.gEl.classList.remove('dim');
n.cEl.setAttribute('stroke','#0E1116');n.cEl.setAttribute('stroke-width','1.5');}
applyBaseLabels();
}
function selectNode(id){
if(btnInv.classList.contains('active'))toggleInverted(false);
selected=id;focusNode(id);renderInspector(byId.get(id));
}
// ---- inspector ----
const insp=document.getElementById('insp');
function renderInspectorHint(){
insp.innerHTML='<p class="hint">Hover a node to trace its links. <b style="color:var(--text)">Click</b> to pin it and list its dependencies. Drag nodes to rearrange · scroll to zoom · drag canvas to pan.</p>';
}
function depItem(node,e){
const li=document.createElement('li');
const nm=document.createElement('span');nm.className='nm';
const dot=document.createElement('span');dot.className='dot';dot.style.background=node.color;
nm.appendChild(dot);nm.appendChild(document.createTextNode(node.id));
const w=document.createElement('span');w.className='w';w.textContent=(e.w>1?e.w+'× ':'')+(e.api?'api':'');
li.appendChild(nm);li.appendChild(w);
li.addEventListener('click',ev=>{ev.stopPropagation();selectNode(node.id);});
return li;
}
function renderInspector(n){
const navView=CURRENT==='screens';
const outs=outAdj.get(n.id).slice().sort((a,b)=>b.w-a.w);
const ins=inAdj.get(n.id).slice().sort((a,b)=>b.w-a.w);
insp.innerHTML='';
const id=document.createElement('div');id.className='insp-id';id.textContent=n.id;insp.appendChild(id);
const chip=document.createElement('span');chip.className='chip';chip.style.color=n.color;chip.style.background=hexA(n.color,0.16);
chip.textContent=navView?(GROUP_LABELS[n.layer]||n.layer):n.layer;insp.appendChild(chip);
if(navView){
const m=SCREENMETA[n.id];
const info=document.createElement('div');info.className='hubtext';info.style.margin='10px 0 2px';
if(m){info.innerHTML=`<div style="font-family:var(--mono);color:var(--text);word-break:break-all">${m.path||'—'}</div>`+
`<div style="margin-top:6px">owner <span style="font-family:var(--mono);color:var(--text)">${m.owner}</span> · ${m.total} refs in ${m.refs.length} module(s)</div>`;}
else info.textContent='Synthetic origin for navigation from the app root / shared UI (no AppRoute of its own).';
insp.appendChild(info);
}
const deg=document.createElement('div');deg.className='degline';
deg.innerHTML=`<span><span class="k">${navView?'navigates to':'depends on'}</span> <b>${n.outD}</b></span><span><span class="k">${navView?'reached from':'used by'}</span> <b>${n.inD}</b></span>`;
insp.appendChild(deg);
const mk=(title,arr,pick)=>{
const wrap=document.createElement('div');wrap.className='deplist';
const h=document.createElement('h3');h.innerHTML=`${title}<span>${arr.length}</span>`;wrap.appendChild(h);
if(!arr.length){const p=document.createElement('p');p.style.cssText='color:var(--faint);font-family:var(--mono);font-size:11px;margin:0 0 0 2px';p.textContent='— none —';wrap.appendChild(p);}
else{const ul=document.createElement('ul');arr.forEach(e=>ul.appendChild(depItem(pick(e),e)));wrap.appendChild(ul);}
insp.appendChild(wrap);
};
mk(navView?'Navigates to →':'Depends on →',outs,e=>e.t);
mk(navView?'← Reached from':'← Used by',ins,e=>e.s);
if(navView&&SCREENMETA[n.id]){
const wrap=document.createElement('div');wrap.className='deplist';
const h=document.createElement('h3');h.innerHTML=`Referenced in<span>${SCREENMETA[n.id].refs.length}</span>`;wrap.appendChild(h);
const ul=document.createElement('ul');
SCREENMETA[n.id].refs.forEach(([a,c])=>{const li=document.createElement('li');
const nm=document.createElement('span');nm.className='nm';nm.textContent=a;
const w=document.createElement('span');w.className='w';w.textContent=c+'×';
li.appendChild(nm);li.appendChild(w);ul.appendChild(li);});
wrap.appendChild(ul);insp.appendChild(wrap);
}
}
// ---- zoom / pan ----
let view={k:1,x:0,y:0};
function applyView(){gRoot.setAttribute('transform',`translate(${view.x} ${view.y}) scale(${view.k})`);}
function screenToVB(ev){const p=svg.createSVGPoint();p.x=ev.clientX;p.y=ev.clientY;return p.matrixTransform(svg.getScreenCTM().inverse());}
function vbToWorld(v){return {x:(v.x-view.x)/view.k,y:(v.y-view.y)/view.k};}
svg.addEventListener('wheel',ev=>{ev.preventDefault();
const vb=screenToVB(ev),w=vbToWorld(vb),f=Math.pow(1.0016,-ev.deltaY);
view.k=Math.max(0.25,Math.min(5,view.k*f));
view.x=vb.x-w.x*view.k;view.y=vb.y-w.y*view.k;applyView();
},{passive:false});
let panning=null;
svg.addEventListener('pointerdown',ev=>{
if(ev.target.closest('.node-dot'))return; // node handles its own
panning={sx:ev.clientX,sy:ev.clientY,vx:view.x,vy:view.y,moved:false};
svg.classList.add('grabbing');
window.addEventListener('pointermove',onPan);window.addEventListener('pointerup',endPan);
});
function onPan(ev){if(!panning)return;const ctm=svg.getScreenCTM();
const dx=(ev.clientX-panning.sx)/ctm.a,dy=(ev.clientY-panning.sy)/ctm.d;
if(Math.abs(ev.clientX-panning.sx)+Math.abs(ev.clientY-panning.sy)>3)panning.moved=true;
view.x=panning.vx+dx;view.y=panning.vy+dy;applyView();}
function endPan(){const moved=panning&&panning.moved;panning=null;svg.classList.remove('grabbing');
window.removeEventListener('pointermove',onPan);window.removeEventListener('pointerup',endPan);
if(!moved){selected=null;clearFocus();renderInspectorHint();}}
document.getElementById('resetView').addEventListener('click',()=>{view={k:1,x:0,y:0};applyView();});
// ---- node drag ----
let dragging=null;
function startDrag(ev){
const n=nodes.find(x=>x.gEl===ev.currentTarget);if(!n)return;
ev.preventDefault();ev.stopPropagation();dragging=n;n.fixed=true;
ev.currentTarget.setPointerCapture(ev.pointerId);
window.addEventListener('pointermove',onDrag);window.addEventListener('pointerup',endDrag);
}
function onDrag(ev){if(!dragging)return;const w=vbToWorld(screenToVB(ev));
dragging.x=Math.max(30,Math.min(W-30,w.x));dragging.y=Math.max(30,Math.min(H-30,w.y));
dragging.vx=0;dragging.vy=0;reheat(0.25);render();}
function endDrag(){if(dragging)dragging.fixed=false;dragging=null;
window.removeEventListener('pointermove',onDrag);window.removeEventListener('pointerup',endDrag);}
// ---- team overlay ----
function setClusterMembership(){
for(const n of nodes){n._cteam=null;
if(!clusterOn)continue;
for(const t of n.teams){if(enabledTeams.has(t.id)){n._cteam=t;break;}}}
}
function computeTeamCentroids(){
for(const t of TEAMS){if(!enabledTeams.has(t.id))continue;
let sx=0,sy=0,c=0;
for(const n of nodes){if(!n.hidden&&n._cteam===t){sx+=n.x;sy+=n.y;c++;}}
if(c){t.cx=sx/c;t.cy=sy/c;}}
}
function updateHalos(){
for(const n of nodes){
const t=n.teams.find(tt=>enabledTeams.has(tt.id));
if(t){n.haloEl.setAttribute('stroke',t.color);n.haloEl.style.opacity='0.9';}
else n.haloEl.style.opacity='0';}
}
function convexHull(pts){
pts=pts.slice().sort((a,b)=>a.x-b.x||a.y-b.y);
const n=pts.length;if(n<3)return pts;
const cross=(o,a,b)=>(a.x-o.x)*(b.y-o.y)-(a.y-o.y)*(b.x-o.x);
const lo=[];for(const p of pts){while(lo.length>=2&&cross(lo[lo.length-2],lo[lo.length-1],p)<=0)lo.pop();lo.push(p);}
const up=[];for(let i=n-1;i>=0;i--){const p=pts[i];while(up.length>=2&&cross(up[up.length-2],up[up.length-1],p)<=0)up.pop();up.push(p);}
lo.pop();up.pop();return lo.concat(up);
}
function smoothPath(p){
const n=p.length;if(n<3)return '';
const mid=(a,b)=>({x:(a.x+b.x)/2,y:(a.y+b.y)/2});const s=mid(p[n-1],p[0]);
let d=`M ${s.x.toFixed(1)} ${s.y.toFixed(1)}`;
for(let i=0;i<n;i++){const cur=p[i],nx=p[(i+1)%n],m=mid(cur,nx);
d+=` Q ${cur.x.toFixed(1)} ${cur.y.toFixed(1)} ${m.x.toFixed(1)} ${m.y.toFixed(1)}`;}
return d+' Z';
}
function renderTeamOverlay(){
for(const t of TEAMS){
if(!t._hullEl){
t._hullEl=document.createElementNS(SVGNS,'path');t._hullEl.setAttribute('class','hull');
t._hullEl.setAttribute('fill',t.color);t._hullEl.setAttribute('stroke',t.color);gHulls.appendChild(t._hullEl);
t._labelEl=document.createElementNS(SVGNS,'text');t._labelEl.setAttribute('class','hull-label');
t._labelEl.setAttribute('fill',t.color);t._labelEl.setAttribute('text-anchor','middle');gHulls.appendChild(t._labelEl);
}
if(!enabledTeams.has(t.id)){t._hullEl.setAttribute('d','');t._labelEl.textContent='';continue;}
const mem=nodes.filter(n=>!n.hidden&&n.teams.includes(t));
if(!mem.length){t._hullEl.setAttribute('d','');t._labelEl.textContent='';continue;}
if(mem.length<3){
let sx=0,sy=0,my=Infinity;mem.forEach(n=>{sx+=n.x;sy+=n.y;if(n.y<my)my=n.y;});
t._hullEl.setAttribute('d','');t._labelEl.setAttribute('x',sx/mem.length);t._labelEl.setAttribute('y',my-14);t._labelEl.textContent=t.name;continue;}
const hull=convexHull(mem.map(n=>({x:n.x,y:n.y})));
let cx=0,cy=0;hull.forEach(p=>{cx+=p.x;cy+=p.y;});cx/=hull.length;cy/=hull.length;
const ex=hull.map(p=>{const dx=p.x-cx,dy=p.y-cy,d=Math.hypot(dx,dy)||1;const pad=32;return {x:p.x+dx/d*pad,y:p.y+dy/d*pad};});
t._hullEl.setAttribute('d',smoothPath(ex));
let miny=Infinity;ex.forEach(p=>{if(p.y<miny)miny=p.y;});
t._labelEl.setAttribute('x',cx);t._labelEl.setAttribute('y',miny-8);t._labelEl.textContent=t.name;
}
}
// ---- controls ----
const search=document.getElementById('search');
search.addEventListener('input',runSearch);
function runSearch(){
const q=search.value.trim().toLowerCase();
if(btnInv.classList.contains('active'))toggleInverted(false);
selected=null;
if(!q){clearFocus();renderInspectorHint();return;}
const matches=new Set(nodes.filter(n=>!n.hidden&&n.id.toLowerCase().includes(q)).map(n=>n.id));
edges.forEach(e=>{setEdge(e,false);
if(!e.s.hidden&&!e.t.hidden)e.el.style.strokeOpacity=(matches.has(e.s.id)&&matches.has(e.t.id))?'0.7':'0.03';});
edges.forEach(e=>{if(matches.has(e.s.id)&&matches.has(e.t.id))setEdge(e,true);});
if(matches.size)dimAll(matches);else clearFocus();
}
function recomputeHidden(){
const showF=togF.classList.contains('on'),showD=togD.classList.contains('on'),showData=togData.classList.contains('on');
const hideHubs=togHubs.classList.contains('on'),apiOnly=togApi.classList.contains('on');
for(const n of nodes){let h=false;
if(n.layer==='features'&&!showF)h=true;
if(n.layer==='domain'&&!showD)h=true;
if(n.layer==='data'&&!showData)h=true;
if(hideHubs&&HUBS.has(n.id))h=true;
n.hidden=h;n.gEl.style.display=h?'none':'';}
for(const e of edges){let h=e.s.hidden||e.t.hidden;if(apiOnly&&!e.api)h=true;
e.el.style.display=h?'none':'';}
if(selected&&!byId.get(selected).hidden)focusNode(selected);
else{selected=null;if(search.value.trim())runSearch();else clearFocus();}
reheat(0.15);
}
function bindTog(el){el.addEventListener('click',()=>{el.classList.toggle('on');recomputeHidden();});}
const togF=document.querySelector('.tog[data-layer="features"]');
const togD=document.querySelector('.tog[data-layer="domain"]');
const togData=document.querySelector('.tog[data-layer="data"]');
const togHubs=document.getElementById('togHubs');
const togApi=document.getElementById('togApi');
[togF,togD,togData,togHubs,togApi].forEach(bindTog);
// team toggles (built from TEAMS config)
const teamList=document.getElementById('teamList');
const togCluster=document.getElementById('togCluster');
TEAMS.forEach(t=>{
const el=document.createElement('label');el.className='tog';el.dataset.team=t.id;
el.innerHTML=`<span class="box"></span><span class="dot" style="background:${t.color}"></span>${t.name}<span class="meta" id="teamCnt_${t.id}"></span>`;
el.addEventListener('click',()=>{el.classList.toggle('on');syncTeams();});
teamList.appendChild(el);
});
function syncTeams(){
enabledTeams.clear();
teamList.querySelectorAll('.tog.on').forEach(el=>enabledTeams.add(el.dataset.team));
setClusterMembership();updateHalos();reheat(0.35);render();
}
togCluster.addEventListener('click',()=>{
togCluster.classList.toggle('on');clusterOn=togCluster.classList.contains('on');
setClusterMembership();reheat(0.45);render();
});
const btnInv=document.getElementById('btnInv');
function toggleInverted(force){
const on=force!==undefined?force:!btnInv.classList.contains('active');
btnInv.classList.toggle('active',on);
if(!on){selected=null;clearFocus();renderInspectorHint();return;}
selected=null;search.value='';
const invE=edges.filter(e=>e.inv&&!e.s.hidden&&!e.t.hidden);
const bright=new Set();invE.forEach(e=>{bright.add(e.s.id);bright.add(e.t.id);});
edges.forEach(e=>{setEdge(e,false);if(!e.s.hidden&&!e.t.hidden)e.el.style.strokeOpacity='0.025';});
invE.forEach(e=>setEdge(e,true));dimAll(bright);
insp.innerHTML='<div class="insp-id" style="color:var(--inverted)">inverted deps</div>'+
'<p class="hint" style="margin-top:10px">Edges where a lower-layer <b style="color:var(--domain)">domain</b> or <b style="color:var(--data)">data</b> module imports a <b style="color:var(--features)">feature</b> <code>api</code> — the dependency arrow points the "wrong" way across the layer boundary.</p>';
const ul=document.createElement('ul');ul.style.cssText='list-style:none;padding:0;margin:12px 0 0;display:flex;flex-direction:column;gap:1px';
invE.sort((a,b)=>a.s.id.localeCompare(b.s.id)).forEach(e=>{
const li=document.createElement('li');
li.style.cssText='font-family:var(--mono);font-size:11px;padding:5px 7px;border-radius:5px;color:var(--inverted);cursor:pointer;word-break:break-all';
li.textContent=`${e.s.id} → ${e.t.id}`;
li.addEventListener('click',ev=>{ev.stopPropagation();toggleInverted(false);selectNode(e.s.id);});
li.addEventListener('mouseenter',()=>li.style.background='var(--panel-2)');
li.addEventListener('mouseleave',()=>li.style.background='');
ul.appendChild(li);});
insp.appendChild(ul);
}
btnInv.addEventListener('click',()=>toggleInverted());
// ---- header / hub text ----
function updateChrome(){
document.getElementById('statNodes').textContent=nodes.length;
document.getElementById('statNodesLbl').textContent=VIEWCFG[CURRENT].kind;
document.getElementById('statEdges').textContent=edges.length;
document.getElementById('invCount').textContent=edges.filter(e=>e.inv).length;
document.getElementById('cntF').textContent=nodes.filter(n=>n.layer==='features').length;
document.getElementById('cntD').textContent=nodes.filter(n=>n.layer==='domain').length;
const nData=nodes.filter(n=>n.layer==='data').length, hasData=nData>0;
document.getElementById('cntData').textContent=nData;
document.getElementById('togDataRow').style.display=hasData?'':'none';
document.getElementById('legendData').style.display=hasData?'':'none';
document.getElementById('hubCount').textContent=HUBS.size;
document.getElementById('hubList').textContent=[...HUBS].map(id=>byId.get(id).label).join(', ');
TEAMS.forEach(t=>{const el=document.getElementById('teamCnt_'+t.id);
if(el)el.textContent=nodes.filter(n=>n.teams.includes(t)).length;});
document.getElementById('statEdgesLbl').textContent=CURRENT==='screens'?'nav links':'dep links';
if(CURRENT==='screens'&&groupListBuilt)Object.keys(GROUP_LABELS).forEach(g=>{
const el=document.getElementById('grpCnt_'+g);if(el)el.textContent=nodes.filter(n=>n.layer===g).length;});
}
// ---- screens-view chrome ----
let groupListBuilt=false;
function buildGroupList(){
groupListBuilt=true;const gl=document.getElementById('groupList');gl.innerHTML='';
Object.keys(GROUP_LABELS).forEach(g=>{
const el=document.createElement('label');el.className='tog';el.dataset.group=g;
el.innerHTML=`<span style="background:${GROUP_COLORS[g]};width:10px;height:10px;border-radius:50%;flex:0 0 auto;display:inline-block"></span>${GROUP_LABELS[g]}<span class="meta" id="grpCnt_${g}"></span>`;
el.addEventListener('click',()=>{
const was=el.classList.contains('act');
gl.querySelectorAll('.tog').forEach(x=>x.classList.remove('act'));
if(was)clearFocus();else{el.classList.add('act');isolateGroup(g);}
});
gl.appendChild(el);
});
}
function isolateGroup(g){
selected=null;
const set=new Set(nodes.filter(n=>!n.hidden&&n.layer===g).map(n=>n.id));
const bright=new Set(set);
edges.forEach(e=>{if(set.has(e.s.id))bright.add(e.t.id);if(set.has(e.t.id))bright.add(e.s.id);});
edges.forEach(e=>{setEdge(e,false);if(!e.s.hidden&&!e.t.hidden)e.el.style.strokeOpacity='0.02';});
edges.forEach(e=>{if(set.has(e.s.id)||set.has(e.t.id))setEdge(e,true);});
dimAll(bright);
}
function applyViewChrome(name){
const isS=name==='screens';
['grpLayers','grpDeclutter','grpLayering'].forEach(id=>document.getElementById(id).style.display=isS?'none':'');
document.getElementById('grpGroups').style.display=isS?'':'none';
document.getElementById('legendBox').style.display=isS?'none':'';
document.querySelector('.stat.warn').style.display=isS?'none':'';
document.getElementById('eyebrowKind').textContent=isS?'AppRoute navigation map':'gradle dependency graph';
document.getElementById('hud').innerHTML=isS
?'screens grouped by function<br>arrow = navigates to<br><b>scroll</b> zoom · <b>drag</b> pan'
:'consumers <span style="color:var(--features)">left</span> · foundations <span style="color:var(--domain)">right</span><br>arrow points to the dependency<br><b>scroll</b> zoom · <b>drag</b> pan';
if(isS&&!groupListBuilt)buildGroupList();
if(groupListBuilt)document.getElementById('groupList').querySelectorAll('.tog').forEach(x=>x.classList.remove('act'));
}
// ---- view switch ----
function loadView(name){
if(raf){cancelAnimationFrame(raf);raf=null;}
CURRENT=name;selected=null;search.value='';
btnInv.classList.remove('active');
applyViewChrome(name);
view={k:1,x:0,y:0};applyView();
buildModel(name);setClusterMembership();initLayout();buildDOM();updateHalos();updateChrome();
recomputeHidden();render();applyBaseLabels();
if(!reduced){alpha=0.16;raf=requestAnimationFrame(animate);}
}
const vs=document.getElementById('viewswitch');
vs.addEventListener('click',ev=>{const b=ev.target.closest('button');if(!b)return;
[...vs.children].forEach(x=>x.classList.toggle('on',x===b));
loadView(b.dataset.view);});
loadView('area');
</script>

View file

@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""
build_graph.py scan the live codebase and refresh the managed regions of
.claude/docs/navigation-graph.md (the source of truth for module-connectivity.html).
What it extracts from code:
- features/domain/data Gradle project dependencies -> area graph + module graph
- AppRoute screens + every `AppRoute.X` reference -> screen navigation graph
What it preserves:
- all hand-written prose ABOVE the <!-- NAVGRAPH:BEGIN --> marker
- the curated CONFIG block (groups / owners / teams) inside the managed region
Run from anywhere inside the repo: python3 build_graph.py
Then render the HTML: python3 render_html.py
"""
import os, re, json, sys
from collections import defaultdict
# ---------------------------------------------------------------- paths
def find_root(start):
d = os.path.abspath(start)
while d != os.path.dirname(d):
if os.path.exists(os.path.join(d, "settings.gradle.kts")):
return d
d = os.path.dirname(d)
sys.exit("ERROR: could not locate repo root (settings.gradle.kts not found).")
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT = find_root(os.getcwd())
SKILL = os.path.dirname(SCRIPT_DIR)
DOCS = os.path.join(ROOT, ".claude", "docs")
MD = os.path.join(DOCS, "navigation-graph.md")
SEED = os.path.join(SKILL, "assets", "config.seed.json")
APPROUTE = os.path.join(ROOT, "common/routing/src/main/kotlin/com/tangem/common/routing/AppRoute.kt")
# ---------------------------------------------------------------- managed-region helpers
BEGIN = "<!-- NAVGRAPH:BEGIN — managed by the navigation-graph skill. Edit only the CONFIG json; the rest is generated from code. -->"
END = "<!-- NAVGRAPH:END -->"
def block(text, key):
"""Return the JSON string inside the <!-- NAVGRAPH:key:BEGIN/END --> markers, or None."""
m = re.search(r"<!-- NAVGRAPH:%s:BEGIN -->\s*```json\s*(.*?)\s*```\s*<!-- NAVGRAPH:%s:END -->"
% (re.escape(key), re.escape(key)), text, re.S)
return m.group(1) if m else None
def wrap(key, payload):
return f"<!-- NAVGRAPH:{key}:BEGIN -->\n```json\n{payload}\n```\n<!-- NAVGRAPH:{key}:END -->"
# ---------------------------------------------------------------- gradle dependency scan
def camel_to_kebab(s): return re.sub(r'(?<!^)(?=[A-Z])', '-', s).lower()
def accessor_to_path(acc): return ':' + ':'.join(camel_to_kebab(p) for p in acc.split('.'))
def dir_to_path(d): return ':' + os.path.relpath(d, ROOT).replace(os.sep, ':')
PROJ_RE = re.compile(r'(api|implementation|testImplementation|androidTestImplementation|'
r'compileOnly|kapt|ksp|debugImplementation)\s*\(\s*projects\.([A-Za-z0-9_.]+)\s*\)')
def scan_modules():
modules = {}
edges = []
for dp, _, fns in os.walk(ROOT):
if '/build/' in dp or '/.git' in dp or '/.gradle' in dp: continue
if 'build.gradle.kts' not in fns: continue
if dp == ROOT: continue
modules[dir_to_path(dp)] = dp
for path, dp in modules.items():
txt = open(os.path.join(dp, 'build.gradle.kts'), encoding='utf-8', errors='ignore').read()
for m in PROJ_RE.finditer(txt):
edges.append((path, accessor_to_path(m.group(2)), m.group(1)))
return modules, edges
SCOPED_LAYERS = ('features', 'domain', 'data')
def is_scoped(m): return any(m.startswith(':' + layer) for layer in SCOPED_LAYERS)
def area_of(m):
p = m.split(':')[1:]
return f"{p[0]}:{p[1]}" if len(p) > 1 and p[0] in SCOPED_LAYERS else None
def build_area_graph(modules, edges):
agg = defaultdict(lambda: {"w": 0, "api": False})
for s, d, c in edges:
if not (is_scoped(s) and is_scoped(d)): continue
sa, da = area_of(s), area_of(d)
if not sa or not da or sa == da: continue
agg[(sa, da)]["w"] += 1
if c == 'api': agg[(sa, da)]["api"] = True
indeg, outdeg = defaultdict(int), defaultdict(int)
for (s, d) in agg: outdeg[s] += 1; indeg[d] += 1
nodes = sorted({n for e in agg for n in e})
N = [[n, n.split(':')[1], n.split(':')[0], indeg[n], outdeg[n]] for n in nodes]
E = [[s, d, v["w"], 1 if v["api"] else 0] for (s, d), v in agg.items()]
return {"n": N, "e": E}
def build_module_graph(modules, edges):
agg = defaultdict(lambda: {"w": 0, "api": False})
for s, d, c in edges:
if not (is_scoped(s) and is_scoped(d)) or s == d: continue
agg[(s, d)]["w"] += 1
if c == 'api': agg[(s, d)]["api"] = True
indeg, outdeg = defaultdict(int), defaultdict(int)
for (s, d) in agg: outdeg[s] += 1; indeg[d] += 1
conn = {n for e in agg for n in e}
N = [[m, ':'.join(m.split(':')[2:]), m.split(':')[1], indeg[m], outdeg[m]]
for m in sorted(conn)]
E = [[s, d, v["w"], 1 if v["api"] else 0] for (s, d), v in agg.items()]
return {"n": N, "e": E}
# ---------------------------------------------------------------- AppRoute scan
REF_RE = re.compile(r'AppRoute\.([A-Z][A-Za-z0-9]+)')
DECL_RE = re.compile(r'^ (?:data )?(?:object|class) (\w+)', re.M)
def read_kotlin_string(text, i):
"""text[i] must be '"'. Return (content, index_after_closing_quote), handling \\"
escapes and ${...} template expressions (nested braces/strings copied verbatim) so a
path with string templates isn't truncated at the first inner quote."""
out, j = [], i + 1
while j < len(text):
c = text[j]
if c == '\\':
out.append(text[j:j + 2]); j += 2; continue
if c == '"':
return ''.join(out), j + 1
if c == '$' and j + 1 < len(text) and text[j + 1] == '{':
out.append('${'); j += 2; depth = 1
while j < len(text) and depth > 0:
ck = text[j]
if ck == '"':
s, j = read_kotlin_string(text, j); out.append('"' + s + '"'); continue
if ck == '{': depth += 1
elif ck == '}': depth -= 1
if depth > 0: out.append(ck)
j += 1
out.append('}'); continue
out.append(c); j += 1
return ''.join(out), j
def extract_path(block):
"""First string literal of the `path = …` argument within one screen's source block.
Block-scoped (so multi-line `AppRoute(` blocks resolve) and template-aware (so paths
aren't truncated); for a non-literal RHS (e.g. `path = when {…}`) it takes the first
branch literal. Returns None when the block has no `path =`."""
m = re.search(r'\bpath\s*=\s*', block)
if not m: return None
i = m.end()
while i < len(block) and block[i] in ' \t\r\n': i += 1
if i < len(block) and block[i] == '"':
return read_kotlin_string(block, i)[0]
q = block.find('"', i)
return read_kotlin_string(block, q)[0] if q != -1 else None
def scan_routes():
src = open(APPROUTE, encoding='utf-8').read()
decls = [(m.group(1), m.start()) for m in DECL_RE.finditer(src)]
screens = {name for name, _ in decls}
paths = {}
for idx, (name, start) in enumerate(decls):
end = decls[idx + 1][1] if idx + 1 < len(decls) else len(src)
p = extract_path(src[start:end])
if p is not None: paths.setdefault(name, p)
usage = defaultdict(lambda: defaultdict(int))
nav = defaultdict(int)
def area(fp):
parts = os.path.relpath(fp, ROOT).split(os.sep)
top = parts[0]
return f"{top}:{parts[1]}" if top in ('features','domain','data','core','common','libs') and len(parts) > 1 else top
for dp, _, fns in os.walk(ROOT):
if '/build/' in dp or '/.git' in dp or '/.gradle' in dp: continue
for fn in fns:
if not fn.endswith('.kt'): continue
fp = os.path.join(dp, fn)
if os.path.samefile(fp, APPROUTE) if os.path.exists(APPROUTE) else False: continue
try: txt = open(fp, encoding='utf-8', errors='ignore').read()
except Exception: continue
if 'AppRoute.' not in txt: continue
a = area(fp)
for m in REF_RE.finditer(txt):
if m.group(1) in screens: usage[m.group(1)][a] += 1
for nm in re.finditer(r'\b(push|replaceCurrent|replaceAll|popTo)\s*\(', txt):
r2 = REF_RE.search(txt[nm.end():nm.end()+160])
if r2 and r2.group(1) in screens: nav[(a, r2.group(1))] += 1
return screens, paths, usage, nav
def build_screen_graph(screens, paths, usage, nav, cfg):
total = {s: sum(usage[s].values()) for s in screens}
sg, so = cfg["screenGroups"], cfg["screenOwners"]
owned = defaultdict(list)
for s in sorted(screens): owned[so.get(s, cfg["defaultOwner"])].append(s)
def fnorm(a): return a.split(':')[-1].replace('-', '').lower()
def main_of(a, ss):
epon = [s for s in ss if s.lower() == fnorm(a)]
return epon[0] if epon else max(ss, key=lambda x: (total[x], x)) # deterministic tie-break
main = {a: main_of(a, ss) for a, ss in owned.items()}
APPSHELL = 'AppShell'
edge = defaultdict(int)
for (a, t), c in nav.items():
src = main.get(a, APPSHELL)
if src == t: continue
edge[(src, t)] += c
use_shell = any(s == APPSHELL for s, _ in edge)
indeg, outdeg = defaultdict(int), defaultdict(int)
for (s, t) in edge: outdeg[s] += 1; indeg[t] += 1
N = [[s, s, sg.get(s, cfg["defaultGroup"]), indeg[s], outdeg[s]] for s in sorted(screens)]
if use_shell: N.append([APPSHELL, 'App shell', 'shell', indeg[APPSHELL], outdeg[APPSHELL]])
E = [[s, t, w, 0] for (s, t), w in edge.items()]
meta = {}
for s in sorted(screens):
meta[s] = {"path": paths.get(s, ""), "owner": so.get(s, cfg["defaultOwner"]),
"group": sg.get(s, cfg["defaultGroup"]), "total": total[s],
"refs": sorted(usage[s].items(), key=lambda kv: -kv[1])}
return {"n": N, "e": E}, meta
# ---------------------------------------------------------------- main
def main():
if not os.path.exists(APPROUTE):
sys.exit(f"ERROR: AppRoute.kt not found at {APPROUTE}")
old = open(MD, encoding='utf-8').read() if os.path.exists(MD) else ""
# config: prefer the one already in the doc (human edits win), else seed
cfg_str = block(old, "config")
cfg = json.loads(cfg_str) if cfg_str else json.load(open(SEED))
modules, dep_edges = scan_modules()
area_g = build_area_graph(modules, dep_edges)
mod_g = build_module_graph(modules, dep_edges)
screens, paths, usage, nav = scan_routes()
# reconcile config with the screens actually present in code
warns = []
for s in sorted(screens):
if s not in cfg["screenGroups"]:
cfg["screenGroups"][s] = cfg["defaultGroup"]; warns.append(f"NEW screen '{s}': group defaulted to '{cfg['defaultGroup']}' — set it in CONFIG")
if s not in cfg["screenOwners"]:
cfg["screenOwners"][s] = cfg["defaultOwner"]; warns.append(f"NEW screen '{s}': owner defaulted to '{cfg['defaultOwner']}' — set it in CONFIG")
stale = [s for s in cfg["screenGroups"] if s not in screens]
screen_g, screen_meta = build_screen_graph(screens, paths, usage, nav, cfg)
inv_area = sum(1 for s, t, w, a in area_g["e"] if t.startswith('features:') and not s.startswith('features:'))
cj = lambda o: json.dumps(o, separators=(',', ':'))
summary = (
f"_Auto-generated from code by the `navigation-graph` skill. Edit only the CONFIG block below._\n\n"
f"- **Screens (AppRoute):** {len(screens)} · screen-nav edges {len(screen_g['e'])}\n"
f"- **Area graph:** {len(area_g['n'])} feature/domain/data areas · {len(area_g['e'])} dependency edges · {inv_area} inverted (domain/data→features)\n"
f"- **Module graph:** {len(mod_g['n'])} modules · {len(mod_g['e'])} edges\n"
)
if warns: summary += "\n**Action needed:**\n" + "\n".join(f"- {w}" for w in warns) + "\n"
if stale: summary += f"\n_Config has {len(stale)} screen(s) no longer in code (kept, harmless): {', '.join(stale)}_\n"
managed = "\n\n".join([
BEGIN,
"## Connectivity data (generated)\n\n" + summary,
"### Curated config (editable — preserved across refreshes)\n\n" + wrap("config", json.dumps(cfg, indent=2)),
"### Graph data (auto — overwritten every refresh; do not hand-edit)\n\n" +
wrap("areaGraph", cj(area_g)) + "\n\n" + wrap("moduleGraph", cj(mod_g)) + "\n\n" +
wrap("screensGraph", cj(screen_g)) + "\n\n" + wrap("screensMeta", cj(screen_meta)),
END,
])
if BEGIN in old and END in old:
head = old[:old.index(BEGIN)].rstrip() + "\n\n"
tail = old[old.index(END) + len(END):]
new = head + managed + tail
elif old.strip():
new = old.rstrip() + "\n\n" + managed + "\n"
else:
new = "# Navigation Graph\n\n" + managed + "\n"
os.makedirs(DOCS, exist_ok=True)
open(MD, "w", encoding='utf-8').write(new)
print(f"✓ updated {os.path.relpath(MD, ROOT)}")
print(f" screens={len(screens)} screen-edges={len(screen_g['e'])} | "
f"areas={len(area_g['n'])}/{len(area_g['e'])} | modules={len(mod_g['n'])}/{len(mod_g['e'])}")
for w in warns: print("" + w)
print("Next: python3 " + os.path.join(SCRIPT_DIR, "render_html.py"))
if __name__ == "__main__":
main()

View file

@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""
render_html.py build .claude/docs/module-connectivity.html from the data blocks
in .claude/docs/navigation-graph.md (which build_graph.py refreshes from code).
Run AFTER build_graph.py: python3 render_html.py
"""
import os, re, json, sys
def find_root(start):
d = os.path.abspath(start)
while d != os.path.dirname(d):
if os.path.exists(os.path.join(d, "settings.gradle.kts")):
return d
d = os.path.dirname(d)
sys.exit("ERROR: could not locate repo root (settings.gradle.kts not found).")
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
ROOT = find_root(os.getcwd())
SKILL = os.path.dirname(SCRIPT_DIR)
MD = os.path.join(ROOT, ".claude", "docs", "navigation-graph.md")
TEMPLATE = os.path.join(SKILL, "assets", "template.html")
OUT = os.path.join(ROOT, ".claude", "docs", "module-connectivity.html")
def block(text, key):
m = re.search(r"<!-- NAVGRAPH:%s:BEGIN -->\s*```json\s*(.*?)\s*```\s*<!-- NAVGRAPH:%s:END -->"
% (re.escape(key), re.escape(key)), text, re.S)
if not m:
sys.exit(f"ERROR: data block '{key}' not found in {MD}. Run build_graph.py first.")
return json.loads(m.group(1))
def main():
if not os.path.exists(MD): sys.exit(f"ERROR: {MD} not found. Run build_graph.py first.")
if not os.path.exists(TEMPLATE): sys.exit(f"ERROR: template missing at {TEMPLATE}")
text = open(MD, encoding='utf-8').read()
cfg = block(text, "config")
area = block(text, "areaGraph")
mod = block(text, "moduleGraph")
screens = block(text, "screensGraph")
smeta = block(text, "screensMeta")
groups = cfg["groups"]
group_colors = {g: groups[g]["color"] for g in groups}
group_labels = {g: groups[g]["label"] for g in groups}
group_anchors = {g: groups[g]["anchor"] for g in groups}
cj = lambda o: json.dumps(o, separators=(',', ':'))
tpl = open(TEMPLATE, encoding='utf-8').read()
repl = {
"/*__AREA_DATA__*/null": cj(area),
"/*__MODULE_DATA__*/null": cj(mod),
"/*__SCREENS_DATA__*/null": cj(screens),
"/*__SCREENS_META__*/null": cj(smeta),
"/*__TEAMS__*/[]": cj(cfg["teams"]),
"/*__GROUP_COLORS__*/{}": cj(group_colors),
"/*__GROUP_LABELS__*/{}": cj(group_labels),
"/*__GROUP_ANCHORS__*/{}": cj(group_anchors),
}
out = tpl
for k, v in repl.items():
if k not in out: sys.exit(f"ERROR: placeholder '{k}' missing in template — template/skill version mismatch.")
out = out.replace(k, v)
for ph in ("__AREA_DATA__", "__MODULE_DATA__", "__SCREENS_DATA__", "__SCREENS_META__",
"__TEAMS__", "__GROUP_COLORS__", "__GROUP_LABELS__", "__GROUP_ANCHORS__"):
if "/*" + ph + "*/" in out: sys.exit(f"ERROR: placeholder {ph} left unreplaced.")
open(OUT, "w", encoding='utf-8').write(out)
print(f"✓ wrote {os.path.relpath(OUT, ROOT)} ({len(out)//1024} KB, self-contained)")
print(f" area {len(area['n'])}n/{len(area['e'])}e · module {len(mod['n'])}n/{len(mod['e'])}e · "
f"screens {len(screens['n'])}n/{len(screens['e'])}e · teams {len(cfg['teams'])}")
print(f" open: {OUT}")
if __name__ == "__main__":
main()