- Hover a skill to trace its references. Click to keep it pinned. Switch to List to reach every skill by
- keyboard.
+ return (
+
{animated &&
}
diff --git a/components/skills/skills-sidebar-nav.tsx b/components/skills/skills-sidebar-nav.tsx
index 627e1cc..1a823ec 100644
--- a/components/skills/skills-sidebar-nav.tsx
+++ b/components/skills/skills-sidebar-nav.tsx
@@ -7,7 +7,7 @@ import { cn } from "@/lib/utils";
export const NAV_ITEMS: { href: string; label: string; icon: LucideIcon }[] = [
{ href: "/skills", label: "Skills", icon: Layers },
- { href: "/skills/visualize-interactions", label: "Visualize interactions", icon: Share2 },
+ { href: "/skills/visualize-interactions", label: "How skills connect", icon: Share2 },
{ href: "/skills/prompt-inputs", label: "Prompt Inputs", icon: SendHorizontal },
];
diff --git a/docs/design/skills-explorer.md b/docs/design/skills-explorer.md
new file mode 100644
index 0000000..1777b9d
--- /dev/null
+++ b/docs/design/skills-explorer.md
@@ -0,0 +1,349 @@
+# Design: Skills Explorer
+
+**Status:** active
+**Owner:** @CommandOSSLabs
+**Last updated:** 2026-08-29
+**Scope:** Feature-level — the `/skills` catalog and the relationship visualization
+
+## Mission
+
+Turn `/skills` into a surface a developer can scan, and turn the reference graph
+into something they can explore. The catalog answers "which skill", the map
+answers "what does it sit between", and both hand off to the detail and
+workspace surfaces that already exist.
+
+## Design Principles
+
+- **The repository is the only source of truth** — every skill, reference and
+ category shown here is parsed from `skills/` at build time. Nothing is
+ hand-maintained alongside it, so nothing can drift.
+- **Deterministic placement beats organic placement** — a map you can refer back
+ to next week is worth more than one that looks alive. A force simulation
+ settles differently on every load; fixed lanes do not.
+- **The list is a peer of the canvas, not a fallback** — it is the same data at
+ the same fidelity, it is the default where a canvas would be unusable, and it
+ is what keyboard and screen-reader users get without asking.
+- **Interaction state belongs to the app, view state belongs to the library** —
+ React Flow owns pan, zoom and drag; selection, pinning, URL sync and
+ persistence stay ours, so swapping the renderer stays cheap.
+
+## Tech Stack
+
+| Layer | Technology | Notes |
+|---|---|---|
+| Runtime | Next.js 15, React 19 | App Router, `force-static` pages |
+| Language | TypeScript | strict |
+| Graph rendering | `@xyflow/react` 12 | DOM-based 2D canvas; pan, zoom, drag, minimap, controls |
+| Data | Build-time filesystem read of `skills/` | no runtime API, no database |
+| Styling | Tailwind 3 + CSS custom properties | `--skill-*` tokens carry graph meaning per theme |
+
+## Architecture
+
+```mermaid
+graph TD
+ A[skills/ directory] --> B[lib/skill-graph.ts]
+ B --> C[lib/skill-graph-layout.ts]
+ C --> D[SkillGraphView]
+ D --> E[SkillGraphCanvas]
+ D --> F[SkillGraphList]
+ D --> G[SkillGraphInspector]
+ E --> H[@xyflow/react]
+ I[sessionStorage] --> E
+ E --> I
+```
+
+### `lib/skill-graph.ts`
+
+Parses every `SKILL.md` under `skills/` at build time and emits nodes, edges and
+dangling references. A node now also carries `category` and `categoryLabel`,
+taken from the same map the catalog uses, so the two surfaces cannot disagree
+about which group a skill is in. Edge semantics are unchanged: a directed
+`source → target` pair per `cmk:` handle found in a skill's files.
+
+### `lib/skill-graph-layout.ts`
+
+Owns placement and persistence, with no React and no renderer knowledge.
+
+- `layoutSkillGraph(nodes)` assigns each node a position from a fixed category
+ lane, alphabetically within the lane. Same input, same output, every time.
+- `LAYOUT_VERSION` is bumped whenever lane geometry changes.
+- `readStoredLayout()` / `writeStoredLayout()` persist dragged positions in
+ `sessionStorage` under a versioned key, and reject a payload whose version or
+ shape does not match.
+
+### `SkillGraphView`
+
+The application shell. Owns the view mode, the selected skill, URL
+synchronization and the inspector. It renders either the canvas or the list, and
+it renders the inspector itself so inspector content never depends on the
+canvas mounting or on an exit animation finishing.
+
+### `SkillGraphCanvas`
+
+The React Flow boundary, loaded only when the canvas view is active. It converts
+laid-out nodes and edges into React Flow's shapes, renders a custom node, and
+reports drags back up. It owns nothing the app needs to know about except the
+positions it emits.
+
+Node dimensions are declared, not 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 delivery never
+arrives, the whole map stays invisible, and `visibility: hidden` also makes
+every node unfocusable. The layout already knows every size, so the node
+objects carry `width` and `height` and the renderer never has to ask.
+
+### Vocabulary
+
+One set of words, defined once and used on every surface. `references` became
+`uses`, `referenced by` became `used by`, `out · in` became `Uses n · Used by
+n`, `graph` became `map`, `entry points` became `starting points`, and the
+page is called "How skills connect". The rename is not cosmetic: a reader who
+has to work out which end of an arrow they are looking at cannot use the
+picture, and half the old labels named a data structure rather than a
+direction.
+
+Two things had to exist before the words could work:
+
+- **`SKILL_PURPOSE` in `lib/skill-types.ts`.** A skill's frontmatter carries a
+ name, a version and a long trigger-shaped description; none of them answer
+ "what is this?" in a card-width line. Extracting a clause from the
+ description reads unevenly across 34 skills, so the line is curated in the
+ site layer, next to `CATEGORY_MAP`, which is presentation-only for the same
+ reason. `skillPurpose()` falls back to the first trigger phrase, so a new
+ skill directory still renders. Changing a line is a copy edit in one file,
+ not a change to the skills themselves.
+- **`oftenUsedWith` in `lib/skill-graph.ts`.** "Often used with" is not an
+ edge. Two skills keep company when some third skill pulls in both, and
+ counting those shared parents ranks them. It answers the question a reader
+ actually has — what else will I want open — which neither direction of the
+ real edge does.
+
+### Starting points
+
+A starting point is a skill nothing else pulls in *that pulls in something
+itself*. The naive definition, in-degree zero, includes `cmk:codebase-docs`,
+which has no connections in either direction: sending a reader there as a
+place to begin is worse than saying nothing. The stat line lost its
+"5 entry points" for the same reason it gained the start panel — it was an
+action wearing a statistic's clothes.
+
+Before anything is selected the rail holds a start panel rather than
+disappearing: group chips that frame a lane, and the starting points with what
+each one leads to. Where the rail cannot fit — under 1280, and in focus mode —
+the same groups appear as a strip over the map, and the browse list carries
+them as its first two sections. A group jump requested from the list is handed
+to the canvas as its opening frame rather than called on it, because at that
+moment the canvas does not exist yet.
+
+### Focus mode
+
+Focus mode is application-level rather than the browser Fullscreen API, and it
+is implemented by collapsing the shell rather than by moving the canvas.
+`SkillGraphView` sets `data-skills-focus` on ``; unlayered rules in
+`app/globals.css` then hide the icon rail, the page header and the page
+heading, drop the shell's padding, and remove the content column's
+`backdrop-filter`. The canvas grows into that space without changing position
+in the React tree, so the React Flow instance is neither unmounted nor
+duplicated and the viewport transform survives the switch untouched.
+
+Removing `backdrop-filter` is not cosmetic. A non-`none` value makes that
+column the containing block for every `fixed` descendant, which is what would
+otherwise pin the inspector drawer to the column instead of the viewport. In
+focus mode the column covers the screen anyway, so there is nothing left to
+blur.
+
+The layout exposes three class hooks for this and nothing else:
+`skills-column`, `skills-topbar` and `skills-content-pad`, plus
+`skills-page-head` on the route's own heading block.
+
+### Viewport policy
+
+The canvas does not fit the graph on load. Fitting eight lanes into a panel
+drove every card to a smear of unreadable colour, which is the defect this
+revision exists to remove. Instead:
+
+- With `?skill=`, the canvas centres that node at zoom 1.1.
+- Otherwise it anchors the map's top-left corner with a small margin at zoom 1,
+ so the first thing on screen is a whole card rather than a clipped column.
+- `Fit all` is a deliberate control. Below zoom 0.62 a node drops its metadata
+ row rather than rendering it at sub-8px.
+- Selecting a skill calls `reveal`, which moves the viewport only when the node
+ is outside the comfortable frame or the zoom is below legible. A canvas that
+ re-centres on every click fights the reader.
+- `Reset layout` restores canonical node positions; it does not touch the zoom.
+
+Every viewport computation is done from the container's own
+`getBoundingClientRect()` and written with `setViewport`, not from React Flow's
+measured size, for the same reason node dimensions are declared: measurement
+rides on ResizeObserver and the opening frame has to land exactly whether or
+not that has been delivered.
+
+Lane grids are three columns wide. At the readable node size a wider grid is
+wider than the canvas gets on a 1440 screen with the inspector docked, and a
+column you have to pan to find on first load is the same mistake as a node too
+small to read.
+
+### `SkillGraphList`
+
+A table of every skill with its outgoing and incoming references as links. Rows
+are buttons, so selection works by keyboard, and it carries the same relationship
+information the canvas draws.
+
+## External Dependencies
+
+- `@xyflow/react` — pan, zoom, node dragging, viewport controls and minimap.
+ Failure behavior: the module is imported dynamically for the canvas view only.
+ If it fails to load, the view falls back to the list, which needs no library.
+
+## Acceptance Criteria
+
+| Requirement | Satisfied by |
+|---|---|
+| SKEX-1.1, SKEX-1.2 | `SkillCard` in `components/skills/skill-catalog.tsx` |
+| SKEX-1.3 | Category grouping in `SkillCatalog` |
+| SKEX-1.4 | Existing 1440px inspector split in `SkillCatalog` |
+| SKEX-1.5, SKEX-1.6 | Card stretched link plus separate workspace control |
+| SKEX-2.1, SKEX-2.2, SKEX-2.3 | `SkillGraphCanvas` node and edge styling |
+| SKEX-2.4 | `SkillGraphInspector`, rendered by `SkillGraphView` |
+| SKEX-2.5 | No edge-mutation handlers are wired |
+| SKEX-3.1, SKEX-3.2 | `layoutSkillGraph` |
+| SKEX-3.3 | React Flow viewport and node drag |
+| SKEX-3.4, SKEX-3.6 | `readStoredLayout` / `writeStoredLayout` |
+| SKEX-3.5 | Reset layout control |
+| SKEX-4.1, SKEX-4.4 | `SkillGraphList` |
+| SKEX-4.2, SKEX-4.3 | View resolution in `SkillGraphView` |
+| SKEX-4.5 | `useReducedMotion` guards on edges and transitions |
+| SKEX-5.1 to SKEX-5.4 | Untouched detail, workspace and `normalizeSkillId` paths |
+| SKEX-6.1 to SKEX-6.3 | Responsive audit and `--skill-*` / `--accent` tokens |
+
+## Cross-Cutting Concerns
+
+### Security
+
+#### Assumptions
+
+- Skill content is repository content, authored in the same review process as
+ code — it is not user input, so it is rendered rather than sanitized as
+ untrusted.
+
+#### Known Gaps and Risks
+
+| Gap | Severity | Impact | Root Cause | Mitigation / Acceptance |
+|---|---|---|---|---|
+| `sessionStorage` holds unvalidated coordinates | low | A crafted payload could place nodes off-screen | Positions are read back from the browser | Shape and version are validated on read; anything else is discarded and the canonical layout is used |
+
+#### Controls
+
+- Versioned storage key — a layout change cannot be poisoned by stale data.
+- Read-only edges — no code path mutates the reference graph from the UI.
+
+### Performance and Scalability
+
+- The graph renderer is dynamically imported and reachable only from
+ `/skills/visualize-interactions`. The catalog and workspace bundles do not
+ include it.
+- The markdown editor stays dynamically imported behind Edit, as before.
+- The animated WebGL backdrop does not mount on the workspace, and the canvas
+ route does not add a second one.
+- At 34 nodes and 108 edges the graph is small enough that no virtualization or
+ level-of-detail work is warranted; that changes if the kit passes a few
+ hundred skills.
+
+### Error Handling and Resilience
+
+- Empty graph: the view renders an explanatory empty state rather than a blank
+ canvas.
+- Malformed or version-mismatched stored layout: discarded, canonical layout used.
+- Unknown `?skill=` value: no selection, rather than an invented one.
+- Unknown `?view=` value: the viewport default.
+
+### Accessibility
+
+- The list view is the complete non-canvas equivalent, and the default under
+ 768px.
+- The inspector docks beside the map only at 1280px and above and only while a
+ skill is selected. Everywhere else — narrower viewports, and focus mode at any
+ width — it is an overlay with `role="dialog"`, `aria-modal`, a label, focus
+ moved in on open, Tab trapped inside, Escape to close and focus returned to
+ the node it came from: the same contract as the workspace's file drawer.
+- `F` enters and leaves focus mode, `R` resets the layout, and `Escape` peels
+ one layer at a time — the open panel first, then focus mode. All three are
+ ignored while a text field, textarea or select has focus.
+- The workspace tab strip owns only tabs. A `tablist` may own nothing but
+ `tab`, so the close control sits inside its own tab; and because a focusable
+ control inside a tab is nested interactive content, the close affordance is
+ pointer-only and out of the accessibility tree, with `Delete` or `Backspace`
+ closing the focused tab instead.
+- `GooeyTextReveal` splits by line with no ARIA of its own. GSAP's `aria: auto`
+ labels the target and hides every generated line; on a paragraph that label is
+ prohibited, so assistive technology could drop it and find only hidden
+ children — a paragraph that reads as empty.
+- Canvas nodes are focusable, expose an accessible name including category and
+ both degree counts, and toggle selection on Enter or Space. React Flow's own
+ node focus is disabled so there is exactly one focus stop per node and the
+ accessible name is the card's.
+- Hover traces a skill without moving the pin: the trace follows the pointer
+ while the pinned marker — fill, ring and a halo the hover state does not
+ have — stays where it was.
+- Selection is conveyed by fill, ring and halo together, never colour alone.
+- `--skill-node`, `--skill-node-active`, `--skill-edge`, `--skill-edge-out` and
+ `--skill-edge-in` resolve per theme so markers clear about 3:1 in both.
+- Idle edges are deliberately quieter than 3:1: they are texture, and every
+ relationship they hint at is available at full contrast in the traced state,
+ the inspector and the list.
+- `prefers-reduced-motion` removes edge animation and view transitions.
+- Document scrollers are focusable. A skill whose rendered body happens to
+ carry no links leaves its scroll container with no focusable child, and a
+ keyboard user then cannot scroll it at all — which is why the preview and
+ the eval view take `tabindex` and a label rather than relying on their
+ content to provide one.
+- axe-core 4.13 reports no WCAG 2.0/2.1 A or AA violations on the catalog, the
+ canvas in both layouts, the list view, the mobile inspector, the skill detail
+ or the workspace. Its `color-contrast` check returns *incomplete* over the
+ canvas, where backgrounds are `color-mix()` values it will not resolve, so
+ those are measured directly instead: the lowest ratio on graph and inspector
+ text is 5.03:1 dark and 5.35:1 light.
+
+## Constraints
+
+- Pages under `/skills` are `force-static`; anything read from the URL is read
+ after mount, so no surface may depend on server-side search params.
+- Tailwind 3 cannot apply an opacity modifier to an arbitrary custom property,
+ and cannot disambiguate a bare `var()` in a `text-` utility — accent ink is
+ written as `text-[color:var(--accent)]`.
+
+## Architecture Rationale
+
+Four options were considered for the visualization.
+
+**Fixed SVG ring (the previous implementation).** Cheap and deterministic, but
+every skill gets identical visual weight, the layout carries no grouping
+information, and nothing can be rearranged. It answered "are these connected"
+and nothing else.
+
+**Force simulation.** Produces attractive clusters, but placement differs on
+every load, so the map cannot be referred back to, and the simulation costs
+frames on every visit for a graph whose structure never changes between builds.
+
+**Three-dimensional force graph.** Rejected. It adds a WebGL context and a
+physics loop to a page whose job is reading; occlusion makes labels unreliable;
+touch interaction is significantly worse; and there is no honest accessible
+equivalent of a 3D scene, so the list would become the real interface for a
+large share of readers while the 3D view took the budget.
+
+**Two-dimensional canvas with deterministic layout (chosen).** Keeps the
+determinism of the ring, adds the exploration the ring lacked, groups by
+category so position itself carries meaning, and degrades to a list that is a
+genuine peer rather than an apology. React Flow supplies the interaction layer
+so the code we own stays limited to layout, selection and persistence, which is
+also what makes the choice reversible.
+
+## Related Documents
+
+- [Codebase Docs](../ai/) — AI-navigable map of the repo
+- [Rules](../rules/README.md) — engineering standards
+
+## Links
+
+- Requirements: [docs/requirements/skills-explorer.md](../requirements/skills-explorer.md)
+- Decisions: no ADR — feature-local and reversible
diff --git a/docs/requirements/skills-explorer.md b/docs/requirements/skills-explorer.md
new file mode 100644
index 0000000..e194b8e
--- /dev/null
+++ b/docs/requirements/skills-explorer.md
@@ -0,0 +1,298 @@
+# Requirements: Skills Explorer
+
+**Status:** active
+**Owner:** @CommandOSSLabs
+**Last updated:** 2026-08-29
+**Notation:** ears
+**ID prefix:** SKEX
+
+## Problem
+
+A developer arriving at `/skills` has to choose one workflow out of 34 before
+they can do anything useful, and today the page makes that choice expensive.
+Every card carries a full summary paragraph plus counts, so picking a skill
+means reading roughly 34 paragraphs; the information that actually decides the
+choice — what the skill is called and the phrase that triggers it — is buried
+inside prose written for a different purpose.
+
+The second problem is relational. Skills reference each other by `cmk:` handle,
+and those references are the real structure of the kit: `cmk:delivery-review`
+means something different once you know it sits between intake and ship. The
+existing visualization draws that structure as a fixed circular ring, which
+cannot be rearranged, cannot be explored, and gives every skill the same visual
+weight regardless of how central it is. A developer who wants to answer "what
+should I run before this" has no way to trace it.
+
+They cope by reading `SKILL.md` files directly in GitHub, which loses the
+relationships entirely, or by opening skills one at a time until one matches.
+
+## Why Now
+
+The catalog, detail and workspace surfaces landed together and are stable, so
+the remaining cost is discovery rather than capability. The reference graph is
+already parsed at build time for the existing visualization, which means the
+data needed for a real relationship map exists and is currently under-used. And
+the kit has grown past the size where a flat alphabetical list is a reasonable
+default: at 34 skills across nine categories, category is now the first useful
+filter rather than a decoration.
+
+## Success Criteria
+
+| Metric | Target | Measurement Method |
+|---|---|---|
+| Vertical space per catalog card | Under half the current height | Rendered card height at 1440px, before and after |
+| Text a reader must scan to pick a skill | Handle, title and one trigger phrase only | Card content audit |
+| Relationship questions answerable in the UI | Both directions, for every skill | Manual pass over all 34 skills in the map and the list |
+| Horizontal overflow | None at 390, 768, 1280, 1440 and 1512px | Scripted `scrollWidth` versus `clientWidth` audit per viewport |
+| Small-text contrast | AA (4.5:1) in light and dark | Measured from rendered elements with transitions disabled |
+| Meaningful graphic contrast | About 3:1 for node fills | Same method, against the canvas surface |
+
+## User Needs and Scenarios
+
+### Recognize the right skill without reading a paragraph
+
+A developer knows the shape of the task but not the kit's vocabulary. The card
+must let them recognize a fit from the handle, the title and the phrase they
+would actually type.
+
+**Scenario:** Someone about to review a pull request scans the Delivery group,
+sees `cmk:delivery-review` with the trigger `"review this PR"`, and opens it
+without reading any other card.
+
+### Understand how a skill relates to the others
+
+A developer has found a plausible skill and needs to know what it depends on
+and what depends on it before committing to it.
+
+**Scenario:** Someone opens the map, selects `cmk:delivery-review`, and sees
+that it references `cmk:delivery-pipeline` and is referenced by
+`cmk:delivery-ship`, which tells them it belongs in the middle of a sequence
+rather than being run alone.
+
+### Explore the structure rather than read a fixed picture
+
+The canonical layout is a starting point, not the only arrangement. A developer
+comparing two clusters needs to move things.
+
+**Scenario:** Someone drags the Sui skills away from the Delivery lane to see
+whether the two clusters share any references, then returns to the canonical
+layout with one control.
+
+### Know what to do next without knowing graph vocabulary
+
+A reader arriving at the map should not have to work out whether an arrow
+means "uses" or "used by", what an entry point is, or whether the picture is
+for choosing a skill or for looking at.
+
+**Scenario:** Someone who has never seen the page reads the heading, picks a
+group or a starting point, selects a skill, and can say out loud which skills
+come before it and which depend on it, without using the word graph.
+
+### Give the map the whole screen when reading it
+
+The map is the subject of the page, not an illustration inside a dashboard. A
+developer tracing a cluster needs the shell to get out of the way, and a node
+they can read without hovering it.
+
+**Scenario:** Someone opens the visualization, sees skill handles at a legible
+size without touching the zoom, presses `F`, and reads the whole map with the
+navigation, page header and stats gone; `Esc` brings them back.
+
+### Use the map without a mouse or a large screen
+
+**Scenario:** Someone on a phone opens the visualization, gets a list of skills
+and their relationships rather than a pinched-in canvas, and reaches every
+relationship with the keyboard on a laptop.
+
+### Keep working on a skill's files
+
+The explorer is how a developer finds work; the workspace is where they do it.
+Discovery changes must not cost them the editor.
+
+**Scenario:** Someone selects a skill in the map, opens its workspace, edits
+`SKILL.md` locally, navigates back to the catalog and returns to find the draft
+still there.
+
+## Acceptance Criteria
+
+### Recognize the right skill without reading a paragraph
+
+- **SKEX-1.1** The catalog shall present each skill as a card carrying its
+ `cmk:` handle, its human-readable title, its category and at most one trigger
+ phrase.
+- **SKEX-1.2** The catalog shall not present skill summary paragraphs, file
+ counts or reference counts on a card.
+- **SKEX-1.3** The catalog shall group skills by category in its default,
+ unfiltered, unsorted state.
+- **SKEX-1.4** When a viewport is at least 1440 pixels wide, the catalog shall
+ present a detail inspector beside the list.
+- **SKEX-1.5** When a card is activated, the explorer shall open that skill's
+ detail surface.
+- **SKEX-1.6** The catalog shall present a separate keyboard-reachable control
+ that opens the skill's workspace.
+
+### Understand how a skill relates to the others
+
+- **SKEX-2.1** When a skill is selected, the visualization shall distinguish its
+ outgoing references from its incoming references.
+- **SKEX-2.2** While a skill is selected, the visualization shall keep that
+ selection marked when the pointer hovers a different skill.
+- **SKEX-2.3** The visualization shall mark a selected skill by a combination of
+ fill, ring and halo rather than by colour alone.
+- **SKEX-2.4** The visualization shall present the selected skill's title,
+ summary, incoming relations and outgoing relations without waiting for any
+ animation to complete.
+- **SKEX-2.5** The visualization shall not offer any control that creates,
+ edits or deletes a relationship.
+
+### Explore the structure rather than read a fixed picture
+
+- **SKEX-3.1** The visualization shall place skills in category-clustered lanes,
+ ordered alphabetically within a lane.
+- **SKEX-3.2** When the visualization is loaded twice without stored positions,
+ it shall produce identical placements.
+- **SKEX-3.3** The visualization shall support panning, zooming and dragging
+ individual skills.
+- **SKEX-3.4** When a skill is dragged, the visualization shall retain its
+ position for the remainder of the browser tab session.
+- **SKEX-3.5** The visualization shall provide a control that discards stored
+ positions and restores the canonical layout.
+- **SKEX-3.6** If stored positions are unreadable or carry a different layout
+ version, the visualization shall discard them and use the canonical layout.
+
+### Know what to do next without knowing graph vocabulary
+
+- **SKEX-8.1** The explorer shall name an outgoing relationship "uses" and an
+ incoming one "used by", on every surface that shows either.
+- **SKEX-8.2** The explorer shall not present a skill's degree counts as its
+ primary description.
+- **SKEX-8.3** A skill shall be identified by its handle, its title and one
+ line saying what it is for, in that order, on the map and in the catalog.
+- **SKEX-8.4** Before anything is selected, the explorer shall offer at least
+ one way in: a group to jump to, or a skill to start from.
+- **SKEX-8.5** Selecting a group shall frame that group on the map, from the
+ map and from the browse list alike.
+- **SKEX-8.6** A starting point shall be a skill nothing else pulls in that
+ pulls in something itself; a skill with no connections in either direction
+ shall not be offered as one.
+- **SKEX-8.7** When a skill is selected, the explorer shall show the skills it
+ is named alongside, the skills it uses and the skills that use it, each
+ labelled in words rather than by direction symbols.
+- **SKEX-8.8** The browse list shall carry the same starting points, groups,
+ purpose lines and relationship words the map does.
+- **SKEX-8.9** The page shall state its counts as one muted line and shall not
+ give them more weight than its controls.
+
+### Give the map the whole screen when reading it
+
+- **SKEX-7.1** The visualization shall open at a zoom at which a skill's handle
+ and category are legible without hovering or zooming in.
+- **SKEX-7.2** The visualization shall not fit the entire graph to the viewport
+ on load; fitting the whole graph shall be an explicit control.
+- **SKEX-7.3** The visualization shall provide a focus mode that hides the
+ application navigation, the page heading and the page statistics, and gives
+ the canvas the full application viewport.
+- **SKEX-7.4** Focus mode shall be reflected in the URL and restored from it.
+- **SKEX-7.5** While in focus mode, the inspector shall be an overlay and shall
+ not reduce the canvas.
+- **SKEX-7.6** Switching between focus mode and the standard layout shall not
+ unmount or duplicate the canvas, and shall preserve the current viewport.
+- **SKEX-7.7** The visualization shall accept `F` for focus mode, `R` for reset
+ layout and `Escape` to dismiss the open panel and then focus mode, and shall
+ ignore them while a text field has focus.
+- **SKEX-7.8** When a skill is selected, the visualization shall bring it into
+ view, raising the zoom to a legible level only if it is not already there.
+- **SKEX-7.9** The docked inspector shall occupy layout only while a skill is
+ selected.
+
+### Use the map without a mouse or a large screen
+
+- **SKEX-4.1** The visualization shall provide a list view exposing every skill
+ and both directions of every relationship.
+- **SKEX-4.2** When a viewport is narrower than 768 pixels, the visualization
+ shall default to the list view.
+- **SKEX-4.3** The visualization shall accept an explicit view selection through
+ the URL, on any viewport.
+- **SKEX-4.4** The list view shall support selecting a skill by keyboard.
+- **SKEX-4.5** While the reader prefers reduced motion, the visualization shall
+ not animate edges or view transitions.
+
+### Keep working on a skill's files
+
+- **SKEX-5.1** The explorer shall preserve the skill detail, workspace, Preview,
+ Source and Edit surfaces.
+- **SKEX-5.2** The explorer shall preserve browser-local drafts across
+ navigation within a browser tab session.
+- **SKEX-5.3** The explorer shall accept a skill identifier with or without the
+ `cmk:` prefix on every surface that takes one.
+- **SKEX-5.4** The explorer shall preserve the existing catalog query, category,
+ sort and flag URL parameters.
+
+### Read the interface in either theme, at any supported width
+
+- **SKEX-6.1** The explorer shall not produce horizontal overflow at 390, 768,
+ 1280, 1440 or 1512 pixels wide.
+- **SKEX-6.2** Small text shall meet a contrast ratio of at least 4.5:1 against
+ its background in both themes.
+- **SKEX-6.3** Skill markers in the visualization shall meet a contrast ratio of
+ at least 3:1 against the canvas surface in both themes.
+- **SKEX-6.4** The catalog, the canvas in both layouts, the list view, the
+ mobile inspector, the skill detail and the workspace shall each report no
+ axe-core violations at WCAG 2.0/2.1 level A and AA.
+
+## Scope
+
+### In Scope
+
+- Catalog information density, grouping and controls.
+- A two-dimensional, pannable, zoomable, draggable relationship map.
+- A list view that is a complete equivalent of the map, not a degraded fallback.
+- Deep links for skill selection and view choice.
+- Requirements, design, roadmap and changelog updates that describe the result.
+
+### Out of Scope
+
+- A three-dimensional force graph — it costs WebGL and physics budget on every
+ visit, is harder to read than a laid-out 2D map, degrades badly on touch, and
+ has no accessible equivalent. Recorded as considered and rejected, not as a
+ later phase.
+- A force simulation as the default layout — non-deterministic placement means
+ the map cannot be referred back to between visits.
+- Editing relationships — references are derived from the repository and are
+ read-only here.
+- A command palette — it would add a third navigation surface before the two
+ that exist are settled.
+- Any repository write, save or publish path for local drafts.
+
+## Risks and Assumptions
+
+### Risks
+
+| Risk | Likelihood | Impact | Why It Exists | Mitigation |
+|---|---|---|---|---|
+| Compact cards remove context someone relied on | medium | medium | Summaries and counts move to detail | Detail is one click and one keystroke away, and the trigger phrase is the strongest recognition signal that remains |
+| A graph library adds meaningful bundle weight | medium | medium | React Flow ships its own renderer and interaction layer | Loaded only on the visualization route, never from the catalog |
+| Stored positions outlive a layout change | low | low | Positions are keyed by skill id | Layout carries a version; a mismatch discards stored positions |
+
+### Assumptions
+
+- Category is a useful primary grouping for this kit — if the categories were
+ arbitrary, grouping would make the catalog harder to scan rather than easier.
+- The reference graph is dense enough to be worth exploring — at 34 skills and
+ 108 references it is; a sparser kit would not justify a canvas.
+- Readers arrive knowing the task, not the vocabulary — if they already knew the
+ handles, search alone would be enough.
+
+## Related Documents
+
+- [Codebase Docs](../ai/) — AI-navigable map of the repo
+
+### Downstream Design
+
+- [Skills Explorer](../design/skills-explorer.md) — catalog architecture, the
+ canvas boundary, layout determinism and the URL contracts
+
+## Links
+
+- Design: [docs/design/skills-explorer.md](../design/skills-explorer.md)
+- Decisions: no ADR — the renderer choice is feature-local and reversible
diff --git a/lib/skill-catalog.ts b/lib/skill-catalog.ts
index 0cae521..f339c1b 100644
--- a/lib/skill-catalog.ts
+++ b/lib/skill-catalog.ts
@@ -2,7 +2,13 @@ import fs from "node:fs";
import path from "node:path";
import { extractFrontmatter } from "./frontmatter";
import { getSkillGraph } from "./skill-graph";
-import { CATEGORY_LABELS, CATEGORY_MAP, type SkillCategoryInfo } from "./skill-types";
+import {
+ CATEGORY_LABELS,
+ CATEGORY_MAP,
+ extractTriggers,
+ skillPurpose,
+ type SkillCategoryInfo,
+} from "./skill-types";
export type SkillFileRef = {
/** path relative to skills/, matching SkillTreeNode ids — e.g. "adr/references/adr-template.md" */
@@ -28,6 +34,8 @@ export type SkillSummary = {
title: string;
/** frontmatter description: the full "use when" sentence */
description: string;
+ /** one line saying what the skill is for — the same line the map shows */
+ purpose: string;
/** first paragraph of the body: what the skill actually does */
summary: string;
version: string;
@@ -180,10 +188,6 @@ function collectFiles(dir: string, skillId: string, out: SkillFileRef[] = []): S
}
/** Trigger phrases are already written down: the description quotes them verbatim. */
-function extractTriggers(description: string): string[] {
- return Array.from(description.matchAll(/"([^"]+)"/g)).map((m) => m[1]);
-}
-
let cached: SkillSummary[] | null = null;
/**
@@ -237,6 +241,7 @@ export function getSkillCatalog(): SkillSummary[] {
handle: field("name") || `cmk:${id}`,
title: parsed.title || id,
description,
+ purpose: skillPurpose(id, description),
summary: parsed.summary,
version: field("version") || "0.0.0",
category,
diff --git a/lib/skill-graph-layout.ts b/lib/skill-graph-layout.ts
new file mode 100644
index 0000000..28f72e7
--- /dev/null
+++ b/lib/skill-graph-layout.ts
@@ -0,0 +1,195 @@
+import type { SkillNode } from "./skill-graph";
+import { CATEGORY_LABELS } from "./skill-types";
+
+export type SkillGraphPosition = { x: number; y: number };
+
+export type PositionedSkillNode = SkillNode & { position: SkillGraphPosition };
+
+export type PersistedSkillGraphLayout = {
+ version: typeof LAYOUT_VERSION;
+ positions: Record
;
+};
+
+/**
+ * Bump when lane geometry changes. A stored layout carrying a different
+ * version is discarded rather than migrated: the positions describe a map that
+ * no longer exists, and restoring them would scatter nodes across lanes that
+ * have moved.
+ */
+export const LAYOUT_VERSION = 3;
+
+const STORAGE_KEY = "ai-devkit-skill-graph-layout";
+
+// Sized for three lines at zoom 1 without hovering: the handle, the human
+// title, and the line saying what the skill is for. The last one is the
+// reason the card grew — a reader who cannot tell what a node does has no
+// use for knowing how many arrows touch it.
+const NODE_WIDTH = 216;
+const NODE_HEIGHT = 92;
+const COLUMN_GAP = 64;
+const ROW_GAP = 28;
+const LANE_GAP = 56;
+const LANE_HEADER = 44;
+
+/** Lane order, so the map reads the same way every time it is opened. */
+const LANE_ORDER = [
+ "delivery",
+ "docs",
+ "setup",
+ "agent",
+ "testing",
+ "sui",
+ "sync",
+ "session",
+ "other",
+];
+
+function laneRank(category: string): number {
+ const i = LANE_ORDER.indexOf(category);
+ return i === -1 ? LANE_ORDER.length : i;
+}
+
+export type SkillGraphLane = {
+ category: string;
+ label: string;
+ /** y of the lane's heading, in flow coordinates */
+ y: number;
+ height: number;
+ count: number;
+};
+
+export type SkillGraphLayout = {
+ nodes: PositionedSkillNode[];
+ lanes: SkillGraphLane[];
+ width: number;
+ /** full bounds of the laid-out map, so the canvas can frame it without measuring */
+ height: number;
+};
+
+/**
+ * Deterministic, category-clustered placement: lanes in a fixed order, skills
+ * alphabetical inside a lane, wrapped into a grid whose width depends only on
+ * the largest lane. The same graph produces the same map on every load, which
+ * is the whole reason this is not a force simulation — a picture you can refer
+ * back to next week beats one that looks alive.
+ */
+export function layoutSkillGraph(nodes: SkillNode[]): SkillGraphLayout {
+ const byCategory = new Map();
+ for (const node of nodes) {
+ const list = byCategory.get(node.category) ?? [];
+ list.push(node);
+ byCategory.set(node.category, list);
+ }
+
+ const categories = Array.from(byCategory.keys()).sort(
+ (a, b) => laneRank(a) - laneRank(b) || a.localeCompare(b),
+ );
+
+ // One column count for every lane, so lanes line up rather than ragging.
+ // Three, not a function of the largest lane: at the readable node size a
+ // wider grid is wider than the canvas gets on a 1440 screen with the
+ // inspector docked, and a column you have to pan to find on first load is
+ // the same mistake as a node too small to read.
+ const columns = 3;
+
+ const positioned: PositionedSkillNode[] = [];
+ const lanes: SkillGraphLane[] = [];
+ let y = 0;
+
+ for (const category of categories) {
+ const list = (byCategory.get(category) ?? []).slice().sort((a, b) => a.id.localeCompare(b.id));
+ const rows = Math.ceil(list.length / columns);
+ const height = LANE_HEADER + rows * NODE_HEIGHT + Math.max(0, rows - 1) * ROW_GAP;
+
+ list.forEach((node, index) => {
+ const column = index % columns;
+ const row = Math.floor(index / columns);
+ positioned.push({
+ ...node,
+ position: {
+ x: column * (NODE_WIDTH + COLUMN_GAP),
+ y: y + LANE_HEADER + row * (NODE_HEIGHT + ROW_GAP),
+ },
+ });
+ });
+
+ lanes.push({
+ category,
+ label: CATEGORY_LABELS[category] ?? category,
+ y,
+ height,
+ count: list.length,
+ });
+
+ y += height + LANE_GAP;
+ }
+
+ return {
+ nodes: positioned,
+ lanes,
+ width: columns * NODE_WIDTH + (columns - 1) * COLUMN_GAP,
+ height: Math.max(0, y - LANE_GAP),
+ };
+}
+
+export const SKILL_NODE_SIZE = { width: NODE_WIDTH, height: NODE_HEIGHT };
+
+function isPosition(value: unknown): value is SkillGraphPosition {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ typeof (value as SkillGraphPosition).x === "number" &&
+ typeof (value as SkillGraphPosition).y === "number" &&
+ Number.isFinite((value as SkillGraphPosition).x) &&
+ Number.isFinite((value as SkillGraphPosition).y)
+ );
+}
+
+/**
+ * Dragged positions, for this browser tab only. Anything unreadable, of the
+ * wrong version, or carrying a value that is not a finite coordinate pair is
+ * discarded whole — a half-restored map is worse than the canonical one.
+ */
+export function readStoredLayout(): Record | null {
+ try {
+ const raw = sessionStorage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ const parsed: unknown = JSON.parse(raw);
+ if (
+ typeof parsed !== "object" ||
+ parsed === null ||
+ (parsed as PersistedSkillGraphLayout).version !== LAYOUT_VERSION
+ ) {
+ return null;
+ }
+ const positions = (parsed as PersistedSkillGraphLayout).positions;
+ if (typeof positions !== "object" || positions === null) return null;
+
+ const out: Record = {};
+ for (const [id, position] of Object.entries(positions)) {
+ if (!isPosition(position)) return null;
+ out[id] = { x: position.x, y: position.y };
+ }
+ return out;
+ } catch {
+ return null;
+ }
+}
+
+export function writeStoredLayout(positions: Record): void {
+ try {
+ const payload: PersistedSkillGraphLayout = { version: LAYOUT_VERSION, positions };
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
+ } catch {
+ // storage unavailable or full — the map still works, it just won't be
+ // where you left it after a reload
+ }
+}
+
+export function clearStoredLayout(): void {
+ try {
+ sessionStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // nothing to clear
+ }
+}
diff --git a/lib/skill-graph.ts b/lib/skill-graph.ts
index b07b9f5..4a29193 100644
--- a/lib/skill-graph.ts
+++ b/lib/skill-graph.ts
@@ -1,17 +1,35 @@
import fs from "node:fs";
import path from "node:path";
import { extractFrontmatter } from "./frontmatter";
+import { CATEGORY_LABELS, CATEGORY_MAP, extractTriggers, skillPurpose } from "./skill-types";
export type SkillNode = {
/** directory name, e.g. "delivery-review" */
id: string;
/** frontmatter name, e.g. "cmk:delivery-review" */
label: string;
+ /** the SKILL.md H1, e.g. "Delivery Review" */
+ title: string;
+ /** one line saying what the skill is for, in the reader's words */
+ purpose: string;
+ /** the phrase the description advertises first, e.g. "review my changes" */
+ trigger: string;
summary: string;
+ /** grouping id shared with the catalog, e.g. "delivery" */
+ category: string;
+ /** the catalog's own label for that group, e.g. "Delivery" */
+ categoryLabel: string;
/** how many other skills reference this one */
inDegree: number;
/** how many other skills this one references */
outDegree: number;
+ /**
+ * Skills that keep company with this one: they are pulled in by the same
+ * third skill. Not an edge in the graph — a co-reference, ranked by how
+ * many skills name both — and the closest thing the data has to "you will
+ * probably want these together".
+ */
+ oftenUsedWith: string[];
};
export type SkillEdge = { source: string; target: string };
@@ -55,7 +73,7 @@ export function getSkillGraph(): SkillGraph {
const ids = readSkillDirs(root);
const known = new Set(ids);
- const meta = new Map();
+ const meta = new Map();
const targets = new Map>();
const dangling: { source: string; token: string }[] = [];
@@ -72,9 +90,10 @@ export function getSkillGraph(): SkillGraph {
}
if (path.basename(file) === "SKILL.md") {
- const { frontmatter } = extractFrontmatter(text);
+ const { frontmatter, body } = extractFrontmatter(text);
meta.set(id, {
label: frontmatter?.find((f) => f.key === "name")?.value ?? `cmk:${id}`,
+ title: body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? id,
summary: frontmatter?.find((f) => f.key === "description")?.value ?? "",
});
}
@@ -99,13 +118,45 @@ export function getSkillGraph(): SkillGraph {
}
}
- const nodes: SkillNode[] = ids.map((id) => ({
- id,
- label: meta.get(id)?.label ?? `cmk:${id}`,
- summary: meta.get(id)?.summary ?? "",
- inDegree: inDegree.get(id) ?? 0,
- outDegree: targets.get(id)?.size ?? 0,
- }));
+ // Two skills "go together" when some third skill pulls in both. Counting
+ // those shared parents is what turns a reference list into a suggestion:
+ // the graph knows delivery-review and cicd are named side by side, and a
+ // reader looking at one is usually about to want the other.
+ const companions = new Map>(ids.map((id) => [id, new Map()]));
+ for (const id of ids) {
+ const cited = Array.from(targets.get(id) ?? []);
+ for (const a of cited) {
+ const row = companions.get(a);
+ if (!row) continue;
+ for (const b of cited) {
+ if (a === b) continue;
+ row.set(b, (row.get(b) ?? 0) + 1);
+ }
+ }
+ }
+
+ const nodes: SkillNode[] = ids.map((id) => {
+ // Same map the catalog groups by, so a skill cannot be filed under
+ // "Delivery" in one surface and somewhere else in the other.
+ const category = CATEGORY_MAP[id] ?? "other";
+ const summary = meta.get(id)?.summary ?? "";
+ return {
+ id,
+ label: meta.get(id)?.label ?? `cmk:${id}`,
+ title: meta.get(id)?.title ?? id,
+ purpose: skillPurpose(id, summary),
+ trigger: extractTriggers(summary)[0] ?? "",
+ summary,
+ category,
+ categoryLabel: CATEGORY_LABELS[category] ?? category,
+ inDegree: inDegree.get(id) ?? 0,
+ outDegree: targets.get(id)?.size ?? 0,
+ oftenUsedWith: Array.from(companions.get(id) ?? [])
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ .slice(0, 3)
+ .map(([other]) => other),
+ };
+ });
return { nodes, edges, dangling };
}
diff --git a/lib/skill-types.ts b/lib/skill-types.ts
index 8b77af3..fa45ed9 100644
--- a/lib/skill-types.ts
+++ b/lib/skill-types.ts
@@ -68,3 +68,68 @@ export const CATEGORY_MAP: Record = {
"sui-devstack": "sui",
interpret: "session",
};
+
+/**
+ * One line saying what a skill is FOR, in the reader's words.
+ *
+ * Presentation-only, exactly like CATEGORY_MAP above: SKILL.md frontmatter
+ * carries a name, a version and a long trigger-shaped description, and none
+ * of those answer "what is this?" in a card-width line. Extracting a clause
+ * from the description reads unevenly across 34 skills, so these are written
+ * and reviewed here, next to the other copy the site owns.
+ *
+ * A skill with no entry falls back to its first trigger phrase, so a new
+ * directory still renders rather than showing a blank line.
+ */
+export const SKILL_PURPOSE: Record = {
+ adr: "Record why an architecture choice was made",
+ "agent-instructions": "Set up CLAUDE.md and AGENTS.md",
+ "agent-vendors": "Vendor skills for each coding agent",
+ cicd: "Set up or speed up CI and deploys",
+ "codebase-docs": "Generate AI-navigable codebase docs",
+ "delivery-handoff": "Hand tracked work to another agent",
+ "delivery-intake": "Pick up a ticket and gather its context",
+ "delivery-pipeline": "Run a ticket end to end, unsupervised",
+ "delivery-review": "Review changes before shipping",
+ "delivery-ship": "Open the PR and close out the ticket",
+ "delivery-simplify": "Clean up a diff without changing behavior",
+ "delivery-spec-plan": "Write the spec and plan before building",
+ "delivery-workflow": "Keep the tracker honest while you work",
+ design: "Decide how to build something",
+ "discover-efforts": "Find what work is already underway",
+ docs: "Bootstrap and audit the docs structure",
+ enclave: "Seal secrets into a TEE enclave",
+ glossary: "Lock the words the team uses",
+ infra: "Structure infrastructure as code",
+ interpret: "Take a stance on another agent's reply",
+ learn: "Capture a gotcha worth remembering",
+ "local-stack": "Run the stack locally, worktree-safe",
+ "mcp-config": "Configure MCP servers for the repo",
+ "project-layout": "Lay out a role-first monorepo",
+ "repo-setup": "Set up a whole repo with the devkit",
+ requirements: "Write requirements and acceptance criteria",
+ rule: "Codify a standard the team follows",
+ rust: "Rust error handling, features and lints",
+ "sui-devstack": "Wire tests to a local Sui stack",
+ "sui-sdk": "Call Sui over gRPC, not JSON-RPC",
+ sync: "Pull upstream skill updates into a repo",
+ "test-resources": "Share fixtures across slow tests",
+ testcontainers: "Start throwaway service containers in tests",
+ toolchain: "Pin versions and assign tool roles",
+};
+
+/** The quoted phrases a description advertises as triggers. */
+export function extractTriggers(description: string): string[] {
+ return Array.from(description.matchAll(/"([^"]+)"/g)).map((m) => m[1]);
+}
+
+/**
+ * What a card and a map node both say about a skill, in the same order, so
+ * the catalog and the map cannot describe the same skill differently.
+ */
+export function skillPurpose(id: string, description: string): string {
+ const curated = SKILL_PURPOSE[id];
+ if (curated) return curated;
+ const trigger = extractTriggers(description)[0];
+ return trigger ? `Ask for it with "${trigger}"` : "";
+}
diff --git a/lib/use-media-query.ts b/lib/use-media-query.ts
new file mode 100644
index 0000000..3105a1b
--- /dev/null
+++ b/lib/use-media-query.ts
@@ -0,0 +1,32 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+/**
+ * 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 anything that syncs the URL would then act on
+ * that guess.
+ *
+ * Both signals are listened for, because a missed `change` leaves the layout
+ * committed to a viewport that no longer exists. `resize` fires on every
+ * viewport change, and React drops the update when the boolean is unchanged,
+ * so the redundancy is nearly free.
+ */
+export function useMediaQuery(query: string): boolean | null {
+ const [matches, setMatches] = useState(null);
+
+ useEffect(() => {
+ const mql = window.matchMedia(query);
+ const update = () => setMatches(mql.matches);
+ update();
+ mql.addEventListener("change", update);
+ window.addEventListener("resize", update);
+ return () => {
+ mql.removeEventListener("change", update);
+ window.removeEventListener("resize", update);
+ };
+ }, [query]);
+
+ return matches;
+}
diff --git a/package-lock.json b/package-lock.json
index 24ea99f..3bf0ffa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
"@gsap/react": "^2.1.2",
"@liquefy-ui/react": "^0.2.0",
"@react-three/fiber": "^9.7.0",
+ "@xyflow/react": "^12.11.5",
"clsx": "^2.1.1",
"gsap": "^3.15.0",
"lucide-react": "^0.468.0",
@@ -1655,6 +1656,55 @@
"tslib": "^2.4.0"
}
},
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-drag": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
+ "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-selection": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
+ "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-transition": {
+ "version": "3.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
+ "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-selection": "*"
+ }
+ },
+ "node_modules/@types/d3-zoom": {
+ "version": "3.0.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
+ "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-interpolate": "*",
+ "@types/d3-selection": "*"
+ }
+ },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2384,6 +2434,76 @@
"win32"
]
},
+ "node_modules/@xyflow/react": {
+ "version": "12.11.5",
+ "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.5.tgz",
+ "integrity": "sha512-QqoryGkqEhWBuQN9bZWRKhwr3Uoj9lCGj/tg0NnIWHHEOXV+5c8cYsc7Q9TST63V92lHqcNV9P5cjvJ9ZAmblQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@xyflow/system": "0.0.81",
+ "classcat": "^5.0.3",
+ "zustand": "^4.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=17",
+ "@types/react-dom": ">=17",
+ "react": ">=17",
+ "react-dom": ">=17"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@xyflow/react/node_modules/zustand": {
+ "version": "4.5.7",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
+ "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
+ "license": "MIT",
+ "dependencies": {
+ "use-sync-external-store": "^1.2.2"
+ },
+ "engines": {
+ "node": ">=12.7.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=16.8",
+ "immer": ">=9.0.6",
+ "react": ">=16.8"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@xyflow/system": {
+ "version": "0.0.81",
+ "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.81.tgz",
+ "integrity": "sha512-hfbafW4i7uLq7ILok8QWFFm4KMFw22lbZNJHKfHOMSOOoCk5e5m8yfr84UV9NaJajmogWaLVnp2XFU9JQejlqg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-drag": "^3.0.7",
+ "@types/d3-interpolate": "^3.0.4",
+ "@types/d3-selection": "^3.0.10",
+ "@types/d3-transition": "^3.0.8",
+ "@types/d3-zoom": "^3.0.8",
+ "d3-drag": "^3.0.0",
+ "d3-interpolate": "^3.0.1",
+ "d3-selection": "^3.0.0",
+ "d3-zoom": "^3.0.0"
+ }
+ },
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -3027,6 +3147,12 @@
"node": ">= 6"
}
},
+ "node_modules/classcat": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
+ "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
+ "license": "MIT"
+ },
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -3113,6 +3239,111 @@
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-dispatch": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+ "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-drag": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
+ "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-selection": "3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-selection": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
+ "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-transition": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
+ "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3",
+ "d3-dispatch": "1 - 3",
+ "d3-ease": "1 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-timer": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "peerDependencies": {
+ "d3-selection": "2 - 3"
+ }
+ },
+ "node_modules/d3-zoom": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
+ "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-dispatch": "1 - 3",
+ "d3-drag": "2 - 3",
+ "d3-interpolate": "1 - 3",
+ "d3-selection": "2 - 3",
+ "d3-transition": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
diff --git a/package.json b/package.json
index 1bad2db..002fca1 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"@gsap/react": "^2.1.2",
"@liquefy-ui/react": "^0.2.0",
"@react-three/fiber": "^9.7.0",
+ "@xyflow/react": "^12.11.5",
"clsx": "^2.1.1",
"gsap": "^3.15.0",
"lucide-react": "^0.468.0",