diff --git a/app/changelog/page.tsx b/app/changelog/page.tsx index ce2380f..6524433 100644 --- a/app/changelog/page.tsx +++ b/app/changelog/page.tsx @@ -39,6 +39,21 @@ const REPO = "https://github.com/CommandOSSLabs/ai-devkit"; const SITE = "https://skills.commandoss.com"; const ENTRIES: ChangelogEntry[] = [ + { + date: "August 29, 2026", + shortDate: "Aug 29", + title: "A skills map that tells you what to do next", + items: [ + "The **Skills catalog** is category-first and much more compact — a card carries the handle, the title and what the skill is for, so choosing a workflow no longer means reading 34 descriptions.", + "Everything now says **uses** and **used by** instead of references and referenced by, on the map, in the list, on the detail page and in the catalog. A skill that uses another is the one pulling it in.", + "Before you pick anything the map offers a way in: **jump to a group**, or start from one of the skills nothing else depends on.", + "**Visualize interactions** is a pan-and-zoom canvas now. Move skills around, trace what each one references and what references it, and reset to the canonical layout whenever you want.", + "The map opens at a size you can actually read, and **`F` gives it the whole screen** — navigation, header and stats out of the way, inspector as an overlay. `Esc` comes back, `R` resets the layout.", + "Every relationship is still available as an accessible **List view**, which is also what smaller screens get by default.", + "**Skill detail, the workspace, Preview, Source, Edit and browser-local drafts** are all still there — this changed how you find a skill, not what you can do with it.", + `[$ gh pr view 24](${REPO}/pull/24)`, + ], + }, { date: "August 14, 2026", shortDate: "Aug 14", diff --git a/app/globals.css b/app/globals.css index 0628901..db45248 100644 --- a/app/globals.css +++ b/app/globals.css @@ -232,7 +232,20 @@ --skill-edge-in: #B4126B; } +/* The map is the one surface that has to sit BELOW the page rather than above + it. Everything else here is a panel lifted off the background; a canvas is a + space you look into, and the dot grid that says so only reads against + something close to black. Opaque on purpose: the animated wash behind the + shell was showing through the translucent panel, lifting the surface past + the dots and flattening the whole map. */ +.skills-shell { + --skill-canvas: #FFFFFF; + --skill-dot: #CBD5E1; +} + .dark .skills-shell { + --skill-canvas: #050608; + --skill-dot: #2F3742; --accent: #82AAFF; --text-tertiary: #7C8593; --text-disabled: #6E7684; @@ -273,3 +286,81 @@ background-color: var(--text-disabled); } } + +/* React Flow ships a light-mode chrome. These map its controls and minimap + onto the shell's own tokens so the canvas does not become the one panel on + the page with a different set of colours. */ +.skills-shell .react-flow__controls { + box-shadow: none; + border: 1px solid var(--border-subtle); + border-radius: 8px; + overflow: hidden; +} + +.skills-shell .react-flow__controls-button { + background: var(--bg-surface); + border-bottom: 1px solid var(--border-subtle); + color: var(--text-secondary); + width: 26px; + height: 26px; +} + +.skills-shell .react-flow__controls-button:hover { + background: var(--bg-elevated); + color: var(--text-primary); +} + +.skills-shell .react-flow__controls-button svg { + fill: currentColor; +} + +.skills-shell .react-flow__minimap { + border: 1px solid var(--border-subtle); + border-radius: 8px; +} + +/* Styled here rather than through the nodeColor prop: that prop is read once + into an SVG fill and does not follow a theme change, while a class does. */ +.skills-shell .react-flow__minimap-node { + fill: var(--skill-node); + stroke: none; +} + +.skills-shell .react-flow__attribution { + display: none; +} + +/* Canvas focus mode. The graph does not move in the tree and is never + unmounted: the shell's own chrome collapses out of its way from a single + attribute on , so the canvas grows into the padding, the rail and the + header instead of being re-created inside a fixed overlay. Unlayered, so it + beats the Tailwind utilities it is overriding regardless of specificity. */ +html[data-skills-focus] .skills-shell { + padding: 0; + gap: 0; +} + +html[data-skills-focus] .skills-shell > aside { + display: none; +} + +/* backdrop-filter on this column makes it the containing block for every + fixed descendant. Dropping it in focus mode is what lets the inspector + drawer position against the viewport, and the column covers the whole + screen by then anyway, so there is nothing left to blur. */ +html[data-skills-focus] .skills-column { + border: 0; + border-radius: 0; + backdrop-filter: none; + background: var(--skill-canvas); +} + +html[data-skills-focus] .skills-topbar, +html[data-skills-focus] .skills-page-head, +html[data-skills-focus] .skills-backdrop { + display: none; +} + +html[data-skills-focus] .skills-content-pad { + padding: 0; +} diff --git a/app/skills/layout.tsx b/app/skills/layout.tsx index 07a7182..bd2d348 100644 --- a/app/skills/layout.tsx +++ b/app/skills/layout.tsx @@ -27,8 +27,8 @@ export default function SkillsLayout({ -
-
+
+
@@ -60,7 +60,7 @@ export default function SkillsLayout({
-
+
{children}
diff --git a/app/skills/page.tsx b/app/skills/page.tsx index 3930875..f95a5f5 100644 --- a/app/skills/page.tsx +++ b/app/skills/page.tsx @@ -1,8 +1,7 @@ import type { Metadata } from "next"; -import { FileText, Layers } from "lucide-react"; +import { Layers } from "lucide-react"; import { getCatalogCategories, getSkillCatalog } from "@/lib/skill-catalog"; import { getRepoSnapshot } from "@/lib/repo-snapshot"; -import { REPO_SKILLS_TREE } from "@/lib/repo-links"; import { SkillCatalog } from "@/components/skills/skill-catalog"; import { RepoSnapshotChip } from "@/components/skills/repo-snapshot-chip"; import { GooeyTextReveal } from "@/components/motion/gooey-text-reveal"; @@ -22,7 +21,6 @@ export default function SkillsBrowsePage() { const skills = getSkillCatalog(); const categories = getCatalogCategories(skills); const snapshot = getRepoSnapshot(); - const fileCount = skills.reduce((n, s) => n + s.files.length, 0); const handles = Object.fromEntries(skills.map((s) => [s.id, s.handle])); return ( @@ -42,19 +40,9 @@ export default function SkillsBrowsePage() { delay={0.05} className="flex w-full max-w-[62ch] flex-col gap-1" > -

Browse skills

+

Skills

- Find the right skill for the task you are working on. Every skill in{" "} - - CommandOSSLabs/ai-devkit - - : what it does, when to reach for it, and what it works with. Open one to read it, or jump straight into its - files. + Find the right workflow, open its files, and adapt it to your repository.

@@ -64,10 +52,6 @@ export default function SkillsBrowsePage() {
-
-
diff --git a/app/skills/visualize-interactions/page.tsx b/app/skills/visualize-interactions/page.tsx index 0a4de63..bd3b8e8 100644 --- a/app/skills/visualize-interactions/page.tsx +++ b/app/skills/visualize-interactions/page.tsx @@ -1,57 +1,35 @@ import type { Metadata } from "next"; -import { Share2, Link2, CircleDot } from "lucide-react"; import { getSkillGraph } from "@/lib/skill-graph"; import { SkillGraphView } from "@/components/skills/skill-graph-view"; -import { BlurHighlight } from "@/components/ui/blur-highlight"; export const dynamic = "force-static"; export const metadata: Metadata = { - title: "Visualize interactions · AI DevKit Skills", + title: "How skills connect · AI DevKit Skills", description: - "How the skills in CommandOSSLabs/ai-devkit reference each other — a graph built from the cmk: handles in every SKILL.md.", + "Which skills to use before, after, or alongside the one you are working on, mapped from the cmk: handles in every SKILL.md.", }; -const metaChipClassName = - "flex h-9 items-center gap-1.5 rounded-lg border border-[var(--border-subtle)] bg-[var(--glass-elevated)] px-3 text-[12.5px] text-[var(--text-secondary)] backdrop-blur-sm"; - export default function VisualizeInteractionsPage() { const graph = getSkillGraph(); - const entryPoints = graph.nodes.filter((n) => n.inDegree === 0).length; return ( -
-
-
-

Visualize interactions

- - Skills reference each other by their cmk: handle, and the result is hub-and-spoke rather than a flat list. - -
- -
-
- - {graph.nodes.length} skills -
-
- - {graph.edges.length} references -
-
- - {entryPoints} entry points -
-
+
+ {/* Two lines and one row of counts. Three stat cards used to sit above + the map at the same weight as the controls, which is backwards on a + page whose subject is the map. The entry-point count left this line + entirely: it is something to act on, so it lives in the start panel + where it can be clicked, not in a statistic. */} +
+

+ How skills connect +

+

+ See which skills to use before, after, or alongside the one you are working on. +

+

+ {graph.nodes.length} skills · {graph.edges.length} connections +

diff --git a/components/marketing/roadmap/data.ts b/components/marketing/roadmap/data.ts index 23b093e..06b1b15 100644 --- a/components/marketing/roadmap/data.ts +++ b/components/marketing/roadmap/data.ts @@ -53,12 +53,12 @@ const REPO = "https://github.com/CommandOSSLabs/ai-devkit"; export const ROADMAP_ITEMS: RoadmapItem[] = [ { - id: "browse-skills-nav", + id: "skills-explorer-canvas", status: "now", category: "Website", - title: "Browse Skills nav + loading transition", - description: "A dedicated nav button to browse skills, with a TextMorph transition while the list loads.", - pr: `${REPO}/pull/15`, + title: "Minimal skills explorer + skill relationship map", + description: "A quieter category-first catalog and a map of which skills to use before, after or alongside each other, with a full-viewport focus mode for reading it.", + pr: `${REPO}/pull/24`, }, { id: "repo-meta-rate-limit", @@ -68,13 +68,6 @@ export const ROADMAP_ITEMS: RoadmapItem[] = [ description: "Stops hitting GitHub's rate limit on repo metadata calls and removes a dead WebGL scroll loop left running behind it.", pr: `${REPO}/pull/13`, }, - { - id: "skill-detail-editor", - status: "now", - category: "Website", - title: "Skill detail view, styled like an editor", - description: "A code-editor-styled view for browsing a skill's own details, instead of the current layout. No PR yet — still in draft.", - }, { id: "cmk-visualize", status: "next", @@ -83,6 +76,15 @@ export const ROADMAP_ITEMS: RoadmapItem[] = [ description: "Turns content you already have — a design doc, a tracker query, a raw description — into a rendered diagram, slide deck, or animated teaser, instead of a wall of markdown. One target shape: an isometric map of a repo's own infrastructure, dependencies and data paths traced from the real code, citing the files it read.", link: { label: "reference: isometric repo-map prompt", url: "https://x.com/JayScambler/status/2088356230968287547" }, }, + { + id: "skills-dashboard", + status: "shipped", + category: "Website", + shippedDate: "Aug 29", + title: "Skills dashboard and local workspace", + description: "Browse, inspect and locally edit all skills through catalog, detail and code-editor-style workspace views.", + ref: { label: "c29ea93", url: `${REPO}/commit/c29ea93` }, + }, { id: "cmk-interpret", status: "shipped", diff --git a/components/motion/gooey-text-reveal.tsx b/components/motion/gooey-text-reveal.tsx index 9898229..0726e0e 100644 --- a/components/motion/gooey-text-reveal.tsx +++ b/components/motion/gooey-text-reveal.tsx @@ -146,7 +146,13 @@ export const GooeyTextReveal = React.forwardRef the label + // is prohibited on the paragraph role, so assistive technology can + // drop it and then find nothing but hidden children — a paragraph + // that reads as empty. Splitting by line keeps whole words in the + // DOM, so the text is announced correctly with no aria at all. + aria: "none", }); split.lines.forEach((line) => { diff --git a/components/skills/eval-view.tsx b/components/skills/eval-view.tsx index e5dacbf..a564df4 100644 --- a/components/skills/eval-view.tsx +++ b/components/skills/eval-view.tsx @@ -34,7 +34,12 @@ export function EvalView({ cases }: { cases: EvalCase[] }) { const totalAssertions = cases.reduce((n, c) => n + (c.assertions?.length ?? 0), 0); return ( -
+
diff --git a/components/skills/file-content-pane.tsx b/components/skills/file-content-pane.tsx index 8158bcb..f21f940 100644 --- a/components/skills/file-content-pane.tsx +++ b/components/skills/file-content-pane.tsx @@ -201,73 +201,97 @@ export function FileContentPane({ {/* Real tab semantics rather than a row of buttons: assistive tech gets the selected state and the arrow-key model people already expect from an editor's tab strip. */} -
{ - const index = openFiles.findIndex((f) => f.id === activeId); - if (index === -1) return; - const move = (next: number) => { - e.preventDefault(); - onSelectTab(openFiles[(next + openFiles.length) % openFiles.length].id); - }; - if (e.key === "ArrowRight") move(index + 1); - else if (e.key === "ArrowLeft") move(index - 1); - else if (e.key === "Home") move(0); - else if (e.key === "End") move(openFiles.length - 1); - }} - className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--border-subtle)] px-2" - > - - {openFiles.map((f) => { - const on = f.id === activeId; - const name = basename(f.id); - const { Icon, color } = fileVisual(name); - const draft = hasDraft(f); - return ( - - - - {on && } - - ); - })} - +
+ {on && } + + ); + })} + +
{actionCluster}
diff --git a/components/skills/markdown-preview.tsx b/components/skills/markdown-preview.tsx index 51b28d4..0cb0451 100644 --- a/components/skills/markdown-preview.tsx +++ b/components/skills/markdown-preview.tsx @@ -414,7 +414,15 @@ export function MarkdownPreview({ )}
-
+ {/* Focusable on purpose: a skill whose rendered body carries no links + gives this scroller no focusable child, and a keyboard user then + has no way to scroll it at all. */} +
{showFrontmatter && frontmatter && } {sections.map((s) => { diff --git a/components/skills/skill-catalog.tsx b/components/skills/skill-catalog.tsx index fcbf6d2..de63bcd 100644 --- a/components/skills/skill-catalog.tsx +++ b/components/skills/skill-catalog.tsx @@ -2,10 +2,11 @@ import Link from "next/link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ArrowUpRight, Search, SlidersHorizontal, X } from "lucide-react"; +import { ArrowRight, FolderOpen, Search, SlidersHorizontal, X } from "lucide-react"; import type { SkillSummary } from "@/lib/skill-catalog"; import type { SkillCategoryInfo } from "@/lib/skill-types"; import { normalizeSkillId } from "@/lib/skill-id"; +import { useMediaQuery } from "@/lib/use-media-query"; import { readRecentSkills } from "@/lib/recent-skills"; import { SkillDetail, type SkillHandles } from "./skill-detail"; @@ -81,31 +82,6 @@ function scoreSkill(skill: SkillSummary, terms: string[]): number { return total; } -function useMediaQuery(query: string) { - // Three-valued on purpose: `null` means "not measured yet". A boolean - // default would make the first client render commit to a layout before - // matchMedia has reported the viewport, and the URL sync below would act on - // that guess and drop a deep link the user actually arrived with. - const [matches, setMatches] = useState(null); - useEffect(() => { - const mql = window.matchMedia(query); - const update = () => setMatches(mql.matches); - update(); - // Both signals, because a missed `change` leaves the layout committed to a - // viewport that no longer exists — observed here as the detail panel - // staying mounted and collapsing to 40px after a window resize. `resize` - // fires on every viewport change, and React drops the update when the - // boolean is unchanged, so the redundancy is nearly free. - mql.addEventListener("change", update); - window.addEventListener("resize", update); - return () => { - mql.removeEventListener("change", update); - window.removeEventListener("resize", update); - }; - }, [query]); - return matches; -} - function SkillCard({ skill, selected, @@ -120,20 +96,20 @@ function SkillCard({ const [firstTrigger, ...restTriggers] = skill.triggers; // min-w-0: a grid item defaults to min-width:auto, so the truncating trigger - // line (whitespace-nowrap) would otherwise set the column's minimum width to - // the full phrase and push the whole grid past a phone's viewport. + // line would otherwise set the column's minimum width to the full phrase and + // push the whole grid past a phone's viewport. return (
-
+
-

+

{/* A real link so it can be opened in a new tab and read by assistive tech, intercepted only where a preview panel exists to update instead. */} @@ -145,45 +121,55 @@ function SkillCard({ e.preventDefault(); onSelect(); }} - className="rounded-sm outline-none after:absolute after:inset-0 after:rounded-[14px] after:content-[''] focus-visible:underline focus-visible:decoration-2 focus-visible:underline-offset-4" + className="rounded-sm outline-none after:absolute after:inset-0 after:rounded-[12px] after:content-[''] focus-visible:underline focus-visible:decoration-2 focus-visible:underline-offset-4" > {skill.handle}

-

{skill.title}

+

{skill.title}

- + {skill.categoryLabel}
-

- {skill.summary || skill.description} -

+ {/* Handle, title, then what the skill is for — the same three facts in + the same order a node on the map gives, so the two surfaces read as + one product. The trigger phrase becomes the secondary line it always + was. */} + {skill.purpose && ( +

{skill.purpose}

+ )} -
- {/* One phrase, not the whole trigger list: the card is for recognising - a skill, and version, file and reference counts moved to the detail - surface where someone is actually comparing them. */} - +
+ {firstTrigger ? ( <> - Use when + Ask with “{firstTrigger}” {restTriggers.length > 0 && ( - +{restTriggers.length} + +{restTriggers.length} )} - ) : null} + ) : ( + No trigger phrase + )} - Open workspace - +
); @@ -208,6 +194,7 @@ export function SkillCatalog({ const [selectedId, setSelectedId] = useState(null); const [recent, setRecent] = useState([]); const [initialised, setInitialised] = useState(false); + const [filtersOpen, setFiltersOpen] = useState(false); const inputRef = useRef(null); const initialDeepLinkSkill = useRef(null); @@ -330,6 +317,33 @@ export function SkillCatalog({ const filtered = query.trim() !== "" || category !== ALL || flags.length > 0; + // Default view only: once someone searches, filters by category or sorts, + // grouping by category would fight the ordering they asked for. + const grouped = useMemo(() => { + if (query.trim() !== "" || category !== ALL || sort !== "name") return null; + const groups = new Map(); + for (const skill of results) { + const list = groups.get(skill.categoryLabel) ?? []; + list.push(skill); + groups.set(skill.categoryLabel, list); + } + return Array.from(groups.entries()).sort(([a], [b]) => a.localeCompare(b)); + }, [results, query, category, sort]); + + const activeFilters = [ + ...(category !== ALL + ? [{ key: `category:${category}`, label: categories.find((c) => c.id === category)?.label ?? category, clear: () => setCategory(ALL) }] + : []), + ...flags.map((flag) => ({ + key: `flag:${flag}`, + label: FLAGS.find((f) => f.value === flag)?.label ?? flag, + clear: () => toggleFlag(flag), + })), + ...(sort !== "name" + ? [{ key: "sort", label: SORTS.find((o) => o.value === sort)?.label ?? sort, clear: () => setSort("name") }] + : []), + ]; + const controls = (
@@ -370,26 +384,53 @@ export function SkillCatalog({ )}
-
+ {filtersOpen && ( +
+ +
+ )} +
setCategory(ALL)}> All {skills.length} @@ -399,13 +440,24 @@ export function SkillCatalog({ {c.label} {c.count} ))} -
+ + {activeFilters.length > 0 && ( +
+ {activeFilters.map((filter) => ( + + ))} +
+ )}
); @@ -429,8 +481,12 @@ export function SkillCatalog({
) : ( -
- {results.map((skill) => ( + (() => { + // Two columns wherever the inspector is absent. A third column has nowhere + // to live: the inspector takes over at 1440, so the only band wide enough + // for three is the one the split already owns. + const gridClass = splitView ? "flex flex-col gap-2.5" : "grid gap-2.5 sm:grid-cols-2"; + const card = (skill: SkillSummary) => ( setSelectedId(skill.id)} /> - ))} -
+ ); + + // Category headings are the default organisation: at 34 skills across + // nine groups, category is the first useful cut, and a flat + // alphabetical wall makes the reader do that grouping themselves. + if (grouped) { + return ( +
+ {grouped.map(([label, group]) => ( +
+

+ {label} + [{group.length}] +

+
{group.map(card)}
+
+ ))} +
+ ); + } + + return
{results.map(card)}
; + })() ); return ( diff --git a/components/skills/skill-detail.tsx b/components/skills/skill-detail.tsx index 6f0238f..9e629a0 100644 --- a/components/skills/skill-detail.tsx +++ b/components/skills/skill-detail.tsx @@ -200,17 +200,19 @@ export function SkillDetail({ className="grid gap-5 rounded-[14px] border border-[var(--border-subtle)] bg-[var(--glass-surface)] p-4 sm:grid-cols-2" aria-label="Related skills" > + {/* Same two words the map and the browse list use, so a reader who + learned the direction on one surface does not relearn it here. */} diff --git a/components/skills/skill-graph-canvas.tsx b/components/skills/skill-graph-canvas.tsx new file mode 100644 index 0000000..d476986 --- /dev/null +++ b/components/skills/skill-graph-canvas.tsx @@ -0,0 +1,516 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + Background, + BackgroundVariant, + Handle, + MiniMap, + Position, + ReactFlow, + useStore, + type Edge, + type Node, + type NodeProps, + type NodeTypes, + type ReactFlowInstance, +} from "@xyflow/react"; +import "@xyflow/react/dist/style.css"; +import type { SkillEdge } from "@/lib/skill-graph"; +import { + SKILL_NODE_SIZE, + type PositionedSkillNode, + type SkillGraphLane, + type SkillGraphPosition, +} from "@/lib/skill-graph-layout"; + +// The React Flow boundary. It owns the viewport — pan, zoom, drag, minimap — +// and nothing else: selection, the URL, the inspector and persistence stay in +// SkillGraphView, so replacing this renderer later touches one file. + +/** Imperative viewport controls, handed to the toolbar that lives outside. */ +export type SkillCanvasApi = { + fitAll: () => void; + zoomIn: () => void; + zoomOut: () => void; + /** bring a node into view, raising the zoom to a readable level if needed */ + reveal: (id: string, force?: boolean) => void; + /** frame one category's lane, for readers who would rather start with a group */ + revealLane: (category: string) => void; +}; + +/** Below this the metadata row is dropped: it would render sub-8px. */ +const META_ZOOM = 0.62; +/** The band a selected node is pulled into. Anything inside it stays put. */ +const READABLE_MIN = 1; +const READABLE_MAX = 1.2; +const MIN_ZOOM = 0.22; +const MAX_ZOOM = 1.8; + +type SkillNodeData = { + label: string; + title: string; + purpose: string; + categoryLabel: string; + outDegree: number; + inDegree: number; + selected: boolean; + /** hovered but not pinned — a lighter marker than the pin's */ + traced: boolean; + related: "out" | "in" | null; + dimmed: boolean; + onSelect: (id: string) => void; +}; + +type LaneNodeData = { label: string; count: number; width: number }; + +type SkillFlowNode = Node | Node; +type SkillCardNode = Node; + +function LaneHeading({ data }: NodeProps>) { + return ( +
+ {data.label} + {/* Tertiary, not disabled: the count is information, and on the light + palette --text-disabled measures 2.54:1 against the canvas. */} + [{data.count}] +
+ ); +} + +function SkillFlowNodeCard({ id, data }: NodeProps) { + const { label, title, purpose, categoryLabel, outDegree, inDegree, selected, traced, related, dimmed } = + data; + // A boolean selector, so the store only re-renders these cards on the two + // zoom steps where the answer actually flips. + const showMeta = useStore((s) => s.transform[2] >= META_ZOOM); + + return ( +
data.onSelect(id)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + data.onSelect(id); + } + }} + style={{ + width: SKILL_NODE_SIZE.width, + height: SKILL_NODE_SIZE.height, + // Inline, not a Tailwind arbitrary value: a color-mix() with commas + // inside shadow-[...] does not survive the class parser, and it failed + // silently — the pin lost its halo and became indistinguishable from a + // hover, which is the one distinction this component has to keep. + boxShadow: selected + ? "0 0 0 4px color-mix(in srgb, var(--skill-node-active) 24%, transparent)" + : undefined, + }} + className={`flex cursor-pointer flex-col justify-center gap-0.5 rounded-[10px] border px-3.5 text-left outline-none transition-opacity focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--skill-node-active)] ${ + selected + ? "border-[var(--skill-node-active)] bg-[var(--bg-surface)]" + : traced + ? "border-[var(--skill-node-active)] bg-[var(--bg-surface)]" + : related === "out" + ? "border-[var(--skill-edge-out)] bg-[var(--bg-surface)]" + : related === "in" + ? "border-[var(--skill-edge-in)] bg-[var(--bg-surface)]" + : "border-[var(--border-subtle)] bg-[var(--bg-surface)]" + } ${dimmed ? "opacity-30" : "opacity-100"}`} + > + {/* Edges need anchors to attach to. They carry no affordance of their + own: connecting is disabled, so these exist purely as geometry. */} + + + {/* Handle, human name, and what it is for. The degree counts left the + card: they answer "how connected is this" to a reader who has not + yet been told what it does, and the panel says them in words. */} + + {label} + + {showMeta && ( + <> + + {title} + + + {purpose} + + + )} +
+ ); +} + +const NODE_TYPES: NodeTypes = { skill: SkillFlowNodeCard, lane: LaneHeading }; + +export function SkillGraphCanvas({ + nodes, + edges, + lanes, + laneWidth, + graphHeight, + selectedId, + hoveredId, + initialLane, + positions, + showMiniMap, + miniMapSide = "left", + rightInset = 0, + reduceMotion, + onSelect, + onHover, + onPositionsChange, + onReady, +}: { + nodes: PositionedSkillNode[]; + edges: SkillEdge[]; + lanes: SkillGraphLane[]; + laneWidth: number; + graphHeight: number; + selectedId: string | null; + hoveredId: string | null; + /** a group the reader asked for before this canvas existed */ + initialLane?: string | null; + /** dragged overrides on top of the canonical layout */ + positions: Record; + showMiniMap: boolean; + /** the corner nothing else is using: the rail sits right, the focus drawer does not */ + miniMapSide?: "left" | "right"; + /** width of anything floating over the canvas's right edge, so centring + * means centred in what the reader can actually see */ + rightInset?: number; + reduceMotion: boolean; + onSelect: (id: string) => void; + onHover: (id: string | null) => void; + onPositionsChange: (next: Record) => void; + onReady?: (api: SkillCanvasApi | null) => void; +}) { + const instance = useRef | null>(null); + const wrapper = useRef(null); + // Read at mount only. Re-centring on every selection change would fight the + // user's own panning; the reveal rule below decides when to move instead. + const initialSelection = useRef(selectedId); + const initialLaneRef = useRef(initialLane); + const rightInsetRef = useRef(rightInset); + rightInsetRef.current = rightInset; + const framed = useRef(false); + + // Hover traces a different skill without taking the pin away: the trace + // follows the pointer, the pinned marker does not move. + const tracedId = hoveredId ?? selectedId; + + const placed = useMemo(() => { + const map = new Map(); + for (const node of nodes) map.set(node.id, positions[node.id] ?? node.position); + return map; + }, [nodes, positions]); + const placedRef = useRef(placed); + placedRef.current = placed; + + const relation = useMemo(() => { + if (!tracedId) return null; + const out = new Set(edges.filter((e) => e.source === tracedId).map((e) => e.target)); + const inc = new Set(edges.filter((e) => e.target === tracedId).map((e) => e.source)); + return { out, inc }; + }, [edges, tracedId]); + + /** + * The one placement helper. Everything is computed from the container's own + * box rather than from React Flow's measured size, because measurement + * rides on ResizeObserver and a frame has to land exactly whether or not + * that has been delivered — and then clamped to the map, since centring on + * a node in the first row would otherwise open with a third of the canvas + * showing nothing above the graph. + */ + const clamped = useCallback( + (x: number, y: number, zoom: number, box: DOMRect) => { + const spanX = laneWidth * zoom; + const spanY = graphHeight * zoom; + const minX = Math.min(32, box.width - rightInset - spanX - 32); + const minY = Math.min(32, box.height - spanY - 32); + return { + x: spanX <= box.width - rightInset ? x : Math.min(32, Math.max(minX, x)), + y: spanY <= box.height ? y : Math.min(32, Math.max(minY, y)), + }; + }, + [graphHeight, laneWidth, rightInset], + ); + + /** The opening frame, anchored to the map's top-left corner rather than + * centred on it: centring a grid wider than the panel clips the first + * column, and the first thing anyone reads should be a whole card. */ + const anchorHome = useCallback(() => { + const inst = instance.current; + if (!inst) return; + inst.setViewport({ x: 32, y: 32, zoom: 1 }, { duration: 0 }); + }, []); + + const api = useMemo( + () => ({ + fitAll: () => { + const box = wrapper.current?.getBoundingClientRect(); + if (!box || box.width === 0) return; + const zoom = Math.max( + MIN_ZOOM, + Math.min( + 1, + (box.width - rightInset - 72) / laneWidth, + (box.height - 72) / Math.max(1, graphHeight), + ), + ); + instance.current?.setViewport( + { + x: (box.width - rightInset) / 2 - (laneWidth / 2) * zoom, + y: box.height / 2 - (graphHeight / 2) * zoom, + zoom, + }, + { duration: reduceMotion ? 0 : 280 }, + ); + }, + zoomIn: () => instance.current?.zoomIn({ duration: reduceMotion ? 0 : 160 }), + zoomOut: () => instance.current?.zoomOut({ duration: reduceMotion ? 0 : 160 }), + reveal: (id, force) => { + const inst = instance.current; + const box = wrapper.current?.getBoundingClientRect(); + const at = placedRef.current.get(id); + if (!inst || !box || !at || box.width === 0) return; + + const cx = at.x + SKILL_NODE_SIZE.width / 2; + const cy = at.y + SKILL_NODE_SIZE.height / 2; + const vp = inst.getViewport(); + const nextZoom = Math.min(READABLE_MAX, Math.max(vp.zoom, READABLE_MIN)); + + // Already legible and comfortably inside the frame: leave the viewport + // where the reader put it. Yanking the canvas on every click is the + // thing that makes a graph feel like it is fighting you. + const sx = cx * vp.zoom + vp.x; + const sy = cy * vp.zoom + vp.y; + const insetX = box.width * 0.16; + const insetY = box.height * 0.16; + const inFrame = + sx > insetX && sx < box.width - insetX && sy > insetY && sy < box.height - insetY; + if (!force && inFrame && nextZoom === vp.zoom) return; + + const frame = clamped( + (box.width - rightInset) / 2 - cx * nextZoom, + box.height / 2 - cy * nextZoom, + nextZoom, + box, + ); + inst.setViewport({ ...frame, zoom: nextZoom }, { duration: reduceMotion ? 0 : 280 }); + }, + revealLane: (category) => { + const lane = lanes.find((l) => l.category === category); + const box = wrapper.current?.getBoundingClientRect(); + if (!lane || !box || box.width === 0) return; + // Frame the lane's own height rather than the whole map, and never + // below a legible zoom: the point of jumping to a group is to arrive + // somewhere you can read. + const zoom = Math.max( + 0.7, + Math.min( + 1, + (box.width - rightInset - 72) / laneWidth, + (box.height - 72) / Math.max(1, lane.height), + ), + ); + const at = clamped( + (box.width - rightInset) / 2 - (laneWidth / 2) * zoom, + box.height / 2 - (lane.y + lane.height / 2) * zoom, + zoom, + box, + ); + instance.current?.setViewport({ ...at, zoom }, { duration: reduceMotion ? 0 : 280 }); + }, + }), + [clamped, graphHeight, lanes, laneWidth, reduceMotion, rightInset], + ); + + const apiRef = useRef(api); + apiRef.current = api; + + useEffect(() => { + onReady?.(api); + return () => onReady?.(null); + }, [api, onReady]); + + const flowNodes = useMemo(() => { + // Lane headings are nodes rather than an overlay, so the category + // structure pans and zooms with the map instead of floating over it. + const laneNodes: SkillFlowNode[] = lanes.map((lane) => ({ + id: `lane:${lane.category}`, + type: "lane" as const, + position: { x: 0, y: lane.y }, + // Dimensions are declared rather than measured. React Flow keeps a node + // `visibility: hidden` until it has measured it, and measurement rides on + // ResizeObserver delivery — under a throttled rendering loop that never + // arrives and the whole map stays invisible. The layout already knows + // every size, so it says so. + width: laneWidth, + height: 26, + data: { label: lane.label, count: lane.count, width: laneWidth }, + draggable: false, + selectable: false, + focusable: false, + deletable: false, + })); + + const skillNodes: SkillFlowNode[] = nodes.map((node) => { + const isSelected = node.id === selectedId; + const isTraced = node.id === tracedId; + const related: "out" | "in" | null = relation?.out.has(node.id) + ? "out" + : relation?.inc.has(node.id) + ? "in" + : null; + return { + id: node.id, + type: "skill" as const, + position: placed.get(node.id) ?? node.position, + width: SKILL_NODE_SIZE.width, + height: SKILL_NODE_SIZE.height, + data: { + label: node.label, + title: node.title, + purpose: node.purpose, + categoryLabel: node.categoryLabel, + outDegree: node.outDegree, + inDegree: node.inDegree, + selected: isSelected, + traced: isTraced && !isSelected, + related, + dimmed: Boolean(tracedId) && !isTraced && !isSelected && related === null, + onSelect, + }, + }; + }); + + return [...laneNodes, ...skillNodes]; + }, [nodes, lanes, laneWidth, placed, relation, selectedId, tracedId, onSelect]); + + const flowEdges = useMemo( + () => + edges.map((edge) => { + const isOut = tracedId !== null && edge.source === tracedId; + const isIn = tracedId !== null && edge.target === tracedId; + const touched = isOut || isIn; + return { + id: `${edge.source}->${edge.target}`, + source: edge.source, + target: edge.target, + type: "smoothstep", + // Edges are read-only: they are repository references, not something + // this UI lets anyone draw. + selectable: false, + focusable: false, + animated: touched && !reduceMotion, + style: { + stroke: isOut + ? "var(--skill-edge-out)" + : isIn + ? "var(--skill-edge-in)" + : "var(--skill-edge)", + strokeWidth: touched ? 1.8 : 0.8, + opacity: tracedId ? (touched ? 0.9 : 0.06) : 0.28, + }, + }; + }), + [edges, tracedId, reduceMotion], + ); + + const handleDragStop = useCallback( + (_: unknown, node: SkillFlowNode) => { + if (node.type !== "skill") return; + onPositionsChange({ ...positions, [node.id]: { x: node.position.x, y: node.position.y } }); + }, + [onPositionsChange, positions], + ); + + return ( +
+ + nodes={flowNodes} + edges={flowEdges} + nodeTypes={NODE_TYPES} + onInit={(i) => { + instance.current = i; + if (framed.current) return; + framed.current = true; + // The map opens readable, not fitted. Fitting eight lanes into a + // panel drove every card to roughly a centimetre of unreadable + // colour; the frame now starts where the reader is going to look — + // the deep-linked skill, or the first lane — and "Fit all" is a + // deliberate action rather than the default. + // A group asked for from the browse list arrives before this + // renderer exists, so it is honoured here rather than through the + // imperative call, which would land on an instance that is not yet + // holding a viewport. + if (initialLaneRef.current) { + apiRef.current?.revealLane(initialLaneRef.current); + return; + } + const start = initialSelection.current + ? placedRef.current.get(initialSelection.current) + : undefined; + if (start) { + // Clamped like every other framing move: a deep link to a skill in + // the first row would otherwise open with a third of the canvas + // showing nothing above the map. + const box = wrapper.current?.getBoundingClientRect(); + if (box && box.width > 0) { + const frame = clamped( + (box.width - rightInsetRef.current) / 2 - (start.x + SKILL_NODE_SIZE.width / 2) * 1.1, + box.height / 2 - (start.y + SKILL_NODE_SIZE.height / 2) * 1.1, + 1.1, + box, + ); + i.setViewport({ ...frame, zoom: 1.1 }, { duration: 0 }); + } + return; + } + anchorHome(); + }} + onNodeDragStop={handleDragStop} + onNodeMouseEnter={(_, node) => { + if (node.type === "skill") onHover(node.id); + }} + onNodeMouseLeave={() => onHover(null)} + onPaneClick={() => onSelect("")} + /* One focus stop per node, ours, so the accessible name and the + Enter/Space handling are the card's rather than the wrapper's. */ + nodesFocusable={false} + edgesFocusable={false} + nodesConnectable={false} + elementsSelectable={false} + proOptions={{ hideAttribution: true }} + minZoom={MIN_ZOOM} + maxZoom={MAX_ZOOM} + > + + {showMiniMap && ( + + )} + +
+ ); +} + +export default SkillGraphCanvas; diff --git a/components/skills/skill-graph-view.tsx b/components/skills/skill-graph-view.tsx index bd62f5a..0a330f3 100644 --- a/components/skills/skill-graph-view.tsx +++ b/components/skills/skill-graph-view.tsx @@ -1,368 +1,852 @@ "use client"; +import dynamic from "next/dynamic"; import Link from "next/link"; -import { useEffect, useMemo, useState } from "react"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { ArrowDownLeft, ArrowUpRight, ExternalLink, List, Share2, X } from "lucide-react"; -import type { SkillGraph } from "@/lib/skill-graph"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useReducedMotion } from "motion/react"; +import { + ArrowDownLeft, + ArrowUpRight, + HelpCircle, + List, + Maximize2, + Minimize2, + Minus, + Plus, + RotateCcw, + Scan, + Share2, + X, +} from "lucide-react"; +import type { SkillGraph, SkillNode } from "@/lib/skill-graph"; +import { + clearStoredLayout, + layoutSkillGraph, + readStoredLayout, + writeStoredLayout, + type SkillGraphPosition, +} from "@/lib/skill-graph-layout"; import { normalizeSkillId } from "@/lib/skill-id"; +import { useMediaQuery } from "@/lib/use-media-query"; +import type { SkillCanvasApi } from "./skill-graph-canvas"; -// A circular (chord) layout rather than a force simulation: with 34 nodes and -// 108 edges a physics layout settles differently on every load, which makes -// the picture impossible to refer back to. Fixed positions mean "the hub at -// the top right" stays the hub at the top right, and it needs no animation -// frame budget next to the fluid background already running on this page. - -const SIZE = 900; -const C = SIZE / 2; -const R = 310; -// Read from the shell's tokens so the graph carries meaning in both themes; -// see the --skill-* block in globals.css. -const OUT_COLOR = "var(--skill-edge-out)"; -const IN_COLOR = "var(--skill-edge-in)"; -const NODE_COLOR = "var(--skill-node)"; -const NODE_ACTIVE = "var(--skill-node-active)"; -const EDGE_COLOR = "var(--skill-edge)"; - -type Pt = { x: number; y: number; angle: number }; - -// Coordinates are rounded before they reach the DOM. Full-precision floats -// serialize differently on the server and the client (…4313 vs …43124), which -// React reports as a hydration mismatch on every label in the ring. -const r2 = (n: number) => Math.round(n * 100) / 100; +// Three things live here and nowhere else: which view is showing, which skill +// is selected, and what the URL says about both. The canvas is a renderer it +// mounts; the inspector is rendered by this component directly, so its content +// never waits on the canvas or on an animation finishing — the previous +// version could leave the panel blank behind an exit transition that never +// completed. + +const SkillGraphCanvas = dynamic( + () => import("./skill-graph-canvas").then((m) => m.SkillGraphCanvas), + { + ssr: false, + loading: () => ( +
+ Loading the map… +
+ ), + }, +); + +export type SkillGraphViewMode = "canvas" | "list"; + +const CANVAS_MIN_WIDTH = "(min-width: 768px)"; +/** Where a docked inspector still leaves the canvas about three quarters of + * the panel. Below it the inspector is a sheet over the map instead. */ +const DOCKED_INSPECTOR = "(min-width: 1280px)"; +const HINT_KEY = "ai-devkit-skill-graph-hint"; + +function toggleClass(on: boolean) { + return `inline-flex h-7 items-center gap-1.5 rounded-[6px] px-2.5 text-[12px] transition-colors ${ + on + ? "bg-[var(--bg-elevated)] font-medium text-[var(--text-primary)]" + : "text-[var(--text-tertiary)] hover:text-[var(--text-primary)]" + }`; +} + +const actionClass = + "inline-flex h-8 items-center gap-1.5 rounded-lg border border-[var(--border-subtle)] bg-[var(--glass-elevated)] px-2.5 text-[12px] text-[var(--text-secondary)] transition-colors hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]"; + +const iconButtonClass = + "inline-flex h-8 w-8 items-center justify-center rounded-lg border border-[var(--border-subtle)] bg-[var(--glass-elevated)] text-[var(--text-secondary)] transition-colors hover:border-[var(--border-strong)] hover:text-[var(--text-primary)]"; + +function RelationGroup({ + title, + icon, + color, + note, + ids, + byId, + onPick, +}: { + title: string; + icon?: React.ReactNode; + color?: string; + note: string; + ids: string[]; + byId: Map; + onPick: (id: string) => void; +}) { + //
rather than a hand-rolled disclosure: the open/closed state, the + // keyboard handling and the announced role all come for free, and this panel + // has to stay light enough that the map keeps the attention. + return ( +
+ + + {icon && {icon}} + {title} + + {note} + +
+ {ids.length === 0 ? ( +

none

+ ) : ( +
+ {ids.map((id) => ( + + ))} +
+ )} +
+
+ ); +} + +/** + * What the panel says before anything is selected. The old copy described the + * gestures — hover to trace, click to pin — which tells a reader how to + * operate a thing they have not been given a reason to operate. This one + * hands them two ways in: a group, or a skill nothing else depends on. + */ +function StartPanel({ + groups, + startingPoints, + onJumpToGroup, + onPickSkill, +}: { + groups: { category: string; label: string; count: number }[]; + startingPoints: SkillNode[]; + onJumpToGroup: (category: string) => void; + onPickSkill: (id: string) => void; +}) { + return ( +
+
+

Start with a skill

+

+ Select any skill to see what it uses and what uses it. +

+
+ +
+

+ Not sure where to begin? Jump to a group. +

+
+ {groups.map((group) => ( + + ))} +
+
+ + {startingPoints.length > 0 && ( +
+

+ Starting points +

+

+ Nothing else pulls these in, so they are where a chain begins. +

+
+ {startingPoints.map((node) => ( + + ))} +
+
+ )} +
+ ); +} export function SkillGraphView({ graph }: { graph: SkillGraph }) { - const reduce = useReducedMotion(); - const [hovered, setHovered] = useState(null); - const [pinned, setPinned] = useState(null); - const [view, setView] = useState<"graph" | "list">("graph"); - const [focused, setFocused] = useState(null); - // Hover traces a different skill without taking the pin away: `active` - // drives the trace, `pinned` keeps its own marker regardless. Losing the - // selection the moment the cursor moved made the pin useless. - const active = hovered ?? pinned; - - // A skill page links here as …?focus=delivery-review, so "see what this - // connects to" lands on that node already traced instead of on 34 - // undifferentiated dots. Read after mount to keep the page static. + const reduce = useReducedMotion() ?? false; + const wideEnoughForCanvas = useMediaQuery(CANVAS_MIN_WIDTH); + const canDockInspector = useMediaQuery(DOCKED_INSPECTOR) === true; + + const [selectedId, setSelectedId] = useState(null); + const [hoveredId, setHoveredId] = useState(null); + const [requestedView, setRequestedView] = useState(null); + const [positions, setPositions] = useState>({}); + const [initialised, setInitialised] = useState(false); + const [focus, setFocus] = useState(false); + const [api, setApi] = useState(null); + const [hintOpen, setHintOpen] = useState(false); + const [pendingLane, setPendingLane] = useState(null); + const deepLinkRead = useRef(false); + const drawerRef = useRef(null); + + const layout = useMemo(() => layoutSkillGraph(graph.nodes), [graph.nodes]); + const byId = useMemo(() => new Map(graph.nodes.map((n) => [n.id, n])), [graph.nodes]); + + // Read once, ever: the sync effect below rewrites the query string, and a + // second read would pick up what it just wrote. useEffect(() => { + if (deepLinkRead.current) return; + deepLinkRead.current = true; + const params = new URLSearchParams(window.location.search); - const requested = normalizeSkillId(params.get("skill") ?? params.get("focus")); - if (requested && graph.nodes.some((n) => n.id === requested)) setPinned(requested); + const skill = normalizeSkillId(params.get("skill")); + if (skill && graph.nodes.some((n) => n.id === skill)) setSelectedId(skill); + + const view = params.get("view"); + if (view === "canvas" || view === "list") setRequestedView(view); + if (params.get("focus") === "canvas") setFocus(true); + + const stored = readStoredLayout(); + if (stored) setPositions(stored); + + try { + if (!sessionStorage.getItem(HINT_KEY)) setHintOpen(true); + } catch { + // storage unavailable: the hint just does not auto-open + } + setInitialised(true); }, [graph.nodes]); - const { points, byId } = useMemo(() => { - const points = new Map(); - const n = graph.nodes.length || 1; - graph.nodes.forEach((node, i) => { - // start at 12 o'clock so the ordering reads clockwise from the top - const angle = (i / n) * Math.PI * 2 - Math.PI / 2; - points.set(node.id, { x: r2(C + Math.cos(angle) * R), y: r2(C + Math.sin(angle) * R), angle }); - }); - return { points, byId: new Map(graph.nodes.map((nd) => [nd.id, nd])) }; - }, [graph]); - - const related = useMemo(() => { - if (!active) return null; - const out = graph.edges.filter((e) => e.source === active).map((e) => e.target); - const inc = graph.edges.filter((e) => e.target === active).map((e) => e.source); - return { out, inc, touching: new Set([...out, ...inc, active]) }; - }, [active, graph.edges]); - - const activeNode = active ? byId.get(active) : null; - const maxDeg = Math.max(...graph.nodes.map((n) => n.inDegree + n.outDegree), 1); + // Canvas where there is room for one, list where there is not, and an + // explicit ?view= wins on any viewport. + const view: SkillGraphViewMode = + requestedView ?? (wideEnoughForCanvas === false ? "list" : "canvas"); - return ( -
-
-
- + useEffect(() => { + if (!initialised || wideEnoughForCanvas === null) return; + const params = new URLSearchParams(window.location.search); + if (selectedId) params.set("skill", selectedId); + else params.delete("skill"); + if (requestedView) params.set("view", requestedView); + else params.delete("view"); + if (focus) params.set("focus", "canvas"); + else params.delete("focus"); + const search = params.toString(); + window.history.replaceState(null, "", search ? `?${search}` : window.location.pathname); + }, [initialised, selectedId, requestedView, focus, wideEnoughForCanvas]); + + // Focus mode is application-level, not the browser's Fullscreen API: it + // collapses the shell's own chrome from a single attribute on , so the + // canvas grows into the space its ancestors were using without being + // unmounted and re-created in a new part of the tree. One canvas, one + // viewport, no remount on the way in or out. + useEffect(() => { + const root = document.documentElement; + if (focus) root.setAttribute("data-skills-focus", ""); + else root.removeAttribute("data-skills-focus"); + return () => root.removeAttribute("data-skills-focus"); + }, [focus]); + + const select = useCallback((id: string) => { + setSelectedId((current) => (id === "" || current === id ? null : id)); + }, []); + + const handlePositions = useCallback((next: Record) => { + setPositions(next); + writeStoredLayout(next); + }, []); + + const resetLayout = useCallback(() => { + clearStoredLayout(); + setPositions({}); + }, []); + + const handleReady = useCallback((next: SkillCanvasApi | null) => setApi(next), []); + + const dismissHint = useCallback(() => { + setHintOpen(false); + try { + sessionStorage.setItem(HINT_KEY, "seen"); + } catch { + // nothing to remember it with + } + }, []); + + // Keyboard: F toggles focus, R resets the layout, Escape peels one layer at + // a time — the open panel first, then focus mode itself. + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable) + ) { + return; + } + + if (e.key === "Escape") { + if (hintOpen) dismissHint(); + else if (selectedId) setSelectedId(null); + else if (focus) setFocus(false); + return; + } + + const key = e.key.toLowerCase(); + if (key === "f" && wideEnoughForCanvas) { + e.preventDefault(); + setFocus((on) => !on); + } else if (key === "r" && view === "canvas") { + e.preventDefault(); + resetLayout(); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [dismissHint, focus, hintOpen, resetLayout, selectedId, view, wideEnoughForCanvas]); + + // Bring the selection into view without yanking the canvas: the renderer + // only moves when the node is off-frame or too small to read. + useEffect(() => { + if (!api || !selectedId || view !== "canvas") return; + api.reveal(selectedId); + }, [api, selectedId, view]); + + // The docked rail exists only while something is selected. An empty 300px + // panel reading "pick a skill" was taking a third of the canvas at 1280 to + // say what the page's own subtitle already says, which is the opposite of + // canvas-first. + // The rail is always there at 1280 and up outside focus mode. It used to + // appear only on selection, which meant the one moment a reader most needs + // a way in — before they have clicked anything — was the moment the page + // offered them nothing but dots. + const dockInspector = canDockInspector && !focus; + + // Wherever the inspector floats over the map rather than sitting beside it, + // it gets the workspace drawer's treatment: focus moves in and Tab stays + // inside while it is open. Escape is handled by the global handler above. + const overlayInspector = selectedId !== null && !dockInspector; + /** Where the rail cannot fit, the way in is a strip of chips over the map. */ + const showStartStrip = selectedId === null && !dockInspector; + useEffect(() => { + if (!overlayInspector) return; + const panel = drawerRef.current; + if (!panel) return; + + // Where focus came from, so closing the panel puts it back on the node + // rather than dropping it at the top of the document. + const returnTo = document.activeElement as HTMLElement | null; + panel.querySelector("button, a")?.focus(); + + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Tab") return; + const focusable = panel.querySelectorAll( + 'a[href], button:not([disabled]), summary', + ); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + if (e.shiftKey && (active === first || !panel.contains(active))) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + }; + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("keydown", onKey); + if (returnTo && returnTo.isConnected && document.body.contains(returnTo)) returnTo.focus(); + }; + }, [overlayInspector, selectedId]); + + // Skills nothing else pulls in, and that lead somewhere: the honest answer + // to "where do I start". A skill with no inbound AND no outbound edges is + // not a starting point, it is an orphan, and sending a reader there is the + // opposite of help. + const startingPoints = useMemo( + () => + graph.nodes + .filter((n) => n.inDegree === 0 && n.outDegree > 0) + .sort((a, b) => b.outDegree - a.outDegree) + .slice(0, 4), + [graph.nodes], + ); + + const groups = useMemo(() => { + const counts = new Map(); + for (const node of graph.nodes) { + const row = counts.get(node.category) ?? { + category: node.category, + label: node.categoryLabel, + count: 0, + }; + row.count += 1; + counts.set(node.category, row); + } + return Array.from(counts.values()).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)); + }, [graph.nodes]); + + // Asking for a group from the browse list means there is no canvas to move + // yet. The request is handed to the renderer as its opening frame instead; + // calling the live one would land on an instance that has no viewport, and + // the reader would arrive at the top of the map wondering what their click + // did. + const jumpToGroup = useCallback( + (category: string) => { + setSelectedId(null); + setPendingLane(category); + setRequestedView("canvas"); + api?.revealLane(category); + }, + [api], + ); + + const selected = selectedId ? (byId.get(selectedId) ?? null) : null; + const relations = useMemo(() => { + if (!selectedId) return { out: [] as string[], inc: [] as string[] }; + return { + out: graph.edges.filter((e) => e.source === selectedId).map((e) => e.target).sort(), + inc: graph.edges.filter((e) => e.target === selectedId).map((e) => e.source).sort(), + }; + }, [graph.edges, selectedId]); + + const inspector = selected ? ( +
+
+
+

+ {selected.label} +

+

+ {selected.title} +

+ {/* Category and the one count a reader can act on. The pair of + degree numbers moved into the group headings below, where each + one sits next to the list it describes. */} +

+ {selected.categoryLabel} · used by {selected.inDegree}{" "} + {selected.inDegree === 1 ? "skill" : "skills"} +

+
+ +
+ +
+ {selected.purpose && ( +

+ {selected.purpose}. +

+ )} + {selected.trigger && ( +

+ Ask for it with{" "} + “{selected.trigger}” +

+ )} + + {selected.oftenUsedWith.length > 0 && ( + + )} + } + color="var(--skill-edge-out)" + note={`${relations.out.length} ${relations.out.length === 1 ? "skill" : "skills"} it pulls in`} + ids={relations.out} + byId={byId} + onPick={select} + /> + } + color="var(--skill-edge-in)" + note={`${relations.inc.length} ${relations.inc.length === 1 ? "skill" : "skills"} that pull it in`} + ids={relations.inc} + byId={byId} + onPick={select} + /> +
+ +
+ + Open detail + + + Open workspace + +
+
+ ) : ( + + ); + + if (graph.nodes.length === 0) { + return ( +
+

No skills were found in this build.

+
+ ); + } + + const viewToggle = ( +
+ + +
+ ); + + const canvasActions = view === "canvas" && ( + <> + + + + + + ); + + const focusToggle = wideEnoughForCanvas ? ( + + ) : null; + + const help = ( +
+ + {hintOpen && ( +
+

Select a skill to see what it uses and what uses it.

+

Drag to rearrange · Scroll to zoom

+

+ F focus · R reset ·{" "} + Esc back +

+ )} +
+ ); - {view === "list" ? ( - /* The same relationships without an SVG in the way: every skill is - a focusable row, and focusing one traces it in the inspector, so - this graph is navigable by keyboard and by screen reader too. */ -
-
    - {graph.nodes.map((node) => { - const on = node.id === active; - return ( -
  • - -
  • - ); - })} -
-
- ) : ( - - - {graph.edges.map((e, i) => { - const a = points.get(e.source); - const b = points.get(e.target); - if (!a || !b) return null; - const isOut = active && e.source === active; - const isIn = active && e.target === active; - const on = isOut || isIn; - // pull the control point toward the centre so edges read as - // chords instead of overlapping straight lines - const cx = r2(C + (a.x + b.x - 2 * C) * 0.18); - const cy = r2(C + (a.y + b.y - 2 * C) * 0.18); - return ( - - ); - })} - - - - {graph.nodes.map((node) => { - const p = points.get(node.id)!; - const deg = node.inDegree + node.outDegree; - const r = r2(3 + (deg / maxDeg) * 7); - const dim = active ? !related?.touching.has(node.id) : false; - const rightSide = Math.cos(p.angle) > -0.01; - const lx = r2(C + Math.cos(p.angle) * (R + 14)); - const ly = r2(C + Math.sin(p.angle) * (R + 14)); - - const isPinned = node.id === pinned; - const isHovered = node.id === hovered; - const isFocused = node.id === focused; - const toggle = () => setPinned((cur) => (cur === node.id ? null : node.id)); - - return ( - + ) : ( + /* Not a fallback with the same rows in a column: the list opens the same + two ways in the map does, and every row carries what a skill is for + alongside the two numbers, in the same words the panel uses. */ +
+ {startingPoints.length > 0 && ( +
+

+ Starting points +

+

+ Nothing else pulls these in, so they are where a chain begins. +

+
+ {startingPoints.map((node) => ( + + ))} +
+
)} - {view === "graph" && ( -
- - references - - - referenced by - +
+

+ Browse by group +

+
+ {groups.map((group) => ( + + ))}
- )} -
+ -
+ ); -
- {activeNode.summary && ( -

{activeNode.summary}

- )} - } color={OUT_COLOR} ids={related.out} /> - } color={IN_COLOR} ids={related.inc} /> -
- - - Open detail - - - Open workspace - -
-
- - ) : ( - -

- Hover a skill to trace its references. Click to keep it pinned. Switch to List to reach every skill by - keyboard. + return ( +

+ {!focus && ( +
+ {viewToggle} + {canvasActions} + {focusToggle} +
+ {view === "canvas" && ( +

+ + uses + + + used by +

- + )} + {help} +
+
+ )} + +
+
+ {canvasPanel} + + {/* In focus mode the page chrome is gone, so the controls come to + the canvas rather than the other way round. */} + {focus && ( +
+
+ {focusToggle} + {viewToggle} + {canvasActions} +
+
{help}
+
)} - - -
- ); -} -function RefList({ - title, - icon, - color, - ids, -}: { - title: string; - icon: React.ReactNode; - color: string; - ids: string[]; -}) { - return ( -
-

- {icon} - {title} ({ids.length}) -

- {ids.length === 0 ? ( -

none

- ) : ( -
- {ids.map((id) => ( - - {id} - - ))} + + Start with a group + + {groups.map((group) => ( + + ))} +
+ )} +
+ + {dockInspector && ( + + )} +
+ + {/* Over the map rather than beside it: a docked rail is a sheet on a + narrow viewport, and an overlay drawer in focus mode, where nothing + is allowed to shrink the canvas. */} + {selected && overlayInspector && ( +
+ {inspector}
)}
diff --git a/components/skills/skills-backdrop.tsx b/components/skills/skills-backdrop.tsx index 2dcf1da..4733fd3 100644 --- a/components/skills/skills-backdrop.tsx +++ b/components/skills/skills-backdrop.tsx @@ -17,7 +17,7 @@ export function SkillsBackdrop() { return (