Skip to content

Commit 6fd5adf

Browse files
authored
feat(network): search refinements — zoom on hit, address panel, location/staking on detail panels (#102)
- Node hash/name search zooms to the matched node + 1-hop neighbors - 0x address search opens a dedicated panel with copyable address, link count, staking positions (CCNs + ALEPH per position), wallet link - Address-driven spotlight: dim everything outside the wallet's footprint; highlightedIds now matches id (staker), owner, and reward - CCN/CRN detail panels show a Location row (flag + country name); country attribution decoupled from the geo layer toggle - Search: controlled component (q lifted to page), 280px to match the detail card, 28×28 info-icon button, gap-0.5 to the input; close-panel + close-address clear the search input - Fix: --network-country OKLCH chroma was out of sRGB gamut at hue 200° and silently dropped by Lightning CSS — country nodes rendered black; lowered chroma to 0.13 (Decision #77)
1 parent 5b0809a commit 6fd5adf

17 files changed

Lines changed: 401 additions & 44 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

docs/ARCHITECTURE.md

Lines changed: 7 additions & 5 deletions
Large diffs are not rendered by default.

docs/DECISIONS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,30 @@ Each entry includes:
1818

1919
---
2020

21+
## Decision #79 - 2026-05-11
22+
**Context:** Country attribution on CCN/CRN nodes used to live inside the `if (layers.has("geo"))` block of `buildGraph`, so `n.country` was only populated when the geo layer was on. After adding a "Location" row to the CCN/CRN detail panels (showing `🇫🇷 France` etc.), the row was missing whenever Geo was off (the default).
23+
**Decision:** Split the geo block into two passes in `network-graph-model.ts`: (1) country *attribution* (set `n.country` on located CCN/CRN, build the `represented` set) always runs; (2) geo *layer artifacts* (the country hub nodes + geo edges) only run when `layers.has("geo")`. Detail panels read `node.country` regardless of layer state.
24+
**Rationale:** Country is a property of the node, not of a presentation layer. Coupling it to the layer toggle was a quirk of the original geo-layer commit (#74), not a design intent. The split is mechanical, doesn't change geo-layer visuals, and unlocks the Location row without forcing users to enable Geo to see where a node lives. The cost is one extra pass over `nodes` when Geo is off — negligible at ~500 nodes.
25+
**Alternatives considered:** Look up country directly in the panel from `node-locations.json` (rejected — duplicates the existing graph-model lookup; two sources of truth for the same data). Force the geo layer on by default (rejected — the layer affects the entire layout; turning it on just to show a country label is overkill). Add a separate `geo-attribute` non-visible layer that only sets `country` (rejected — adds a layer concept the user can never toggle, confusing).
26+
27+
---
28+
29+
## Decision #78 - 2026-05-11
30+
**Context:** Address deep-link (`?address=0x...`) previously highlighted matching nodes with a pulsing primary-color ring and auto-fit the camera to them, but otherwise nothing changed — no detail panel, no spotlight dim. After making `?selected=` open a node panel, search-by-address still felt like nothing happened: pulse rings were easy to miss against a busy graph and the user got no panel feedback. Separately, `highlightedIds` was computed only from `owner === address`, so a wallet that staked (id-match) or received rewards (reward-match) but didn't own any node showed zero highlights.
31+
**Decision:** Treat `?address=` as a first-class selection signal. (1) Open a dedicated `NetworkSearchAddressPanel` (right side, 280px, same chrome as the node panel) when address is set and no node is selected — showing the copyable address, link count, an "Open wallet view →" link, and a staking section listing every CCN where the address appears in `c.stakers` with per-position and total ALEPH. (2) Expand `highlightedIds` to match `id` (staker), `owner`, or `reward` so the on-graph pulse reflects the wallet's full footprint. (3) Extend the selection spotlight: when no node is selected but `highlightedIds.size > 0`, `relevantIds = highlightedIds ∪ 1-hop neighbors` and the existing dim path applies to nodes, labels, *and* edges (non-incident edges fade). Address-panel close clears `?address=` *and* the search input, matching the panel-close clears-search pattern.
32+
**Rationale:** The pulse-ring-only design assumed power users would notice the dim signal — in practice users searched an address and saw "nothing happen." Promoting the address to a real panel + spotlight reuses the same visual idiom users already know from node selection (dim everything else, panel on the right), so address search becomes discoverable without inventing a new pattern. Expanding `highlightedIds` to id/owner/reward matches the wallet's actual footprint, not just its ownership — a staker who doesn't own anything still gets visual presence. The edge-dim extension was a missing piece: previously only `selectedId` drove the `faded` flag, so address search left edges fully lit while nodes dimmed — visually inconsistent.
33+
**Alternatives considered:** Auto-select the first matching node instead of opening an address panel (rejected — the panel context would be "this node" rather than "this wallet"; misleading when the address owns multiple nodes). Treat the address as a virtual GraphNode and add `kind: "address"` (rejected — ripples through `RADIUS`, color tables, panel switch statements for a UI-only synthetic). Use a thicker pulse ring instead of dimming (rejected — pulse alone didn't read; the dim is what makes the spotlight feel intentional). Keep `highlightedIds` owner-only (rejected — a real-world staker without ownership is invisible, contradicts the new panel's "Linked to N nodes" copy).
34+
35+
---
36+
37+
## Decision #77 - 2026-05-11
38+
**Context:** Country nodes on `/network` rendered as flat black circles instead of cyan-teal. The CSS token `--network-country` declared in `globals.css` resolved to an empty string in the browser; SVG `fill="var(--network-country)"` then fell back to the SVG default (`black`). Sibling tokens (`--network-edge-owner`, `--network-edge-reward`) in the same `:root` block compiled fine. The compiled `_next/static/chunks/src_app_globals_*.css` literally did not contain `--network-country` — the property was being stripped at build time.
39+
**Decision:** Drop the chroma value on `--network-country` from `0.16` (light) / `0.14` (dark) to `0.13` for both, keeping hue `200°` and lightness `0.70` / `0.78`. The token now compiles through Lightning CSS as a valid `lab()` value with a hex fallback.
40+
**Rationale:** Lightning CSS (Tailwind v4's CSS engine) silently drops `oklch()` values that are out of sRGB gamut at the requested hue, instead of clamping. At hue ~200° (cyan), the in-gamut chroma ceiling is around `0.13` at these lightnesses — `0.16`/`0.14` overshot and got dropped silently. The other tokens used `230°` (blue), which has more headroom and stayed in gamut. The visual difference between chroma `0.13` and `0.14`/`0.16` is small enough to not warrant a different hue or a hex fallback, and keeping OKLCH preserves the codebase convention.
41+
**Alternatives considered:** Switch the token to a hex value (rejected — breaks the OKLCH convention used by the rest of `globals.css`; would also lose automatic light/dark inversion since hex doesn't communicate intent). Move to a different hue with more headroom (rejected — the cyan/teal hue was the entire point of the choice in Decision #74; would lose the distinct identity vs CCN purple, CRN green, staker amber). Add a hex fallback alongside the OKLCH (rejected — works but doesn't address the root cause of *why* the OKLCH dropped, and would mask the issue if anyone later swapped values back to out-of-gamut). Verified with browser-resolved computed styles after each chroma value.
42+
43+
---
44+
2145
## Decision #76 - 2026-05-11
2246
**Context:** Decision #74's initial design treated geo edges as force-only — `network-graph.tsx` early-returned on `e.type === "geo"` so they never rendered, on the theory that proximity to the country dot would already convey "in this country." Real-world testing with Geo + Structural both on showed the cross-country CCN→CRN structural arrows visually muddled the geo grouping — each country dot was surrounded by its cluster but no edges connected them, so the eye couldn't tell which CCN belonged to which country at a glance.
2347
**Decision:** Render geo edges as a thin, country-tinted dashed tether — `STROKE.geo = var(--network-country)`, `DASH.geo = "1 2"`, `OPACITY.geo = 0.35`, `strokeWidth=0.5`, no arrowhead. The country becomes a visible hub-and-spoke hub. Wire country into the selection-incident-color path (`incidentColor` returns `var(--network-country)` when a country is selected) so picking a country brightens its tethers to `0.9` opacity via the existing `highlightColor` branch in `NetworkEdge`. Add a "Country tether" line swatch to the legend (also conditional on the geo layer).

src/app/globals.css

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@
1313
--map-vignette: rgba(0, 0, 0, 0.05);
1414
--network-edge-owner: oklch(0.65 0.18 230);
1515
--network-edge-reward: var(--color-primary-400);
16-
--network-country: oklch(0.70 0.16 200);
16+
--network-country: oklch(0.70 0.13 200);
1717
}
1818

1919
.theme-dark {
2020
--map-dot-color: rgba(255, 255, 255, 0.06);
2121
--map-vignette: rgba(0, 0, 0, 0.3);
2222
--network-edge-owner: oklch(0.72 0.16 230);
2323
--network-edge-reward: var(--color-primary-300);
24-
--network-country: oklch(0.78 0.14 200);
24+
--network-country: oklch(0.78 0.13 200);
2525
}
2626

2727
html {

src/app/network/page.tsx

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { NetworkSearch } from "@/components/network/network-search";
1414
import { NetworkDetailPanel } from "@/components/network/network-detail-panel";
1515
import { NetworkFocusPill } from "@/components/network/network-focus-pill";
1616
import { NetworkLegend } from "@/components/network/network-legend";
17+
import { NetworkSearchAddressPanel } from "@/components/network/network-search-address-panel";
1718

1819
const SETTLE_MS = 500;
1920

@@ -30,6 +31,9 @@ function NetworkContent() {
3031
} = useNetworkGraph();
3132
const [resetKey, setResetKey] = useState(0);
3233
const [isSettling, setIsSettling] = useState(false);
34+
const [searchQuery, setSearchQuery] = useState("");
35+
const [fitTargetId, setFitTargetId] = useState<string | null>(null);
36+
const [fitNonce, setFitNonce] = useState(0);
3337

3438
useEffect(() => {
3539
setIsSettling(true);
@@ -39,6 +43,12 @@ function NetworkContent() {
3943

4044
const onResetView = useCallback(() => {
4145
setResetKey((k) => k + 1);
46+
setFitTargetId(null);
47+
}, []);
48+
49+
const onSearchFit = useCallback((id: string) => {
50+
setFitTargetId(id);
51+
setFitNonce((n) => n + 1);
4252
}, []);
4353

4454
const selectedId = searchParams.get("selected");
@@ -50,7 +60,11 @@ function NetworkContent() {
5060
if (!address) return new Set<string>();
5161
return new Set(
5262
visibleGraph.nodes
53-
.filter((n) => n.owner?.toLowerCase() === address)
63+
.filter((n) =>
64+
n.id.toLowerCase() === address ||
65+
n.owner?.toLowerCase() === address ||
66+
n.reward?.toLowerCase() === address,
67+
)
5468
.map((n) => n.id),
5569
);
5670
}, [visibleGraph, address]);
@@ -65,6 +79,15 @@ function NetworkContent() {
6579
const params = new URLSearchParams(searchParams.toString());
6680
params.delete("selected");
6781
router.replace(`/network?${params.toString()}`, { scroll: false });
82+
setSearchQuery("");
83+
setFitTargetId(null);
84+
}, [router, searchParams]);
85+
86+
const onCloseAddress = useCallback(() => {
87+
const params = new URLSearchParams(searchParams.toString());
88+
params.delete("address");
89+
router.replace(`/network?${params.toString()}`, { scroll: false });
90+
setSearchQuery("");
6891
}, [router, searchParams]);
6992

7093
const onFocus = useCallback((id: string) => {
@@ -154,6 +177,8 @@ function NetworkContent() {
154177
selectedId={selectedId}
155178
highlightedIds={highlightedIds}
156179
refitKey={refitKey}
180+
fitTargetId={fitTargetId}
181+
fitNonce={fitNonce}
157182
onNodeClick={onNodeClick}
158183
/>
159184
)}
@@ -195,7 +220,11 @@ function NetworkContent() {
195220
{isFetching ? "Fetching" : "Updating"}
196221
</span>
197222
)}
198-
<NetworkSearch />
223+
<NetworkSearch
224+
q={searchQuery}
225+
onChange={setSearchQuery}
226+
onSearchFit={onSearchFit}
227+
/>
199228
</div>
200229
</div>
201230

@@ -214,6 +243,17 @@ function NetworkContent() {
214243
/>
215244
</aside>
216245
)}
246+
247+
{address && !selectedNode && (
248+
<aside className="pointer-events-auto absolute right-6 top-40 z-20 hidden w-[280px] max-h-[calc(100%-11rem)] overflow-hidden rounded-xl border border-foreground/[0.06] bg-muted/40 dark:bg-surface shadow-md md:block">
249+
<NetworkSearchAddressPanel
250+
address={address}
251+
matchCount={highlightedIds.size}
252+
nodeState={nodeState}
253+
onClose={onCloseAddress}
254+
/>
255+
</aside>
256+
)}
217257
</div>
218258
);
219259
}

src/changelog.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,39 @@ export type VersionEntry = {
1111
changes: ChangeEntry[];
1212
};
1313

14-
export const CURRENT_VERSION = "0.12.0";
14+
export const CURRENT_VERSION = "0.13.0";
1515

1616
export const CHANGELOG: VersionEntry[] = [
17+
{
18+
version: "0.13.0",
19+
date: "2026-05-11",
20+
changes: [
21+
{
22+
type: "feature",
23+
text: "Network graph search: pressing Enter on a node hash or name now zooms in on the matching node and its 1-hop neighborhood — no more squinting at the dot that just got selected. Country searches still focus the country's subgraph; address searches use their own fit path.",
24+
},
25+
{
26+
type: "feature",
27+
text: "Network graph address search: a 0x address now opens a dedicated panel (right side) listing the copyable wallet, link count, and a Staking section showing every CCN the address stakes on with per-position and total ALEPH. The spotlight dims everything outside the wallet's footprint — nodes where the address is the staker, owner, or reward target stay full opacity, the rest fade. Pulse rings still mark the matches.",
28+
},
29+
{
30+
type: "feature",
31+
text: "Network graph CCN/CRN cards: new Location row with flag emoji and country name (e.g. 🇫🇷 France) — visible regardless of whether the Geo layer is on, since country attribution is now independent of the layer toggle.",
32+
},
33+
{
34+
type: "ui",
35+
text: "Network graph search field: same width (280px) as the detail cards so the column reads as one stack, and the info-icon button shrunk to a 28×28 target tight against the input (was a chunky pill-shaped lozenge with extra padding).",
36+
},
37+
{
38+
type: "ui",
39+
text: "Network graph panels: closing the node detail panel (× button) now also clears the search input — previously you had to clear the field separately.",
40+
},
41+
{
42+
type: "fix",
43+
text: "Network graph country nodes rendered as flat black circles because the cyan CSS token's OKLCH chroma was out of sRGB gamut at hue 200° and got silently dropped by Lightning CSS. Lowered chroma to a safe value; country nodes now render in their intended cyan-teal.",
44+
},
45+
],
46+
},
1747
{
1848
version: "0.12.0",
1949
date: "2026-05-11",

src/components/network/network-detail-panel-address.test.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,21 @@ const STAKER: GraphNode = {
1515

1616
describe("NetworkDetailPanelAddress", () => {
1717
it("renders the address, degree summary, and wallet link when degree > 0", () => {
18-
render(<NetworkDetailPanelAddress node={STAKER} degree={4} />);
18+
render(<NetworkDetailPanelAddress node={STAKER} degree={4} nodeState={undefined} />);
1919
expect(screen.getByText(/Connected to 4 CCNs/i)).toBeInTheDocument();
2020
expect(
2121
screen.getByRole("link", { name: /Open wallet view/i }),
2222
).toHaveAttribute("href", `/wallet?address=${STAKER.id}`);
2323
});
2424

2525
it("hides the degree line when degree is 0", () => {
26-
render(<NetworkDetailPanelAddress node={STAKER} degree={0} />);
26+
render(
27+
<NetworkDetailPanelAddress
28+
node={STAKER}
29+
degree={0}
30+
nodeState={undefined}
31+
/>,
32+
);
2733
expect(screen.queryByText(/Connected to/i)).not.toBeInTheDocument();
2834
});
2935

@@ -32,6 +38,7 @@ describe("NetworkDetailPanelAddress", () => {
3238
<NetworkDetailPanelAddress
3339
node={{ ...STAKER, kind: "reward" }}
3440
degree={2}
41+
nodeState={undefined}
3542
/>,
3643
);
3744
expect(screen.getByText(/Connected to 2 nodes/i)).toBeInTheDocument();

src/components/network/network-detail-panel-address.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
import Link from "next/link";
44
import { CopyableText } from "@aleph-front/ds/copyable-text";
5+
import type { NodeState } from "@/api/credit-types";
56
import type { GraphNode } from "@/lib/network-graph-model";
7+
import { NetworkStakingSection } from "./network-staking-section";
68

79
type Props = {
810
node: GraphNode;
911
degree: number;
12+
nodeState: NodeState | undefined;
1013
};
1114

12-
export function NetworkDetailPanelAddress({ node, degree }: Props) {
15+
export function NetworkDetailPanelAddress({ node, degree, nodeState }: Props) {
1316
const noun = node.kind === "staker" ? "CCNs" : "nodes";
1417

1518
return (
@@ -28,6 +31,8 @@ export function NetworkDetailPanelAddress({ node, degree }: Props) {
2831
</p>
2932
)}
3033

34+
<NetworkStakingSection address={node.id} nodeState={nodeState} />
35+
3136
<Link
3237
href={`/wallet?address=${node.id}`}
3338
className="block text-sm font-medium text-primary-300 hover:underline"

src/components/network/network-detail-panel-ccn.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
import { Badge } from "@aleph-front/ds/badge";
44
import { CopyableText } from "@aleph-front/ds/copyable-text";
55
import type { CCNInfo } from "@/api/credit-types";
6+
import { countryFlag } from "@/lib/country-flag";
7+
import { countryName } from "@/lib/network-address-info";
68

79
type Props = {
810
info: CCNInfo;
11+
country?: string | undefined;
912
};
1013

1114
const ALEPH_FMT = new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 });
@@ -16,9 +19,11 @@ function ccnChipVariant(info: CCNInfo): "success" | "warning" | "default" {
1619
return "warning";
1720
}
1821

19-
export function NetworkDetailPanelCCN({ info }: Props) {
22+
export function NetworkDetailPanelCCN({ info, country }: Props) {
2023
const crnCount = info.resourceNodes.length;
2124
const stakerCount = Object.keys(info.stakers).length;
25+
const flag = country ? countryFlag(country) : null;
26+
const land = country ? countryName(country) : null;
2227

2328
return (
2429
<div className="space-y-4 px-4 py-3 text-sm">
@@ -39,6 +44,15 @@ export function NetworkDetailPanelCCN({ info }: Props) {
3944
<dt className="text-muted-foreground">Score</dt>
4045
<dd className="font-mono text-xs">{info.score.toFixed(2)}</dd>
4146
</div>
47+
{land && (
48+
<div className="flex justify-between">
49+
<dt className="text-muted-foreground">Location</dt>
50+
<dd className="flex items-center gap-1.5">
51+
{flag && <span aria-hidden>{flag}</span>}
52+
<span>{land}</span>
53+
</dd>
54+
</div>
55+
)}
4256
</dl>
4357

4458
<div className="grid grid-cols-2 gap-2">

src/components/network/network-detail-panel-crn.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@ import { Skeleton } from "@aleph-front/ds/ui/skeleton";
66
import { ResourceBar } from "@/components/resource-bar";
77
import { useNode } from "@/hooks/use-nodes";
88
import type { CCNInfo, CRNInfo } from "@/api/credit-types";
9+
import { countryFlag } from "@/lib/country-flag";
10+
import { countryName } from "@/lib/network-address-info";
911

1012
type Props = {
1113
info: CRNInfo;
1214
parent: CCNInfo | null;
15+
country?: string | undefined;
1316
onFocusParent: (parentId: string) => void;
1417
};
1518

@@ -19,10 +22,12 @@ function crnChipVariant(info: CRNInfo): "success" | "warning" | "default" {
1922
return "warning";
2023
}
2124

22-
export function NetworkDetailPanelCRN({ info, parent, onFocusParent }: Props) {
25+
export function NetworkDetailPanelCRN({ info, parent, country, onFocusParent }: Props) {
2326
const { data: node, isLoading } = useNode(info.hash);
2427
const showResources =
2528
isLoading || (node?.resources != null && node.resources.vcpusTotal > 0);
29+
const flag = country ? countryFlag(country) : null;
30+
const land = country ? countryName(country) : null;
2631

2732
return (
2833
<div className="space-y-4 px-4 py-3 text-sm">
@@ -51,6 +56,15 @@ export function NetworkDetailPanelCRN({ info, parent, onFocusParent }: Props) {
5156
)}
5257
</dd>
5358
</div>
59+
{land && (
60+
<div className="flex justify-between">
61+
<dt className="text-muted-foreground">Location</dt>
62+
<dd className="flex items-center gap-1.5">
63+
{flag && <span aria-hidden>{flag}</span>}
64+
<span>{land}</span>
65+
</dd>
66+
</div>
67+
)}
5468
</dl>
5569

5670
<div className="space-y-1 border-t border-edge pt-3">

0 commit comments

Comments
 (0)