diff --git a/crates/toolpath-desktop/.taurignore b/crates/toolpath-desktop/.taurignore new file mode 100644 index 0000000..a22982e --- /dev/null +++ b/crates/toolpath-desktop/.taurignore @@ -0,0 +1,5 @@ +# Tauri CLI file-watcher ignore list (gitignore syntax). +# `cargo tauri dev` watches the crate root and restarts the Rust binary on +# any change. Vite already hot-reloads the Svelte frontend, so we don't +# want the frontend tree to trigger a Rust restart. +frontend/ diff --git a/crates/toolpath-desktop/frontend/public/contour-texture.svg b/crates/toolpath-desktop/frontend/public/contour-texture.svg new file mode 100644 index 0000000..de9b29e --- /dev/null +++ b/crates/toolpath-desktop/frontend/public/contour-texture.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/crates/toolpath-desktop/frontend/src/app.svelte b/crates/toolpath-desktop/frontend/src/app.svelte index 8a5461a..aad2bfb 100644 --- a/crates/toolpath-desktop/frontend/src/app.svelte +++ b/crates/toolpath-desktop/frontend/src/app.svelte @@ -1,68 +1,98 @@ -{#if notInTauri} - -{/if} +
+ +
+ + -
-
- - Toolpath - Pathbase companion -
- -
+ +
+ {#if notInTauri} + + {/if} -
- {#if store.m.error} -
- {store.m.error} - -
- {/if} + {#if store.m.error} +
+
+ {store.m.error} + + +
+
+ {/if} - {#if store.m.route === "home"} - - {:else if store.m.route === "browse-agents"} - - {:else if store.m.route === "browse-claude"} - - {:else if store.m.route === "browse-pi"} - - {:else if store.m.route === "browse-git"} - - {:else if store.m.route === "browse-github"} - - {:else if store.m.route === "preview"} - - {:else if store.m.route === "result"} - - {/if} -
+ {#if store.m.route === "home"} + + {:else if store.m.route === "browse-agents"} + + {:else if store.m.route === "browse-claude"} + + {:else if store.m.route === "browse-pi"} + + {:else if store.m.route === "browse-git"} + + {:else if store.m.route === "browse-github"} + + {:else if store.m.route === "preview"} + + {:else if store.m.route === "result"} + + {/if} +
+
diff --git a/crates/toolpath-desktop/frontend/src/lib/SourceLogo.svelte b/crates/toolpath-desktop/frontend/src/lib/SourceLogo.svelte new file mode 100644 index 0000000..570abad --- /dev/null +++ b/crates/toolpath-desktop/frontend/src/lib/SourceLogo.svelte @@ -0,0 +1,33 @@ + + +{#if kind === "github"} + + + +{:else if kind === "git"} + + + +{:else if kind === "claude"} + + + + +{:else if kind === "pi"} + + + + + +{/if} diff --git a/crates/toolpath-desktop/frontend/src/routes/BrowseAgents.svelte b/crates/toolpath-desktop/frontend/src/routes/BrowseAgents.svelte index 8bdb829..87ad3d9 100644 --- a/crates/toolpath-desktop/frontend/src/routes/BrowseAgents.svelte +++ b/crates/toolpath-desktop/frontend/src/routes/BrowseAgents.svelte @@ -3,47 +3,58 @@ import type { AgentStatus } from "../lib/types"; function statusLabel(s: AgentStatus): string { - switch (s) { - case "available": return "Installed"; - case "unavailable": return "Not detected"; - case "coming-soon": return "Coming soon"; - } + if (s === "available") return "Installed"; + if (s === "unavailable") return "Not detected"; + return "Coming soon"; + } + function statusTag(s: AgentStatus): string { + if (s === "available") return "tag tag--ok"; + if (s === "unavailable") return "tag tag--muted"; + return "tag tag--warn"; } -
- -
-

Agents

-

- {store.m.agents.loading ? "Detecting installed agents… " : "Select an agent to browse its traces."} - {#if store.m.agents.loading}{/if} -

+
+
+ +
-{#if store.m.agents.list === null} - -{:else if store.m.agents.list.length === 0} -
No agents known.
-{:else} -
- {#each store.m.agents.list as agent (agent.id)} - {@const clickable = agent.status === "available"} -
store.dispatch({ t: "AgentsSelect", agent }) : undefined} - > -
-
{agent.name}
-
- {statusLabel(agent.status)} -
-
{agent.tagline ?? ""}
- {#if agent.reason} -
{agent.reason}
- {/if} -
- {/each} + -{/if} + + {#if store.m.agents.list === null} + + {:else if store.m.agents.list.length === 0} +
No agents known.
+ {:else} +
+ {#each store.m.agents.list as agent (agent.id)} + {@const clickable = agent.status === "available"} + + {/each} +
+ {/if} +
diff --git a/crates/toolpath-desktop/frontend/src/routes/BrowseClaude.svelte b/crates/toolpath-desktop/frontend/src/routes/BrowseClaude.svelte index f3cb9ae..7f78dc3 100644 --- a/crates/toolpath-desktop/frontend/src/routes/BrowseClaude.svelte +++ b/crates/toolpath-desktop/frontend/src/routes/BrowseClaude.svelte @@ -1,12 +1,9 @@ -
- -
-

Claude Code sessions

-

- {#if projectCount === 0 && claude.loadingProjects} - Scanning ~/.claude/projects/… - {:else} - {projectCount} project{projectCount === 1 ? "" : "s"}{claude.loadingProjects ? " — still scanning… " : " "} - {/if} - {#if claude.loadingProjects}{/if} -

+
+
+ +
-{#if projectCount === 0 && claude.projectsDone} -
No Claude projects found. Use Claude Code at least once and come back.
-{:else} -
- {#each claude.projects as p (p.project_path)} - {@const isExpanded = claude.expanded === p.project_path} - {@const selectedForProject = Object.keys(claude.selected[p.project_path] ?? {}).length} -
-
store.dispatch({ t: "ClaudeExpandProject", path: p.project_path })} - > -
{p.display_name}
-
-
- {p.session_count} session{p.session_count === 1 ? "" : "s"} - {#if selectedForProject > 0} · {selectedForProject} selected{/if} -
-
- {#if isExpanded} - {@const sessions = claude.sessionsByPath[p.project_path] ?? []} - {@const loading = claude.sessionsLoading[p.project_path]} -
- {#if sessions.length === 0 && loading} -
Loading sessions…
- {:else if sessions.length === 0} -
No sessions in this project.
- {:else} - {#each sessions as s (s.session_id)} - {@const isChecked = !!(claude.selected[p.project_path] ?? {})[s.session_id]} - {@const title = claude.titles[`${p.project_path}|${s.session_id}`]} -
+ {/if} -
-
- - {selectedCount || "No"} session{selectedCount === 1 ? "" : "s"} selected - - +
+ + + {selectedCount || "No"} session{selectedCount === 1 ? "" : "s"} selected + + +
diff --git a/crates/toolpath-desktop/frontend/src/routes/BrowseGit.svelte b/crates/toolpath-desktop/frontend/src/routes/BrowseGit.svelte index bf02541..878a085 100644 --- a/crates/toolpath-desktop/frontend/src/routes/BrowseGit.svelte +++ b/crates/toolpath-desktop/frontend/src/routes/BrowseGit.svelte @@ -1,63 +1,89 @@ -
- -
-

Local git repository

-

Point to a repo and pick a branch. History on that branch becomes the trace.

+
+
+ +
-
- -
- store.dispatch({ t: "GitSetRepoPath", value: (ev.target as HTMLInputElement).value })} - onkeydown={(ev) => { if (ev.key === "Enter") store.dispatch({ t: "GitLoadBranches" }); }} - /> - - + -
-{#if git.branches === null} -
No branches loaded yet.
-{:else if git.branches.length === 0} -
No branches found in this repo.
-{:else} -
- {#each git.branches as b (b.name)} -
store.dispatch({ t: "GitSelectBranch", name: b.name })} - > -
-
{b.name}
-
{b.subject || "(no subject)"}
-
-
-
{b.head_short} · {b.author}
-
- {/each} + -{/if} -
-
- +
+ +
+ store.dispatch({ t: "GitSetRepoPath", value: (ev.target as HTMLInputElement).value })} + onkeydown={(ev) => { if (ev.key === "Enter") store.dispatch({ t: "GitLoadBranches" }); }} + /> + + +
+
+ + {#if git.branches !== null && git.branches.length > 0} + +
+ {#each git.branches as b (b.name)} + + {/each} +
+ {:else if git.branches !== null} +
No branches found in this repo.
+ {:else} +
No branches loaded yet.
+ {/if} + +
+ + +
diff --git a/crates/toolpath-desktop/frontend/src/routes/BrowseGithub.svelte b/crates/toolpath-desktop/frontend/src/routes/BrowseGithub.svelte index 7fb92e4..5a7cf9c 100644 --- a/crates/toolpath-desktop/frontend/src/routes/BrowseGithub.svelte +++ b/crates/toolpath-desktop/frontend/src/routes/BrowseGithub.svelte @@ -3,80 +3,107 @@ const gh = $derived(store.m.github); -
- -
-

GitHub pull request

-

Paste a PR URL. We fetch commits, reviews, and CI checks, then render the trace.

+
+
+ +
-{#if gh.hasToken === null} -
Checking keychain…
-{:else if gh.editingToken || !gh.hasToken} -
- -
- store.dispatch({ t: "GithubSetTokenInput", value: (ev.target as HTMLInputElement).value })} - /> - - {#if gh.hasToken} - - {/if} + -{:else} -
- GitHub token is configured. - - + + -{/if} -
- - store.dispatch({ t: "GithubSetUrl", value: (ev.target as HTMLInputElement).value })} - /> -
- - + {#if gh.hasToken === null} +
Checking keychain…
+ {:else if gh.editingToken || !gh.hasToken} +
+ +
+ store.dispatch({ t: "GithubSetTokenInput", value: (ev.target as HTMLInputElement).value })} + /> + + {#if gh.hasToken} + + {/if} +
+
+ Stored under dev.pathbase.toolpath-desktop. +
+
+ {:else} +
+ ◇ Token set + + + +
+ {/if} + + -
-
-
- +
+ store.dispatch({ t: "GithubSetUrl", value: (ev.target as HTMLInputElement).value })} + /> +
+ + +
+
+ +
+ + +
diff --git a/crates/toolpath-desktop/frontend/src/routes/BrowsePi.svelte b/crates/toolpath-desktop/frontend/src/routes/BrowsePi.svelte index 16d22aa..6d7e452 100644 --- a/crates/toolpath-desktop/frontend/src/routes/BrowsePi.svelte +++ b/crates/toolpath-desktop/frontend/src/routes/BrowsePi.svelte @@ -3,6 +3,7 @@ import { invoke, listen } from "../lib/ipc"; import { store } from "../lib/store.svelte"; import { dbg } from "../lib/debug"; + import SourceLogo from "../lib/SourceLogo.svelte"; import type { PiProject, PiSession } from "../lib/types"; // Permanent subscriptions — `onMount` runs exactly once per component @@ -95,94 +96,110 @@ ); -
- -
-

pi.dev sessions

-

- {#if projectCount === 0 && pi.loadingProjects} - Scanning ~/.pi/agent/sessions/… - {:else} - {projectCount} project{projectCount === 1 ? "" : "s"}{pi.loadingProjects ? " — still scanning… " : " "} - {/if} - {#if pi.loadingProjects}{/if} -

+
+
+ +
-{#if projectCount === 0 && pi.projectsDone} -
No pi.dev projects found. Run a pi.dev session and come back.
-{:else} -
- {#each pi.projects as p (p.project_path)} - {@const isExpanded = pi.expanded === p.project_path} - {@const selectedForProject = Object.keys(pi.selected[p.project_path] ?? {}).length} -
-
store.dispatch({ t: "PiExpandProject", path: p.project_path })} - > -
{p.display_name}
-
-
- {p.session_count} session{p.session_count === 1 ? "" : "s"} - {#if selectedForProject > 0} · {selectedForProject} selected{/if} -
-
- {#if isExpanded} - {@const sessions = pi.sessionsByPath[p.project_path] ?? []} - {@const loading = pi.sessionsLoading[p.project_path]} -
- {#if sessions.length === 0 && loading} -
Loading sessions…
- {:else if sessions.length === 0} -
No sessions in this project.
- {:else} - {#each sessions as s (s.session_id)} - {@const isChecked = !!(pi.selected[p.project_path] ?? {})[s.session_id]} -
+ {/if} -
-
- - {selectedCount || "No"} session{selectedCount === 1 ? "" : "s"} selected - - +
+ + + {selectedCount || "No"} session{selectedCount === 1 ? "" : "s"} selected + + +
diff --git a/crates/toolpath-desktop/frontend/src/routes/Home.svelte b/crates/toolpath-desktop/frontend/src/routes/Home.svelte index 7464e6f..8a4ae4b 100644 --- a/crates/toolpath-desktop/frontend/src/routes/Home.svelte +++ b/crates/toolpath-desktop/frontend/src/routes/Home.svelte @@ -1,24 +1,110 @@ -

Pick a source

-

- Select where your agent trace is coming from. You'll be able to preview - it before exporting or uploading to Pathbase. -

-
- {#each cards as c (c.id)} -
store.dispatch({ t: "NavigateTo", screen: c.id })} role="button" tabindex="0"> -
{c.title}
-
{c.desc}
+
+ + + +
+ {#each SOURCES as s (s.k)} + + {/each}
- {/each} + + + {#each SOURCES as s (s.k)} + {#if active === s.k} +
+
+
+ +

+ {s.desc} +

+

+ ⎇ derived via path derive {s.k} +

+
+ +
+
+ {/if} + {/each}
diff --git a/crates/toolpath-desktop/frontend/src/routes/Preview.svelte b/crates/toolpath-desktop/frontend/src/routes/Preview.svelte index 6c640ce..cf23b39 100644 --- a/crates/toolpath-desktop/frontend/src/routes/Preview.svelte +++ b/crates/toolpath-desktop/frontend/src/routes/Preview.svelte @@ -6,7 +6,7 @@ let canvasEl: HTMLDivElement | null = $state(null); // Re-render the graph whenever the underlying doc, selection, or toggles - // change. `vizEpoch` bumps on every toggle/branch-expand. + // change. `vizEpoch` bumps on every toggle / branch-expand. $effect(() => { const p = store.m.preview; if (!canvasEl || !p) return; @@ -38,81 +38,149 @@ const times = $derived( steps.map((s) => s.step.timestamp).filter((t): t is string => !!t).sort(), ); + const docTitle = $derived(preview?.filename ?? "(untitled)"); {#if !preview} -

Nothing to preview.

+
+
+
Nothing to preview
+

+ Pick a repo, pull request, or conversation from the home tab. Toolpath + will derive a Path document, show its provenance graph, and let you + upload it to pathbase.dev. +

+
⎇ ◇ ⊕ ● · four source types
+
{:else} -
- -
- {preview.source} + +
+
+
§2 · DERIVED PATH · {preview.source}
+
{docTitle}
+
+ △ {steps.length} step{steps.length === 1 ? "" : "s"} + · ◆ {actorSet.size} actor{actorSet.size === 1 ? "" : "s"} + {#if times.length >= 2}· {times[0].slice(0, 10)} → {times[times.length - 1].slice(0, 10)}{/if} +
+
+
+ ◇ Valid + + +
-

Preview

-

- {steps.length} step{steps.length === 1 ? "" : "s"} · {actorSet.size} actor{actorSet.size === 1 ? "" : "s"} - {#if times.length >= 2} · spans {times[0].slice(0, 10)} → {times[times.length - 1].slice(0, 10)}{/if} -

-
- - -
- Click a card's expand chip to reveal its dead-end branch. -
+ +
+
+ -
-
-
- {#if !preview.selectedStep} -
Click a step in the graph to inspect its diff and metadata.
- {:else} - {@const s = preview.selectedStep} - {@const actor = s.step.actor} - {@const def = preview.selectedActors?.[actor]} - {@const displayName = def?.name ?? actor.split(":").slice(1).join(":")} - {@const changeKeys = s.change ? Object.keys(s.change) : []} -

{s.step.id}

-
-
Actor
-
{displayName} {actor}
- {#if s.step.timestamp} -
Time
{s.step.timestamp}
- {/if} - {#if s.step.parents && s.step.parents.length} -
Parents
{s.step.parents.join(", ")}
- {/if} - {#if s.meta?.intent} -
Intent
{s.meta.intent}
- {/if} -
- {#if changeKeys.length === 0} -
No change body on this step.
+
+ {#if !preview.selectedStep} +
Click a step in the graph to inspect its diff and metadata.
{:else} - {#each changeKeys as k (k)} - {@const ch = s.change![k]} -
{k}
- {#if ch.raw}
{ch.raw}
{/if} - {#if ch.structural}
{JSON.stringify(ch.structural, null, 2)}
{/if} - {/each} + {@const s = preview.selectedStep} + {@const actor = s.step.actor} + {@const def = preview.selectedActors?.[actor]} + {@const displayName = def?.name ?? actor.split(":").slice(1).join(":")} + {@const changeKeys = s.change ? Object.keys(s.change) : []} +
+ {s.step.id} +
+
+
Actor
+
{displayName} {actor}
+ {#if s.step.timestamp} +
Time
+
{s.step.timestamp}
+ {/if} + {#if s.step.parents && s.step.parents.length} +
Parents
+
{s.step.parents.join(", ")}
+ {/if} + {#if s.meta?.intent} +
Intent
+
{s.meta.intent}
+ {/if} +
+ {#if changeKeys.length === 0} +
No change body on this step.
+ {:else} + {#each changeKeys as k (k)} + {@const ch = s.change![k]} +
{k}
+ {#if ch.raw}
{ch.raw}
{/if} + {#if ch.structural}
{JSON.stringify(ch.structural, null, 2)}
{/if} + {/each} + {/if} {/if} - {/if} +
+ +
+ +
+
Source
+
{preview.source}
+
Filename
+
{preview.filename}
+
Steps
+
{steps.length}
+
Actors
+
{actorSet.size}
+
-
-
-
- - +
+ + +
+ + + + Click a card's expand chip to reveal its dead-end branch. +
+ +
+ +
+ + + human + + + + agent + + + + dead-end + + + {steps.length} steps · {actorSet.size} actors +
+
{/if} diff --git a/crates/toolpath-desktop/frontend/src/routes/Result.svelte b/crates/toolpath-desktop/frontend/src/routes/Result.svelte index 3ae2fae..948b3bc 100644 --- a/crates/toolpath-desktop/frontend/src/routes/Result.svelte +++ b/crates/toolpath-desktop/frontend/src/routes/Result.svelte @@ -13,33 +13,67 @@ } -{#if !r} -

No result.

-{:else if r.kind === "export"} -

Saved

-

Your trace was written to disk.

-
-
Source
{r.source}
-
Path
{r.path}
-
-
- - -
-{:else} -

Upload queued

- {#if r.stub} -
- Pathbase is not live yet — this is a stubbed response. The document validated cleanly; - the real upload will drop in when the API ships. +
+ {#if !r} +
+
+
No result yet
+

Derive and upload a Path document to see the outcome here.

+
+ {:else if r.kind === "export"} + + +
+
+
Source
+
{r.source}
+
Path
+
{r.path}
+
+
+ +
+ + + +
+ {:else} + + + {#if r.stub} +
+ Pathbase is not live yet — this is a stubbed response. The document + validated cleanly; the real upload will drop in when the API ships. +
+ {/if} + +
+
+
Source
+
{r.source}
+
URL
+
{r.url}
+
+
+ +
+ + +
{/if} -
-
Source
{r.source}
-
URL
{r.url}
-
-
- - -
-{/if} +
diff --git a/crates/toolpath-desktop/frontend/src/styles.css b/crates/toolpath-desktop/frontend/src/styles.css index 1edac4d..3841c47 100644 --- a/crates/toolpath-desktop/frontend/src/styles.css +++ b/crates/toolpath-desktop/frontend/src/styles.css @@ -1,244 +1,787 @@ -/* Toolpath desktop styles. Palette mirrors the site brand. */ +/* ========================================================================== + Toolpath Desktop — cartographic "paper chart" design language. + Mirrors the Pathbase brand: topographic map palette, hairline rules, + serif body, monospace metadata. + ========================================================================== */ + +@import url('https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,300..900;1,8..60,300..900&family=JetBrains+Mono:wght@300..700&family=IBM+Plex+Sans+Condensed:wght@300;400;500;600;700&display=swap'); + :root { - --bg: #f7f3ec; - --panel: #ffffff; - --border: #e4dccf; - --text: #2d2a26; - --text-muted: #8a8078; - --accent: #b5652b; - --accent-soft: #b5652b18; - --accent-hot: #b5652b30; - --danger: #c44030; - --dead: #c4403018; - --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; - --sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + /* Paper stock — cream chart paper */ + --paper: #ece6d6; + --paper-2: #e2dbc8; + --paper-3: #d6ceb8; + --paper-bright: #f2ede0; + --paper-white: #f6f2e6; + + /* Ink — desaturated warm-black */ + --ink: #221f18; + --ink-2: #3d3830; + --ink-3: #6e6857; + --ink-4: #9a9483; + --ink-5: #beb8a5; + + /* Contour brown — the defining cartographic ink */ + --contour: #8c6a3a; + --contour-2: #6d4f26; + --contour-faint: #b59a6e; + + /* Map palette */ + --water: #6e8faa; + --water-wash: #cfd9e0; + --forest: #8a9a6e; + --forest-wash: #d7dcbf; + --road: #a85038; + --road-2: #8a3e2a; + --road-wash: #ecd5cb; + --boundary: #8a3a6a; + + --accent: var(--road); + --accent-2: var(--road-2); + --accent-wash: var(--road-wash); + --ok: var(--forest); + --warn: var(--contour); + --err: var(--road); + + --font-serif: "Source Serif 4", "Source Serif Pro", "Times New Roman", Times, Georgia, serif; + --font-display: "Source Serif 4", Georgia, serif; + --font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace; + --font-sans: "IBM Plex Sans Condensed", "IBM Plex Sans", -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif; + + --ease: cubic-bezier(0.2, 0, 0.2, 1); + + /* Legacy aliases used by viz.ts (.path-graph, .pg-card) */ + --text: var(--ink); + --text-muted: var(--ink-3); + --panel: var(--paper-bright); + --border: var(--ink-5); + --accent-soft: var(--accent-wash); + --accent-hot: var(--accent-wash); + --danger: var(--road); + --dead: var(--road-wash); + --mono: var(--font-mono); + --sans: var(--font-sans); } + * { box-sizing: border-box; } -html, body { height: 100%; margin: 0; } -body { - background: var(--bg); - color: var(--text); - font-family: var(--sans); +html, body { + height: 100%; + margin: 0; + background: var(--paper-3); + color: var(--ink); + font-family: var(--font-serif); font-size: 14px; + line-height: 1.55; + font-feature-settings: "onum" 1, "liga" 1, "kern" 1; -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } -.appbar { - display: flex; - justify-content: space-between; +*::-webkit-scrollbar { width: 10px; height: 10px; } +*::-webkit-scrollbar-track { background: var(--paper-2); } +*::-webkit-scrollbar-thumb { background: var(--ink-5); border: 2px solid var(--paper-2); } +*::-webkit-scrollbar-thumb:hover { background: var(--ink-4); } + +/* ---------- Window chrome ---------- */ +.window { + display: grid; + grid-template-rows: 36px auto 1fr auto; + min-height: 100vh; + background: var(--paper); + border: 0.5px solid var(--ink); + position: relative; +} + +.window__title { + display: grid; + grid-template-columns: auto 1fr auto; align-items: center; - padding: 10px 20px; - border-bottom: 1px solid var(--border); - background: #fcf9f3; - position: sticky; - top: 0; - z-index: 10; + gap: 12px; + padding: 0 14px; + height: 36px; + background: var(--paper-2); + border-bottom: 0.5px solid var(--ink); + user-select: none; +} +.window__dots { display: flex; gap: 6px; align-items: center; } +.window__dot { + width: 10px; height: 10px; border-radius: 999px; + border: 0.5px solid var(--ink); background: transparent; +} +.window__brand { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.22em; + text-transform: uppercase; + color: var(--contour-2); + margin-left: 8px; } -.appbar__brand { display: flex; align-items: center; gap: 10px; } -.appbar__logo { - display: inline-block; width: 18px; height: 18px; - border-radius: 4px; background: var(--accent); +.window__title-center { + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--ink-3); + text-align: center; + display: flex; align-items: center; justify-content: center; gap: 10px; } -.appbar__tag { color: var(--text-muted); font-size: 12px; margin-left: 6px; } - -.screen { padding: 20px; max-width: 560px; margin: 0 auto; } -.screen--wide { max-width: none; padding: 20px 24px; } -h1 { font-size: 22px; margin: 0 0 4px 0; font-weight: 600; } -p.subtitle { color: var(--text-muted); margin: 0 0 20px 0; } +.window__title-center .sep { opacity: 0.4; } +.window__title-right { + display: flex; align-items: center; gap: 10px; + font-family: var(--font-mono); font-size: 10px; + color: var(--ink-3); letter-spacing: 0.14em; + text-transform: uppercase; +} +.window__title-right .elevation { color: var(--contour-2); } -.cards { +/* ---------- Tab bar ---------- */ +.tabs { display: flex; - flex-direction: column; - gap: 14px; -} -.card { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - padding: 16px; + align-items: stretch; + background: var(--paper); + border-bottom: 0.5px solid var(--ink); + padding-left: 24px; +} +.tabs__item { + background: transparent; + border: 0; + border-right: 0.5px solid var(--ink-5); + border-left: 0.5px solid transparent; + border-top: 2px solid transparent; + padding: 10px 18px; cursor: pointer; - transition: border-color 0.12s, transform 0.12s; + display: flex; align-items: center; gap: 8px; + font-family: var(--font-serif); + font-size: 14px; + color: var(--ink-3); + transition: color 120ms var(--ease); +} +.tabs__item:hover { color: var(--ink-2); } +.tabs__item--active { + background: var(--paper-bright); + border-left: 0.5px solid var(--ink-5); + border-top: 2px solid var(--ink); + margin-bottom: -0.5px; + color: var(--ink); +} +.tabs__num { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.14em; + color: var(--ink-4); } -.card:hover { border-color: var(--accent); transform: translateY(-1px); } -.card__title { font-weight: 600; margin-bottom: 6px; } -.card__desc { color: var(--text-muted); font-size: 13px; line-height: 1.4; } -.card__hint { - color: var(--text-muted); - font-size: 12px; - font-style: italic; - margin-top: 6px; +.tabs__item--active .tabs__num { color: var(--contour-2); } +.tabs__badge { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.1em; + padding: 1px 6px; + border: 0.5px solid var(--road); + color: var(--road); + margin-left: 4px; } -.card--disabled { opacity: 0.55; cursor: not-allowed; } -.card--disabled:hover { border-color: var(--border); transform: none; } -.badge { - display: inline-block; - font-size: 10.5px; - font-family: var(--mono); - text-transform: uppercase; - letter-spacing: 0.4px; - padding: 2px 8px; - border-radius: 999px; - border: 1px solid currentColor; +/* ---------- Main scroll area ---------- */ +.main { + min-height: 0; + overflow-y: auto; + background: var(--paper); + position: relative; } -.badge--available { color: #3a7e3a; background: #3a7e3a14; } -.badge--unavailable { color: var(--text-muted); background: #8a807815; } -.badge--coming-soon { color: var(--accent); background: var(--accent-soft); } -button.primary, button.secondary, button.danger { - font-family: inherit; +/* ---------- Page header "sheet title" ---------- */ +.page { + max-width: 1360px; + margin: 0 auto; + padding: 28px 32px 48px; +} +.page__eyebrow { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.28em; + text-transform: uppercase; + color: var(--contour-2); +} +.page__title { + font-family: var(--font-display); + font-size: 34px; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--ink); + margin: 4px 0 6px; + line-height: 1.15; +} +.page__title .accent { color: var(--road); } +.page__lede { + font-family: var(--font-serif); font-size: 14px; - padding: 8px 14px; - border-radius: 6px; - border: 1px solid var(--accent); - background: var(--accent); - color: white; - cursor: pointer; + color: var(--ink-3); + max-width: 64ch; + margin: 0; } -button.secondary { background: var(--panel); color: var(--accent); } -button.danger { background: var(--panel); color: var(--danger); border-color: var(--danger); } -button:disabled { opacity: 0.5; cursor: not-allowed; } -button.linklike { - background: none; border: none; - font-family: inherit; font-size: 14px; - color: var(--accent); cursor: pointer; padding: 4px 8px; +.page__coord { + font-family: var(--font-mono); + font-size: 10px; + color: var(--ink-3); + letter-spacing: 0.16em; + text-transform: uppercase; + white-space: nowrap; +} +.page__header { + display: grid; + grid-template-columns: 1fr auto; + align-items: end; + gap: 20px; + margin-bottom: 24px; } -button.linklike:hover { text-decoration: underline; } -input[type="text"], input[type="url"], input[type="password"] { - font-family: inherit; font-size: 14px; - padding: 8px 10px; - border: 1px solid var(--border); border-radius: 6px; - background: var(--panel); color: var(--text); - width: 100%; +/* ---------- Section label ---------- */ +.section-label { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: baseline; + gap: 10px; + margin: 0 0 10px; +} +.section-label__num { + font-family: var(--font-mono); font-size: 10px; + color: var(--contour-2); + letter-spacing: 0.22em; text-transform: uppercase; font-weight: 500; +} +.section-label__text { + font-family: var(--font-sans); font-size: 11px; + letter-spacing: 0.22em; text-transform: uppercase; + color: var(--ink-2); font-weight: 500; + border-bottom: 0.5px solid var(--ink-5); + padding-bottom: 4px; +} +.section-label__right { + font-family: var(--font-mono); font-size: 10px; + color: var(--ink-4); letter-spacing: 0.14em; text-transform: uppercase; } -input:focus { outline: none; border-color: var(--accent); } -.row { display: flex; gap: 10px; align-items: center; } -.spacer { flex: 1 1 auto; } -.stack > * + * { margin-top: 10px; } +/* ---------- Status bar ---------- */ +.status-bar { + height: 28px; + border-top: 0.5px solid var(--ink); + background: var(--paper-2); + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + padding: 0 18px; + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.1em; + color: var(--ink-3); + gap: 14px; +} +.status-bar__items { display: flex; gap: 20px; align-items: center; } +.status-bar__item { display: inline-flex; align-items: center; gap: 6px; } +.status-bar__k { color: var(--ink-4); text-transform: uppercase; letter-spacing: 0.14em; } +.status-bar__v { color: var(--ink); } +.status-bar__right { display: flex; gap: 14px; align-items: center; } +.status-bar__sep { color: var(--ink-5); } -.list { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; +/* ---------- Banner (not-in-tauri) ---------- */ +.banner { + padding: 10px 20px; + font-size: 13px; + background: var(--road); + color: var(--paper-bright); + font-family: var(--font-sans); +} +.banner code { + font-family: var(--font-mono); + font-size: 12px; + background: rgba(255,255,255,0.18); + padding: 1px 5px; } -.list__item { - display: flex; align-items: center; + +/* ---------- Error ---------- */ +.error { + color: var(--road); + background: var(--road-wash); + border: 0.5px solid var(--road); padding: 10px 14px; - border-top: 1px solid var(--border); - gap: 10px; cursor: pointer; -} -.list__item:first-child { border-top: none; } -.list__item:hover { background: var(--accent-soft); } -.list__item--selected, .list__item--expanded { background: var(--accent-soft); } -.list__title { font-weight: 500; } -.list__meta { color: var(--text-muted); font-size: 12px; font-family: var(--mono); } -.list__children { - background: #faf6ee; - border-top: 1px solid var(--border); - padding-left: 20px; + font-family: var(--font-serif); font-size: 13px; + display: flex; align-items: center; gap: 12px; + margin-bottom: 18px; } -.list__loading-hint { - color: var(--text-muted); font-size: 12px; font-style: italic; - padding: 8px 14px; + +/* ---------- Buttons ---------- */ +.btn { + font-family: var(--font-sans); + font-size: 13px; + font-weight: 500; + padding: 7px 14px; + border: 0.5px solid var(--ink); + background: var(--paper-bright); + color: var(--ink); + cursor: pointer; + transition: all 120ms var(--ease); + display: inline-flex; align-items: center; gap: 6px; + letter-spacing: 0; + border-radius: 2px; +} +.btn:hover:not(:disabled) { background: var(--paper); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn--primary { background: var(--ink); color: var(--paper-bright); } +.btn--primary:hover:not(:disabled) { background: var(--ink-2); } +.btn--accent { background: var(--accent); color: var(--paper-bright); border-color: var(--accent); } +.btn--accent:hover:not(:disabled) { background: var(--accent-2); } +.btn--ghost { + background: transparent; border: 0; padding: 6px 8px; + text-decoration: underline; text-underline-offset: 4px; text-decoration-thickness: 0.5px; + border-radius: 0; +} +.btn--danger { background: var(--paper-bright); color: var(--road); border-color: var(--road); } +.btn--sm { padding: 5px 10px; font-size: 11.5px; } + +/* ---------- Tag / badge ---------- */ +.tag { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 2px 7px; + border: 0.5px solid var(--ink); + color: var(--ink); + background: transparent; + display: inline-flex; align-items: center; gap: 5px; +} +.tag--solid { background: var(--ink); color: var(--paper-bright); } +.tag--accent { border-color: var(--accent); color: var(--accent); } +.tag--ok { border-color: var(--ok); color: var(--ok); } +.tag--warn { border-color: var(--warn); color: var(--warn); } +.tag--muted { border-color: var(--ink-5); color: var(--ink-3); } + +/* ---------- Source-type tabs (top of source picker) ---------- */ +.source-tabs { + display: grid; + grid-template-columns: repeat(4, 1fr); + background: var(--paper-2); + border-bottom: 0.5px solid var(--ink-5); +} +.source-tabs__item { + background: transparent; + border: 0; + border-right: 0.5px solid var(--ink-5); + border-bottom: 2px solid transparent; + padding: 12px 8px; + display: flex; flex-direction: column; align-items: center; gap: 3px; + cursor: pointer; + color: var(--ink-3); + font-family: var(--font-sans); + transition: all 120ms var(--ease); +} +.source-tabs__item:hover { background: var(--paper); color: var(--ink-2); } +.source-tabs__item--active { + background: var(--paper-bright); + border-bottom-color: var(--contour-2); + color: var(--ink); +} +.source-tabs__glyph { + font-family: var(--font-mono); + font-size: 13px; + color: var(--ink-4); +} +.source-tabs__item--active .source-tabs__glyph { color: var(--contour-2); } +.source-tabs__label { font-size: 12px; letter-spacing: 0.06em; } +.source-tabs__count { + font-family: var(--font-mono); font-size: 9px; + letter-spacing: 0.1em; color: var(--ink-4); + text-transform: uppercase; } -.checkbox { accent-color: var(--accent); width: 16px; height: 16px; margin: 0; } -.toolbar { - display: flex; gap: 16px; align-items: center; +/* ---------- Input / search bar ---------- */ +.field { padding: 10px 14px; - background: #faf6ee; - border: 1px solid var(--border); - border-radius: 8px; - margin-bottom: 10px; - flex-wrap: wrap; + border-bottom: 0.5px solid var(--ink-5); + background: var(--paper); + display: flex; align-items: center; gap: 8px; } -.toolbar label { - display: inline-flex; gap: 6px; align-items: center; - font-size: 12px; color: var(--text-muted); +.field input { + flex: 1; + border: 0; + background: transparent; + outline: 0; + font-family: var(--font-serif); + font-size: 13px; + color: var(--ink); } +.field input::placeholder { color: var(--ink-4); } -.preview-layout { +.input { + font-family: var(--font-serif); + font-size: 14px; + padding: 8px 10px; + border: 0.5px solid var(--ink); + background: var(--paper-white); + color: var(--ink); + outline: 0; + width: 100%; + border-radius: 2px; +} +.input:focus { border-color: var(--accent); } + +.kbd { + font-family: var(--font-mono); + font-size: 11px; + padding: 1px 6px; + border: 0.5px solid var(--ink); + border-bottom-width: 1.5px; + border-radius: 2px; + background: var(--paper-bright); + color: var(--ink); + letter-spacing: 0.04em; + display: inline-block; + line-height: 1.3; +} + +/* ---------- Row card (source picker list row) ---------- */ +.row-card { + width: 100%; + text-align: left; + background: transparent; + border: 0; + border-left: 2px solid transparent; + border-bottom: 0.5px solid var(--ink-5); + padding: 14px; + cursor: pointer; display: grid; - grid-template-columns: 1fr; - grid-template-rows: minmax(280px, 1fr) auto; - gap: 12px; - min-height: 400px; + grid-template-columns: 18px 1fr auto; + align-items: start; + column-gap: 10px; + row-gap: 4px; + transition: background 120ms var(--ease); + color: var(--ink); +} +.row-card:hover { background: var(--paper); } +.row-card--selected { + background: var(--paper-bright); + border-left-color: var(--contour-2); +} +.row-card__marker { + font-family: var(--font-mono); + font-size: 13px; + color: var(--contour-2); + margin-top: 1px; + text-align: center; } -@media (min-width: 760px) { - .preview-layout { - grid-template-columns: 1fr 320px; - grid-template-rows: none; - height: calc(100vh - 260px); - } +.row-card__marker--road { color: var(--road); } +.row-card__marker--water { color: var(--water); } +.row-card__marker--forest { color: var(--forest); } +.row-card__marker--boundary{ color: var(--boundary); } +.row-card__marker--muted { color: var(--ink-4); } +.row-card__title { + font-family: var(--font-serif); font-size: 14px; color: var(--ink); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.row-card__sub { + font-family: var(--font-mono); + font-size: 10.5px; + color: var(--ink-3); + letter-spacing: 0.04em; + margin-top: 3px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.preview-canvas { - border: 1px solid var(--border); border-radius: 8px; - background: #faf6ee; - position: relative; overflow: auto; - padding: 16px; -} -.preview-panel { - background: var(--panel); border: 1px solid var(--border); border-radius: 8px; - padding: 14px; overflow: auto; -} -.preview-panel__empty { color: var(--text-muted); font-size: 13px; line-height: 1.5; } -.preview-panel h3 { margin: 0 0 4px 0; font-size: 14px; font-family: var(--mono); } -.preview-panel dl { - margin: 8px 0; - display: grid; grid-template-columns: auto 1fr; gap: 4px 10px; - font-size: 12px; +.row-card__meta { + font-family: var(--font-mono); + font-size: 10px; + color: var(--ink-4); + letter-spacing: 0.06em; + margin-top: 4px; + display: flex; gap: 10px; flex-wrap: wrap; +} +.row-card__right { + font-family: var(--font-mono); font-size: 10px; + color: var(--ink-4); letter-spacing: 0.06em; + text-align: right; white-space: nowrap; } -.preview-panel dt { color: var(--text-muted); } -.preview-panel pre { - font-family: var(--mono); font-size: 11px; - background: #faf6ee; border: 1px solid var(--border); border-radius: 4px; - padding: 8px; overflow: auto; max-height: 220px; - white-space: pre-wrap; word-break: break-word; +.row-card__children { + background: var(--paper-bright); + border-bottom: 0.5px solid var(--ink-5); + padding: 4px 0; } -.error { - color: var(--danger); font-size: 13px; - padding: 8px 10px; - background: #fdeeea; border: 1px solid #f0c4ba; border-radius: 6px; +/* ---------- Paper-bright card panel ---------- */ +.card-panel { + background: var(--paper-bright); + border: 0.5px solid var(--ink-5); + padding: 18px; +} + +/* ---------- Meta table ---------- */ +.meta-table { + display: grid; + grid-template-columns: 120px 1fr; + gap: 0; + width: 100%; +} +.meta-table__k { + font-family: var(--font-mono); font-size: 10.5px; + letter-spacing: 0.1em; color: var(--ink-3); text-transform: uppercase; + padding: 6px 0; + border-bottom: 0.5px solid var(--ink-5); +} +.meta-table__v { + font-family: var(--font-mono); font-size: 12px; + color: var(--ink); + padding: 6px 0; + font-variant-numeric: tabular-nums; + border-bottom: 0.5px solid var(--ink-5); + word-break: break-all; +} +.meta-table__k:last-of-type, .meta-table__v:last-of-type { border-bottom: 0; } + +/* ---------- Empty states ---------- */ +.empty { + display: flex; flex-direction: column; align-items: center; justify-content: center; + height: 100%; gap: 14px; padding: 48px; text-align: center; +} +.empty__mark { + width: 88px; height: 88px; border-radius: 999px; + border: 0.5px solid var(--contour-faint); + display: flex; align-items: center; justify-content: center; + font-family: var(--font-mono); font-size: 28px; color: var(--contour-2); + position: relative; +} +.empty__mark::after { + content: ""; position: absolute; inset: -8px; border-radius: 999px; + border: 0.5px dashed var(--contour-faint); +} +.empty__title { + font-family: var(--font-display); + font-size: 20px; color: var(--ink); +} +.empty__body { + font-family: var(--font-serif); font-size: 14px; color: var(--ink-3); + max-width: 360px; margin: 0; } +.empty__hint { + font-family: var(--font-mono); font-size: 10px; + color: var(--ink-4); letter-spacing: 0.18em; text-transform: uppercase; + margin-top: 12px; +} + +/* ---------- Notice ---------- */ .notice { - color: var(--text-muted); font-size: 13px; - padding: 10px 12px; - background: #faf6ee; border: 1px dashed var(--border); border-radius: 6px; + font-family: var(--font-serif); + font-size: 13px; + color: var(--ink-3); + padding: 12px 16px; + background: var(--paper-bright); + border: 0.5px dashed var(--contour-faint); + border-left: 2px solid var(--contour); } + +/* ---------- Spinner ---------- */ .spinner { display: inline-block; width: 10px; height: 10px; - border: 2px solid var(--accent-soft); - border-top-color: var(--accent); + border: 1.5px solid var(--contour-faint); + border-top-color: var(--contour-2); border-radius: 50%; animation: spin 0.9s linear infinite; vertical-align: middle; } @keyframes spin { to { transform: rotate(360deg); } } -.kbd { font-family: var(--mono); font-size: 11px; color: var(--text-muted); } -.banner { - padding: 8px 20px; - font-size: 13px; - font-family: var(--sans); +/* ---------- Helpers ---------- */ +.row { display: flex; gap: 10px; align-items: center; } +.stack > * + * { margin-top: 10px; } +.spacer { flex: 1 1 auto; } +.mono { font-family: var(--font-mono); } + +/* Preview canvas */ +.preview-canvas { + border: 0.5px solid var(--ink-5); + background: var(--paper-white); + position: relative; + overflow: hidden; } -.banner code { - font-family: var(--mono); - font-size: 12px; - background: rgba(0, 0, 0, 0.08); - padding: 1px 5px; - border-radius: 3px; +.preview-canvas svg { width: 100%; height: 100%; display: block; } + +/* Wizard split layout */ +.wizard { + display: grid; + grid-template-columns: 380px 1fr; + min-height: calc(100vh - 36px - 44px - 28px); +} +.wizard__left { + border-right: 0.5px solid var(--ink); + background: var(--paper-2); + display: grid; + grid-template-rows: auto auto auto 1fr; + min-height: 0; +} +.wizard__left-header { + padding: 16px 18px 12px; + border-bottom: 0.5px solid var(--ink-5); +} +.wizard__left-header h2 { + font-family: var(--font-display); + font-size: 17px; font-weight: 600; color: var(--ink); + margin: 2px 0 0; +} +.wizard__left-header .hint { + font-family: var(--font-mono); + font-size: 10.5px; color: var(--ink-3); + letter-spacing: 0.05em; margin-top: 4px; +} +.wizard__list { + overflow-y: auto; + min-height: 0; + background: var(--paper); +} +.wizard__right { + min-height: 0; + overflow-y: auto; + background: var(--paper); +} + +/* Preview header band */ +.preview-header { + padding: 18px 28px 14px; + border-bottom: 0.5px solid var(--ink-5); + display: grid; + grid-template-columns: 1fr auto; + align-items: end; + gap: 14px; +} +.preview-header__eyebrow { + font-family: var(--font-mono); font-size: 10px; + letter-spacing: 0.22em; text-transform: uppercase; color: var(--contour-2); +} +.preview-header__title { + font-family: var(--font-display); + font-size: 26px; font-weight: 600; color: var(--ink); + letter-spacing: -0.005em; line-height: 1.2; margin-top: 4px; +} +.preview-header__stats { + font-family: var(--font-mono); + font-size: 11px; color: var(--ink-3); + letter-spacing: 0.08em; margin-top: 6px; +} +.preview-header__actions { + display: flex; gap: 8px; align-items: center; +} + +.preview-body { + display: grid; + gap: 0; + padding: 18px 28px 28px; +} +.preview-body--split { + grid-template-columns: 340px 1fr; + padding: 0; +} +.preview-body--split > .preview-body__left { + padding: 18px 20px 18px 28px; + border-right: 0.5px solid var(--ink-5); +} +.preview-body--split > .preview-body__right { + padding: 18px 28px 28px 20px; +} + +/* Step inspector panel */ +.inspector { + background: var(--paper-bright); + border: 0.5px solid var(--ink-5); + padding: 14px; +} +.inspector pre { + font-family: var(--font-mono); + font-size: 11px; + background: var(--paper-2); + border: 0.5px solid var(--ink-5); + padding: 10px; + overflow: auto; + max-height: 220px; + white-space: pre-wrap; + word-break: break-word; + margin: 6px 0; +} +.inspector__empty { + color: var(--ink-3); font-size: 13px; + font-style: italic; +} + +/* Toolbar (visualizer toggles) */ +.toolbar { + display: flex; gap: 16px; align-items: center; + padding: 10px 14px; + background: var(--paper-2); + border: 0.5px solid var(--ink-5); + flex-wrap: wrap; + margin: 10px 0; +} +.toolbar label { + display: inline-flex; gap: 6px; align-items: center; + font-family: var(--font-mono); font-size: 11px; + color: var(--ink-3); letter-spacing: 0.06em; + text-transform: uppercase; + cursor: pointer; } -.banner--warn { - background: var(--accent); - color: white; +.checkbox { + width: 14px; height: 14px; + accent-color: var(--contour-2); + margin: 0; } -.banner--warn code { background: rgba(255, 255, 255, 0.18); } +/* Contour-texture backdrop */ +.backdrop { + position: fixed; inset: 0; + background-image: url('/contour-texture.svg'); + background-size: 600px 600px; + opacity: 0.06; + pointer-events: none; + z-index: 0; +} + +::selection { background: var(--accent-wash); color: var(--ink); } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +code { font-family: var(--font-mono); font-size: 0.92em; } + +/* Result / destination display */ +dl.dl-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 16px; + margin: 14px 0; +} +dl.dl-grid dt { + font-family: var(--font-mono); font-size: 10.5px; + color: var(--ink-3); letter-spacing: 0.1em; + text-transform: uppercase; +} +dl.dl-grid dd { + margin: 0; + font-family: var(--font-mono); font-size: 12px; + color: var(--ink); + word-break: break-all; +} + +/* Live-pulse animation (upload / tracking indicators) */ +@keyframes widgetPulse { + 0% { transform: scale(1); opacity: 0.6; } + 100% { transform: scale(2.2); opacity: 0; } +} +.pulse { + position: relative; display: inline-block; + width: 10px; height: 10px; +} +.pulse::before { + content: ""; position: absolute; inset: 0; + border-radius: 999px; background: var(--road); +} +.pulse::after { + content: ""; position: absolute; inset: -4px; + border-radius: 999px; border: 1px solid var(--road); + opacity: 0.4; + animation: widgetPulse 1.6s ease-out infinite; +} /* ─── Path graph (viz.ts) ──────────────────────────────────────────── */ .path-graph { position: relative;