From 93d966d342192d0e92c427b86ec5f48abe6c68a6 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Sat, 29 Aug 2026 12:41:08 +0700 Subject: [PATCH 1/9] Write down what the skills explorer is for and how it is built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the two problems this work exists to solve — a catalog that costs 34 paragraphs to read, and a reference graph drawn as a fixed ring nobody can explore — with measurable success criteria and IDed acceptance criteria. The design doc carries the contract: the repository stays the only source of truth, placement is deterministic rather than simulated, the list is a peer of the canvas rather than a fallback, and interaction state stays ours while the renderer owns only the viewport. It also records what was rejected and why. A 3D force graph costs a WebGL context and a physics loop on a page whose job is reading, occludes its own labels, degrades badly on touch, and has no honest accessible equivalent. It is prior art here, not a later phase. --- docs/design/skills-explorer.md | 218 +++++++++++++++++++++++++ docs/requirements/skills-explorer.md | 230 +++++++++++++++++++++++++++ 2 files changed, 448 insertions(+) create mode 100644 docs/design/skills-explorer.md create mode 100644 docs/requirements/skills-explorer.md diff --git a/docs/design/skills-explorer.md b/docs/design/skills-explorer.md new file mode 100644 index 0000000..87a8b65 --- /dev/null +++ b/docs/design/skills-explorer.md @@ -0,0 +1,218 @@ +# 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. + +### `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. +- Canvas nodes are focusable, expose an accessible name including both degree + counts, and toggle selection on Enter or Space. +- 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. + +## 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..709747d --- /dev/null +++ b/docs/requirements/skills-explorer.md @@ -0,0 +1,230 @@ +# 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. + +### 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. + +### 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. + +## 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 From c09fbffbdcd20b7e988902cf89e158ab3de1d75e Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Sat, 29 Aug 2026 12:41:08 +0700 Subject: [PATCH 2/9] Replace the skill ring with a pannable, deterministic 2D map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The circular layout gave every skill identical weight, carried no grouping, and could not be rearranged, so it answered "are these connected" and nothing else. Skills now sit in fixed category lanes, alphabetical within a lane, and the same graph produces the same map on every load — a picture you can refer back to next week is worth more than one that looks alive. React Flow owns the viewport: pan, zoom, node drag, minimap and controls. Selection, pinning, URL sync and persistence stay in the view, so the renderer is replaceable and the inspector renders directly rather than behind an animation that could leave it blank. Dragged positions live in sessionStorage under a versioned key; a payload of the wrong version or shape is discarded whole rather than half-restored, and Reset layout returns to canonical. The list is a first-class mode, not a fallback: it carries every skill and both directions of every relationship, it is the default under 768px, and it is what loads when the canvas is never opened — the renderer is imported only when the canvas view is. --- app/globals.css | 43 ++ app/skills/visualize-interactions/page.tsx | 19 +- components/skills/skill-graph-canvas.tsx | 260 +++++++++ components/skills/skill-graph-view.tsx | 618 ++++++++++----------- lib/skill-graph-layout.ts | 185 ++++++ lib/skill-graph.ts | 16 +- lib/use-media-query.ts | 32 ++ package-lock.json | 231 ++++++++ package.json | 1 + 9 files changed, 1067 insertions(+), 338 deletions(-) create mode 100644 components/skills/skill-graph-canvas.tsx create mode 100644 lib/skill-graph-layout.ts create mode 100644 lib/use-media-query.ts diff --git a/app/globals.css b/app/globals.css index 0628901..ffee4c5 100644 --- a/app/globals.css +++ b/app/globals.css @@ -273,3 +273,46 @@ 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; +} diff --git a/app/skills/visualize-interactions/page.tsx b/app/skills/visualize-interactions/page.tsx index 0a4de63..928f156 100644 --- a/app/skills/visualize-interactions/page.tsx +++ b/app/skills/visualize-interactions/page.tsx @@ -2,7 +2,6 @@ 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"; @@ -13,7 +12,7 @@ export const metadata: Metadata = { }; 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"; + "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)]"; export default function VisualizeInteractionsPage() { const graph = getSkillGraph(); @@ -24,18 +23,10 @@ export default function VisualizeInteractionsPage() {

Visualize interactions

- - Skills reference each other by their cmk: handle, and the result is hub-and-spoke rather than a flat list. - +

+ Every reference between skills, laid out by category. Pick one to trace what it + references and what references it. +

diff --git a/components/skills/skill-graph-canvas.tsx b/components/skills/skill-graph-canvas.tsx new file mode 100644 index 0000000..4b32857 --- /dev/null +++ b/components/skills/skill-graph-canvas.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { + Background, + BackgroundVariant, + Controls, + Handle, + MiniMap, + Position, + ReactFlow, + 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. + +type SkillNodeData = { + label: string; + categoryLabel: string; + outDegree: number; + inDegree: number; + selected: 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} + [{data.count}] +
+ ); +} + +function SkillFlowNodeCard({ id, data }: NodeProps) { + const { label, categoryLabel, outDegree, inDegree, selected, related, dimmed } = data; + + 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 }} + className={`flex cursor-pointer flex-col justify-center gap-0.5 rounded-[10px] border px-3 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)] shadow-[0_0_0_4px_color-mix(in_srgb,var(--skill-node-active)_22%,transparent)]" + : 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-25" : "opacity-100"}`} + > + {/* Edges need anchors to attach to. They carry no affordance of their + own: connecting is disabled, so these exist purely as geometry. */} + + + {label} + + {categoryLabel} + + {outDegree} out · {inDegree} in + + +
+ ); +} + +const NODE_TYPES: NodeTypes = { skill: SkillFlowNodeCard, lane: LaneHeading }; + +export function SkillGraphCanvas({ + nodes, + edges, + lanes, + laneWidth, + selectedId, + positions, + showMiniMap, + reduceMotion, + onSelect, + onPositionsChange, +}: { + nodes: PositionedSkillNode[]; + edges: SkillEdge[]; + lanes: SkillGraphLane[]; + laneWidth: number; + selectedId: string | null; + /** dragged overrides on top of the canonical layout */ + positions: Record; + showMiniMap: boolean; + reduceMotion: boolean; + onSelect: (id: string) => void; + onPositionsChange: (next: Record) => void; +}) { + const instance = useRef | null>(null); + + const relation = useMemo(() => { + if (!selectedId) return null; + const out = new Set(edges.filter((e) => e.source === selectedId).map((e) => e.target)); + const inc = new Set(edges.filter((e) => e.target === selectedId).map((e) => e.source)); + return { out, inc }; + }, [edges, selectedId]); + + 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 }, + 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 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: positions[node.id] ?? node.position, + data: { + label: node.label, + categoryLabel: node.categoryLabel, + outDegree: node.outDegree, + inDegree: node.inDegree, + selected: isSelected, + related, + dimmed: Boolean(selectedId) && !isSelected && related === null, + onSelect, + }, + }; + }); + + return [...laneNodes, ...skillNodes]; + }, [nodes, lanes, laneWidth, positions, relation, selectedId, onSelect]); + + const flowEdges = useMemo( + () => + edges.map((edge) => { + const isOut = selectedId !== null && edge.source === selectedId; + const isIn = selectedId !== null && edge.target === selectedId; + 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: selectedId ? (touched ? 0.9 : 0.05) : 0.28, + }, + }; + }), + [edges, selectedId, reduceMotion], + ); + + // Re-fit when the graph itself changes, not on every selection. + useEffect(() => { + instance.current?.fitView({ padding: 0.16, duration: reduceMotion ? 0 : 300 }); + }, [nodes.length, 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; + }} + onNodeDragStop={handleDragStop} + 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 }} + fitView + fitViewOptions={{ padding: 0.16 }} + minZoom={0.2} + maxZoom={1.8} + className="[&_.react-flow\\_\\_attribution]:hidden" + > + + + {showMiniMap && ( + + )} + + ); +} + +export default SkillGraphCanvas; diff --git a/components/skills/skill-graph-view.tsx b/components/skills/skill-graph-view.tsx index bd62f5a..f4450aa 100644 --- a/components/skills/skill-graph-view.tsx +++ b/components/skills/skill-graph-view.tsx @@ -1,368 +1,342 @@ "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, List, RotateCcw, 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"; -// 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. +// 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 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)"; +const SkillGraphCanvas = dynamic( + () => import("./skill-graph-canvas").then((m) => m.SkillGraphCanvas), + { + ssr: false, + loading: () => ( +
+ Loading the map… +
+ ), + }, +); + +export type SkillGraphViewMode = "canvas" | "list"; -type Pt = { x: number; y: number; angle: number }; +const CANVAS_MIN_WIDTH = "(min-width: 768px)"; -// 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; +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)]" + }`; +} + +function RelationList({ + title, + icon, + color, + ids, + byId, +}: { + title: string; + icon: React.ReactNode; + color: string; + ids: string[]; + byId: Map; +}) { + return ( +
+

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

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

none

+ ) : ( +
+ {ids.map((id) => ( + + {byId.get(id)?.label ?? `cmk:${id}`} + + ))} +
+ )} +
+ ); +} 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; + const reduce = useReducedMotion() ?? false; + const wideEnoughForCanvas = useMediaQuery(CANVAS_MIN_WIDTH); + const showMiniMap = useMediaQuery("(min-width: 1280px)") === true; + + const [selectedId, setSelectedId] = useState(null); + const [requestedView, setRequestedView] = useState(null); + const [positions, setPositions] = useState>({}); + const [initialised, setInitialised] = useState(false); + const deepLinkRead = useRef(false); + + const layout = useMemo(() => layoutSkillGraph(graph.nodes), [graph.nodes]); + const byId = useMemo(() => new Map(graph.nodes.map((n) => [n.id, n])), [graph.nodes]); - // 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. + // 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") ?? params.get("focus")); + if (skill && graph.nodes.some((n) => n.id === skill)) setSelectedId(skill); + + const view = params.get("view"); + if (view === "canvas" || view === "list") setRequestedView(view); + + const stored = readStoredLayout(); + if (stored) setPositions(stored); + 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]); + // 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"); - 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]); + 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"); + params.delete("focus"); + const search = params.toString(); + window.history.replaceState(null, "", search ? `?${search}` : window.location.pathname); + }, [initialised, selectedId, requestedView, wideEnoughForCanvas]); + + 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 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 activeNode = active ? byId.get(active) : null; - const maxDeg = Math.max(...graph.nodes.map((n) => n.inDegree + n.outDegree), 1); + const inspector = selected ? ( +
+
+
+

+ {selected.label} +

+

+ {selected.categoryLabel} · {selected.outDegree} out · {selected.inDegree} in +

+
+ +
+ +
+ {selected.summary && ( +

{selected.summary}

+ )} + } + color="var(--skill-edge-out)" + ids={relations.out} + byId={byId} + /> + } + color="var(--skill-edge-in)" + ids={relations.inc} + byId={byId} + /> +
+ + Open detail + + + Open workspace + +
+
+
+ ) : ( +
+

+ Pick a skill to trace what it references and what references it. +

+
+ ); + + if (graph.nodes.length === 0) { + return ( +
+

No skills were found in this build.

+
+ ); + } return ( -
-
-
+
+
+
- {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 ( - setHovered(node.id)} - onMouseLeave={() => setHovered(null)} - onFocus={() => { - setFocused(node.id); - setHovered(node.id); - }} - onBlur={() => { - setFocused(null); - setHovered(null); - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - toggle(); - } - }} - onClick={toggle} - > - {/* The pin is a halo plus a ring plus a fill, so it survives - hovering another node and does not rely on colour alone. */} - {isPinned && ( - - )} - {isFocused && ( - - )} - - {/* generous invisible hit area — the dots are small */} - - - {node.id} - - - ); - })} - - + {view === "canvas" && ( + )} - {view === "graph" && ( -
+ {view === "canvas" && ( +

- references + references - referenced by + referenced by -

+

)}
- -
- ); -} +
-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} - - ))} + +
+ + {/* Below lg the inspector is a bottom sheet: a 320px rail would leave + neither the map nor the panel usable. */} + {selected && ( +
+
{inspector}
)}
diff --git a/lib/skill-graph-layout.ts b/lib/skill-graph-layout.ts new file mode 100644 index 0000000..2cc5e3f --- /dev/null +++ b/lib/skill-graph-layout.ts @@ -0,0 +1,185 @@ +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 = 1; + +const STORAGE_KEY = "ai-devkit-skill-graph-layout"; + +const NODE_WIDTH = 188; +const NODE_HEIGHT = 56; +const COLUMN_GAP = 84; +const ROW_GAP = 26; +const LANE_GAP = 72; +const LANE_HEADER = 40; + +/** 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; +}; + +/** + * 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. + const largest = Math.max(1, ...Array.from(byCategory.values(), (list) => list.length)); + const columns = Math.min(5, Math.max(3, Math.ceil(Math.sqrt(largest) + 1))); + + 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, + }; +} + +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..646f0da 100644 --- a/lib/skill-graph.ts +++ b/lib/skill-graph.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { extractFrontmatter } from "./frontmatter"; +import { CATEGORY_LABELS, CATEGORY_MAP } from "./skill-types"; export type SkillNode = { /** directory name, e.g. "delivery-review" */ @@ -8,6 +9,10 @@ export type SkillNode = { /** frontmatter name, e.g. "cmk:delivery-review" */ label: 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 */ @@ -99,13 +104,20 @@ export function getSkillGraph(): SkillGraph { } } - const nodes: SkillNode[] = ids.map((id) => ({ + 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"; + return { id, label: meta.get(id)?.label ?? `cmk:${id}`, summary: meta.get(id)?.summary ?? "", + category, + categoryLabel: CATEGORY_LABELS[category] ?? category, inDegree: inDegree.get(id) ?? 0, outDegree: targets.get(id)?.size ?? 0, - })); + }; + }); return { nodes, edges, dangling }; } 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", From 8c19474cb38493016ebebf884f34803b306ba4e4 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Sat, 29 Aug 2026 12:41:08 +0700 Subject: [PATCH 3/9] Make the catalog something you scan rather than read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Choosing one skill out of 34 meant reading 34 summary paragraphs, while the things that actually decide the choice — the handle you would type and the phrase that triggers it — were buried inside prose written for another purpose. A card now carries the handle, the title, the category and one trigger phrase. Summaries, file counts and reference counts move to detail, where someone is comparing rather than scanning. The header is a word and a sentence. Sort and capability flags fold behind one Filters control, with any active filter shown as a chip you can remove. The default view groups by category, because at 34 skills across nine groups that is the first useful cut and a flat alphabetical wall makes the reader do the grouping themselves. Search, ranking, filters and every deep link behave exactly as before. --- app/skills/page.tsx | 22 +-- components/skills/skill-catalog.tsx | 217 ++++++++++++++++++---------- 2 files changed, 146 insertions(+), 93 deletions(-) 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/components/skills/skill-catalog.tsx b/components/skills/skill-catalog.tsx index fcbf6d2..98e3f97 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,27 +121,23 @@ 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} -

- -
- {/* 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. */} - + {/* The phrase you would actually type is the strongest recognition + signal a card can carry. Summaries and counts live in detail, where + someone is comparing rather than scanning. */} +
+ {firstTrigger ? ( <> Use when @@ -174,16 +146,25 @@ function SkillCard({ +{restTriggers.length} )} - ) : null} + ) : ( + No trigger phrase + )} - Open workspace - +
); @@ -208,6 +189,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 +312,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 +379,53 @@ export function SkillCatalog({ )}
-
+ {filtersOpen && ( +
+ +
+ )} +
setCategory(ALL)}> All {skills.length} @@ -399,13 +435,24 @@ export function SkillCatalog({ {c.label} {c.count} ))} -
+ + {activeFilters.length > 0 && ( +
+ {activeFilters.map((filter) => ( + + ))} +
+ )} ); @@ -429,8 +476,9 @@ export function SkillCatalog({ ) : ( -
- {results.map((skill) => ( + (() => { + const gridClass = splitView ? "flex flex-col gap-2.5" : "grid gap-2.5 sm:grid-cols-2 xl:grid-cols-3"; + 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 ( From 79b8573c07ca304910ca0ab0ef0136ca36d65a68 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Sat, 29 Aug 2026 12:41:08 +0700 Subject: [PATCH 4/9] Point the roadmap and changelog at what actually shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two roadmap items PR #15 delivered were still sitting in "now" after it merged. They become one shipped entry referencing the merge commit, and this work takes their place as current. Adds the changelog entry for the quieter catalog and the relationship map, including that detail, workspace and browser-local drafts are unchanged — this release changed how you find a skill, not what you can do with it. --- app/changelog/page.tsx | 12 ++++++++++++ components/marketing/roadmap/data.ts | 23 ++++++++++++----------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/app/changelog/page.tsx b/app/changelog/page.tsx index ce2380f..dd4088a 100644 --- a/app/changelog/page.tsx +++ b/app/changelog/page.tsx @@ -39,6 +39,18 @@ 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 quieter skills catalog and an interactive relationship map", + items: [ + "The **Skills catalog** is category-first and much more compact — a card carries the handle, the title and the phrase that triggers it, so choosing a workflow no longer means reading 34 descriptions.", + "**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.", + "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 16](${REPO}/pull/16)`, + ], + }, { date: "August 14, 2026", shortDate: "Aug 14", diff --git a/components/marketing/roadmap/data.ts b/components/marketing/roadmap/data.ts index 23b093e..5b2d717 100644 --- a/components/marketing/roadmap/data.ts +++ b/components/marketing/roadmap/data.ts @@ -53,12 +53,11 @@ 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 + relationship canvas", + description: "A quieter category-first catalog and a pan-and-zoom map for tracing how skills reference each other.", }, { id: "repo-meta-rate-limit", @@ -68,13 +67,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 +75,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", From 160788780f65f48bb31dd997b034596327285d50 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Sat, 29 Aug 2026 12:42:10 +0700 Subject: [PATCH 5/9] Point the roadmap and changelog at the real PR number The changelog entry and the roadmap item were written before the PR existed, so both carried a guessed number. They name PR #24 now. --- app/changelog/page.tsx | 2 +- components/marketing/roadmap/data.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/changelog/page.tsx b/app/changelog/page.tsx index dd4088a..b09c8b8 100644 --- a/app/changelog/page.tsx +++ b/app/changelog/page.tsx @@ -48,7 +48,7 @@ const ENTRIES: ChangelogEntry[] = [ "**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.", "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 16](${REPO}/pull/16)`, + `[$ gh pr view 24](${REPO}/pull/24)`, ], }, { diff --git a/components/marketing/roadmap/data.ts b/components/marketing/roadmap/data.ts index 5b2d717..b842e85 100644 --- a/components/marketing/roadmap/data.ts +++ b/components/marketing/roadmap/data.ts @@ -58,6 +58,7 @@ export const ROADMAP_ITEMS: RoadmapItem[] = [ category: "Website", title: "Minimal skills explorer + relationship canvas", description: "A quieter category-first catalog and a pan-and-zoom map for tracing how skills reference each other.", + pr: `${REPO}/pull/24`, }, { id: "repo-meta-rate-limit", From d9a8968a0018cff8429b39fb92b4014dc9a82a4a Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Sat, 29 Aug 2026 12:54:29 +0700 Subject: [PATCH 6/9] Make the canvas do what the docs already said it did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review against the plan found three things I had written down as true and not built, plus one that would have shipped broken. Hover tracing did not exist. The requirements doc carried an acceptance criterion for it and the design doc described it, which is worse than a missing feature — the docs were lying. Hover now traces a skill while the pin keeps its own marker and the inspector stays on the pinned skill. The pin's halo never rendered. It was written as a Tailwind arbitrary shadow containing a color-mix() with commas, which the class parser drops silently, so a pinned node and a hovered node were pixel-identical. It is an inline style now, which is exactly the distinction this component exists to keep. Nodes carried no declared size, so React Flow held every one at visibility: hidden until it had measured them. Measurement rides on ResizeObserver delivery, and where that is throttled the entire map renders blank — and hidden nodes are unfocusable, which is why keyboard access to the canvas did not work either. The layout knows every size, so the nodes say so. The mobile inspector was a bare sheet. It is a dialog now, with a label, focus moved in, Tab trapped and Escape to close, matching the workspace file drawer. The catalog also drops to two columns at 1280 to match the agreed matrix; a third column has nowhere to live, since the inspector takes over at 1440. --- components/skills/skill-catalog.tsx | 5 +- components/skills/skill-graph-canvas.tsx | 73 ++++++++++++++++++------ components/skills/skill-graph-view.tsx | 46 ++++++++++++++- docs/design/skills-explorer.md | 19 +++++- 4 files changed, 121 insertions(+), 22 deletions(-) diff --git a/components/skills/skill-catalog.tsx b/components/skills/skill-catalog.tsx index 98e3f97..849523c 100644 --- a/components/skills/skill-catalog.tsx +++ b/components/skills/skill-catalog.tsx @@ -477,7 +477,10 @@ export function SkillCatalog({ ) : ( (() => { - const gridClass = splitView ? "flex flex-col gap-2.5" : "grid gap-2.5 sm:grid-cols-2 xl:grid-cols-3"; + // 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) => ( void; @@ -57,7 +59,7 @@ function LaneHeading({ data }: NodeProps>) { } function SkillFlowNodeCard({ id, data }: NodeProps) { - const { label, categoryLabel, outDegree, inDegree, selected, related, dimmed } = data; + const { label, categoryLabel, outDegree, inDegree, selected, traced, related, dimmed } = data; return (
) { data.onSelect(id); } }} - style={{ width: SKILL_NODE_SIZE.width, height: SKILL_NODE_SIZE.height }} + 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 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)] shadow-[0_0_0_4px_color-mix(in_srgb,var(--skill-node-active)_22%,transparent)]" - : 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)]" + ? "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-25" : "opacity-100"}`} > {/* Edges need anchors to attach to. They carry no affordance of their @@ -106,10 +120,12 @@ export function SkillGraphCanvas({ lanes, laneWidth, selectedId, + hoveredId, positions, showMiniMap, reduceMotion, onSelect, + onHover, onPositionsChange, }: { nodes: PositionedSkillNode[]; @@ -117,21 +133,27 @@ export function SkillGraphCanvas({ lanes: SkillGraphLane[]; laneWidth: number; selectedId: string | null; + hoveredId: string | null; /** dragged overrides on top of the canonical layout */ positions: Record; showMiniMap: boolean; reduceMotion: boolean; onSelect: (id: string) => void; + onHover: (id: string | null) => void; onPositionsChange: (next: Record) => void; }) { const instance = useRef | null>(null); + // 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 relation = useMemo(() => { - if (!selectedId) return null; - const out = new Set(edges.filter((e) => e.source === selectedId).map((e) => e.target)); - const inc = new Set(edges.filter((e) => e.target === selectedId).map((e) => e.source)); + 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, selectedId]); + }, [edges, tracedId]); const flowNodes = useMemo(() => { // Lane headings are nodes rather than an overlay, so the category @@ -140,6 +162,13 @@ export function SkillGraphCanvas({ 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: 24, data: { label: lane.label, count: lane.count, width: laneWidth }, draggable: false, selectable: false, @@ -149,6 +178,7 @@ export function SkillGraphCanvas({ 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) @@ -158,27 +188,30 @@ export function SkillGraphCanvas({ id: node.id, type: "skill" as const, position: positions[node.id] ?? node.position, + width: SKILL_NODE_SIZE.width, + height: SKILL_NODE_SIZE.height, data: { label: node.label, categoryLabel: node.categoryLabel, outDegree: node.outDegree, inDegree: node.inDegree, selected: isSelected, + traced: isTraced && !isSelected, related, - dimmed: Boolean(selectedId) && !isSelected && related === null, + dimmed: Boolean(tracedId) && !isTraced && !isSelected && related === null, onSelect, }, }; }); return [...laneNodes, ...skillNodes]; - }, [nodes, lanes, laneWidth, positions, relation, selectedId, onSelect]); + }, [nodes, lanes, laneWidth, positions, relation, selectedId, tracedId, onSelect]); const flowEdges = useMemo( () => edges.map((edge) => { - const isOut = selectedId !== null && edge.source === selectedId; - const isIn = selectedId !== null && edge.target === selectedId; + const isOut = tracedId !== null && edge.source === tracedId; + const isIn = tracedId !== null && edge.target === tracedId; const touched = isOut || isIn; return { id: `${edge.source}->${edge.target}`, @@ -197,11 +230,11 @@ export function SkillGraphCanvas({ ? "var(--skill-edge-in)" : "var(--skill-edge)", strokeWidth: touched ? 1.8 : 0.8, - opacity: selectedId ? (touched ? 0.9 : 0.05) : 0.28, + opacity: tracedId ? (touched ? 0.9 : 0.05) : 0.28, }, }; }), - [edges, selectedId, reduceMotion], + [edges, tracedId, reduceMotion], ); // Re-fit when the graph itself changes, not on every selection. @@ -226,6 +259,10 @@ export function SkillGraphCanvas({ instance.current = i; }} 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. */ diff --git a/components/skills/skill-graph-view.tsx b/components/skills/skill-graph-view.tsx index f4450aa..463e2e6 100644 --- a/components/skills/skill-graph-view.tsx +++ b/components/skills/skill-graph-view.tsx @@ -91,10 +91,12 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { const showMiniMap = useMediaQuery("(min-width: 1280px)") === 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 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]); @@ -134,6 +136,40 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { window.history.replaceState(null, "", search ? `?${search}` : window.location.pathname); }, [initialised, selectedId, requestedView, wideEnoughForCanvas]); + // Below lg the inspector is a sheet over the map, so it gets the same + // treatment as the workspace's file drawer: focus moves in, Escape closes, + // and Tab stays inside while it is open. + const drawerOpen = selectedId !== null && wideEnoughForCanvas !== null; + useEffect(() => { + if (!drawerOpen) return; + const panel = drawerRef.current; + if (!panel || window.matchMedia("(min-width: 1024px)").matches) return; + + panel.querySelector("button, a")?.focus(); + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + setSelectedId(null); + return; + } + if (e.key !== "Tab") return; + const focusable = panel.querySelectorAll('a[href], button:not([disabled])'); + 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); + }, [drawerOpen, selectedId]); + const select = useCallback((id: string) => { setSelectedId((current) => (id === "" || current === id ? null : id)); }, []); @@ -288,10 +324,12 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { lanes={layout.lanes} laneWidth={layout.width} selectedId={selectedId} + hoveredId={hoveredId} positions={positions} showMiniMap={showMiniMap} reduceMotion={reduce} onSelect={select} + onHover={setHoveredId} onPositionsChange={handlePositions} /> ) : ( @@ -335,7 +373,13 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { {/* Below lg the inspector is a bottom sheet: a 320px rail would leave neither the map nor the panel usable. */} {selected && ( -
+
{inspector}
)} diff --git a/docs/design/skills-explorer.md b/docs/design/skills-explorer.md index 87a8b65..c41e3e3 100644 --- a/docs/design/skills-explorer.md +++ b/docs/design/skills-explorer.md @@ -85,6 +85,13 @@ 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. + ### `SkillGraphList` A table of every skill with its outgoing and incoming references as links. Rows @@ -163,8 +170,16 @@ information the canvas draws. - The list view is the complete non-canvas equivalent, and the default under 768px. -- Canvas nodes are focusable, expose an accessible name including both degree - counts, and toggle selection on Enter or Space. +- Below `lg` the inspector is a sheet over the map, with `role="dialog"`, + `aria-modal`, a label, focus moved in on open, Tab trapped inside and Escape + to close — the same contract as the workspace's file drawer. +- 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. From 44957ea7dc75398d437e0a18b7edccacc0a9c89e Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Sat, 29 Aug 2026 15:08:08 +0700 Subject: [PATCH 7/9] Make the canvas the page rather than a panel inside it The map was technically correct and unpleasant to use. It opened fitted to the viewport, so eight lanes were squeezed into a panel and every node rendered at roughly a centimetre of unreadable colour; the shell, the page header, three stat cards and a permanently docked inspector took the rest. It read as a dashboard with a small graph in it. Focus mode. F gives the canvas the whole application viewport: rail, header, heading and stats collapse out of the way, the inspector becomes an overlay, and a floating toolbar carries the controls. It is app level rather than the browser Fullscreen API, and it works by setting one attribute on and collapsing the shell around a canvas that never moves in the React tree, so React Flow is neither unmounted nor duplicated and the viewport survives the switch. Esc peels one layer at a time, R resets the layout, and all three are ignored while a text field has focus. The mode is carried in the URL. Readable by default. The canvas no longer fits the graph on load: with ?skill= it centres that node at zoom 1.1, otherwise it anchors the map's top left corner at zoom 1, and Fit all becomes a deliberate control. Nodes are larger, their type is larger, lanes are three columns wide so nothing starts off screen, and below zoom 0.62 a card drops its metadata row instead of drawing it at sub 8px. Selecting a skill moves the viewport only when the node is off frame or too small to read. Layout version is bumped, since the geometry a stored position describes no longer exists. Less chrome. The heading is two lines and one row of counts instead of three stat cards, the hint moved into a help popover that opens itself once per session, and the docked inspector takes layout only while something is selected, so the empty state is all canvas. Accessibility, from an axe-core 4.13 run over the catalog, both canvas layouts, the list view, the mobile inspector, the detail page and the workspace. That run found four real defects, now fixed: informational text at --text-disabled measuring 2.54:1 to 4.27:1 on the light palette; an aria-label on a bare div; a tablist owning links and buttons instead of only tabs; and, once the close control moved inside its tab, a focusable button nested in a widget. The close affordance is now pointer only and out of the accessibility tree, with Delete or Backspace closing the focused tab. GooeyTextReveal stops asking GSAP for aria, which was putting a prohibited label on a paragraph and hiding every line under it. All seven surfaces now report zero violations at WCAG 2.0 and 2.1 A and AA, and the lowest measured contrast is 5.03:1 dark and 5.35:1 light. --- app/changelog/page.tsx | 1 + app/globals.css | 34 ++ app/skills/layout.tsx | 6 +- app/skills/visualize-interactions/page.tsx | 42 +- components/marketing/roadmap/data.ts | 2 +- components/motion/gooey-text-reveal.tsx | 8 +- components/skills/file-content-pane.tsx | 154 +++--- components/skills/skill-catalog.tsx | 10 +- components/skills/skill-graph-canvas.tsx | 294 +++++++---- components/skills/skill-graph-view.tsx | 539 ++++++++++++++------- docs/design/skills-explorer.md | 74 ++- docs/requirements/skills-explorer.md | 35 ++ lib/skill-graph-layout.ts | 27 +- 13 files changed, 861 insertions(+), 365 deletions(-) diff --git a/app/changelog/page.tsx b/app/changelog/page.tsx index b09c8b8..3f802f0 100644 --- a/app/changelog/page.tsx +++ b/app/changelog/page.tsx @@ -46,6 +46,7 @@ const ENTRIES: ChangelogEntry[] = [ items: [ "The **Skills catalog** is category-first and much more compact — a card carries the handle, the title and the phrase that triggers it, so choosing a workflow no longer means reading 34 descriptions.", "**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)`, diff --git a/app/globals.css b/app/globals.css index ffee4c5..0c89626 100644 --- a/app/globals.css +++ b/app/globals.css @@ -316,3 +316,37 @@ .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(--bg-base); +} + +html[data-skills-focus] .skills-topbar, +html[data-skills-focus] .skills-page-head { + 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/visualize-interactions/page.tsx b/app/skills/visualize-interactions/page.tsx index 928f156..756245c 100644 --- a/app/skills/visualize-interactions/page.tsx +++ b/app/skills/visualize-interactions/page.tsx @@ -1,5 +1,4 @@ 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"; @@ -11,38 +10,25 @@ export const metadata: Metadata = { "How the skills in CommandOSSLabs/ai-devkit reference each other — a graph built 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)]"; - export default function VisualizeInteractionsPage() { const graph = getSkillGraph(); const entryPoints = graph.nodes.filter((n) => n.inDegree === 0).length; return ( -
-
-
-

Visualize interactions

-

- Every reference between skills, laid out by category. Pick one to trace what it - references and what references it. -

-
- -
-
- - {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. Hidden entirely in focus mode. */} +
+

+ Visualize interactions +

+

+ Map how skills connect. Select one to trace its incoming and outgoing references. +

+

+ {graph.nodes.length} skills · {graph.edges.length} links · {entryPoints} entry points +

diff --git a/components/marketing/roadmap/data.ts b/components/marketing/roadmap/data.ts index b842e85..863554d 100644 --- a/components/marketing/roadmap/data.ts +++ b/components/marketing/roadmap/data.ts @@ -57,7 +57,7 @@ export const ROADMAP_ITEMS: RoadmapItem[] = [ status: "now", category: "Website", title: "Minimal skills explorer + relationship canvas", - description: "A quieter category-first catalog and a pan-and-zoom map for tracing how skills reference each other.", + description: "A quieter category-first catalog and a pan-and-zoom map for tracing how skills reference each other, with a full-viewport focus mode for reading it.", pr: `${REPO}/pull/24`, }, { 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/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/skill-catalog.tsx b/components/skills/skill-catalog.tsx index 849523c..e7a8fb2 100644 --- a/components/skills/skill-catalog.tsx +++ b/components/skills/skill-catalog.tsx @@ -140,14 +140,14 @@ function SkillCard({ {firstTrigger ? ( <> - Use when + Use when “{firstTrigger}” {restTriggers.length > 0 && ( - +{restTriggers.length} + +{restTriggers.length} )} ) : ( - No trigger phrase + No trigger phrase )} @@ -438,7 +438,7 @@ export function SkillCatalog({
{activeFilters.length > 0 && ( -
+
{activeFilters.map((filter) => (
-
+
{selected.summary && ( -

{selected.summary}

+

+ {selected.summary} +

)} - } color="var(--skill-edge-out)" ids={relations.out} byId={byId} /> - } color="var(--skill-edge-in)" ids={relations.inc} byId={byId} /> -
- - Open detail - - - Open workspace - -
+
+ +
+ + Open detail + + + Open workspace +
) : ( @@ -264,123 +383,213 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { ); } - return ( -
-
-
- - -
+ const viewToggle = ( +
+ + +
+ ); - {view === "canvas" && ( + const canvasActions = view === "canvas" && ( + <> + + + + + + ); + + const focusToggle = wideEnoughForCanvas ? ( + + ) : null; + + const help = ( +
+ + {hintOpen && ( +
+

Drag to rearrange · Scroll to zoom · Select to trace

+

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

- )} +
+ )} +
+ ); - {view === "canvas" && ( -

- - references - - - referenced by - -

- )} + const canvasPanel = + view === "canvas" ? ( + + ) : ( +
+
    + {graph.nodes.map((node) => { + const on = node.id === selectedId; + return ( +
  • + +
  • + ); + })} +
+ ); + + return ( +
+ {!focus && ( +
+ {viewToggle} + {canvasActions} + {focusToggle} +
+ {view === "canvas" && ( +

+ + references + + + referenced by + +

+ )} + {help} +
+
+ )}
-
- {view === "canvas" ? ( - - ) : ( -
-
    - {graph.nodes.map((node) => { - const on = node.id === selectedId; - return ( -
  • - -
  • - ); - })} -
+
+ {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}
)}
- + {dockInspector && ( + + )}
- {/* Below lg the inspector is a bottom sheet: a 320px rail would leave - neither the map nor the panel usable. */} - {selected && ( + {/* 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}
+ {inspector}
)}
diff --git a/docs/design/skills-explorer.md b/docs/design/skills-explorer.md index c41e3e3..9ce9c58 100644 --- a/docs/design/skills-explorer.md +++ b/docs/design/skills-explorer.md @@ -92,6 +92,54 @@ 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. +### 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 @@ -170,9 +218,23 @@ information the canvas draws. - The list view is the complete non-canvas equivalent, and the default under 768px. -- Below `lg` the inspector is a sheet over the map, with `role="dialog"`, - `aria-modal`, a label, focus moved in on open, Tab trapped inside and Escape - to close — the same contract as the workspace's file drawer. +- 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 @@ -187,6 +249,12 @@ information the canvas draws. 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. +- 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 diff --git a/docs/requirements/skills-explorer.md b/docs/requirements/skills-explorer.md index 709747d..f85195c 100644 --- a/docs/requirements/skills-explorer.md +++ b/docs/requirements/skills-explorer.md @@ -78,6 +78,16 @@ comparing two clusters needs to move things. whether the two clusters share any references, then returns to the canonical layout with one control. +### 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 @@ -140,6 +150,28 @@ still there. - **SKEX-3.6** If stored positions are unreadable or carry a different layout version, the visualization shall discard them and use the canonical layout. +### 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 @@ -171,6 +203,9 @@ still there. 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 diff --git a/lib/skill-graph-layout.ts b/lib/skill-graph-layout.ts index 2cc5e3f..1ac6f7e 100644 --- a/lib/skill-graph-layout.ts +++ b/lib/skill-graph-layout.ts @@ -16,16 +16,19 @@ export type PersistedSkillGraphLayout = { * no longer exists, and restoring them would scatter nodes across lanes that * have moved. */ -export const LAYOUT_VERSION = 1; +export const LAYOUT_VERSION = 2; const STORAGE_KEY = "ai-devkit-skill-graph-layout"; -const NODE_WIDTH = 188; -const NODE_HEIGHT = 56; -const COLUMN_GAP = 84; -const ROW_GAP = 26; -const LANE_GAP = 72; -const LANE_HEADER = 40; +// Sized so a card is readable at zoom 1 without hovering it: the handle, the +// category and the degree counts all have to survive at their own font size, +// because the map opens at a readable zoom rather than fitted to the viewport. +const NODE_WIDTH = 204; +const NODE_HEIGHT = 64; +const COLUMN_GAP = 72; +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 = [ @@ -58,6 +61,8 @@ 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; }; /** @@ -80,8 +85,11 @@ export function layoutSkillGraph(nodes: SkillNode[]): SkillGraphLayout { ); // One column count for every lane, so lanes line up rather than ragging. - const largest = Math.max(1, ...Array.from(byCategory.values(), (list) => list.length)); - const columns = Math.min(5, Math.max(3, Math.ceil(Math.sqrt(largest) + 1))); + // 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[] = []; @@ -119,6 +127,7 @@ export function layoutSkillGraph(nodes: SkillNode[]): SkillGraphLayout { nodes: positioned, lanes, width: columns * NODE_WIDTH + (columns - 1) * COLUMN_GAP, + height: Math.max(0, y - LANE_GAP), }; } From 6c25bdab438573bc24f7d8111a31c34172063355 Mon Sep 17 00:00:00 2001 From: Harry Phan Date: Sat, 29 Aug 2026 16:18:02 +0700 Subject: [PATCH 8/9] Say what the map is for, in the words a reader already has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map was correct and mute. It said references, referenced by, entry points, out and in — accurate about the data structure, silent about the question anyone opens the page with: which skill do I reach for, and what goes with it. A node gave a handle, a category and two numbers, so a reader had to know how connected something was before they could learn what it did. Vocabulary, defined once and used everywhere. References became uses, referenced by became used by, out and in became Uses n and Used by n, graph became map, entry points became starting points, Fit all became Show whole map, and the page is called How skills connect. The map, the browse list, the detail page and the catalog now use the same words, so a reader who works out the direction on one surface does not work it out again on the next. Two things had to exist first. SKILL_PURPOSE is a curated one-line purpose per skill, in the site layer beside CATEGORY_MAP, because frontmatter carries a name, a version and a long trigger-shaped description and none of them answer "what is this?" in a card-width line; extracting a clause reads unevenly across 34 skills, and this way changing one is a copy edit in one file rather than a change to the skills themselves. oftenUsedWith is not an edge: two skills keep company when some third skill pulls in both, and counting those shared parents answers what else a reader will want open, which neither direction of the real edge does. A way in before anything is selected. The rail used to vanish until you clicked something, so the one moment a reader most needs help was the moment the page offered dots. It now holds a start panel: group chips that frame a lane, and the skills nothing else pulls in, with what each leads to. Under 1280 and in focus mode the same groups are a strip over the map, and the browse list carries them as its first two sections. A starting point is a skill nothing pulls in that pulls in something itself — the naive in-degree-zero list includes one skill with no connections at all, and sending someone there is worse than silence. A node card is now a handle, a title and what the skill is for. The counts moved to the panel, where they sit beside the list each one describes. Framing is clamped to the map, and centred against what is visible rather than the element, so a deep link to a skill in the first row no longer opens with a third of the canvas showing nothing, and the focus drawer no longer covers the node the map just centred. Also fixed, found while re-running the audit: the rendered-document and eval scrollers had no focusable child whenever a skill's body happened to carry no links, which left a keyboard user unable to scroll them. 37 end-to-end checks pass against a production build, including axe runs on all seven surfaces at WCAG 2.0 and 2.1 A and AA. --- app/changelog/page.tsx | 6 +- app/skills/visualize-interactions/page.tsx | 15 +- components/marketing/roadmap/data.ts | 4 +- components/skills/eval-view.tsx | 7 +- components/skills/markdown-preview.tsx | 10 +- components/skills/skill-catalog.tsx | 13 +- components/skills/skill-detail.tsx | 8 +- components/skills/skill-graph-canvas.tsx | 158 +++++++-- components/skills/skill-graph-view.tsx | 363 ++++++++++++++++++--- components/skills/skills-sidebar-nav.tsx | 2 +- docs/design/skills-explorer.md | 48 +++ docs/requirements/skills-explorer.md | 33 ++ lib/skill-catalog.ts | 15 +- lib/skill-graph-layout.ts | 15 +- lib/skill-graph.ts | 59 +++- lib/skill-types.ts | 65 ++++ 16 files changed, 692 insertions(+), 129 deletions(-) diff --git a/app/changelog/page.tsx b/app/changelog/page.tsx index 3f802f0..6524433 100644 --- a/app/changelog/page.tsx +++ b/app/changelog/page.tsx @@ -42,9 +42,11 @@ const ENTRIES: ChangelogEntry[] = [ { date: "August 29, 2026", shortDate: "Aug 29", - title: "A quieter skills catalog and an interactive relationship map", + 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 the phrase that triggers it, so choosing a workflow no longer means reading 34 descriptions.", + "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.", diff --git a/app/skills/visualize-interactions/page.tsx b/app/skills/visualize-interactions/page.tsx index 756245c..bd3b8e8 100644 --- a/app/skills/visualize-interactions/page.tsx +++ b/app/skills/visualize-interactions/page.tsx @@ -5,29 +5,30 @@ import { SkillGraphView } from "@/components/skills/skill-graph-view"; 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.", }; export default function VisualizeInteractionsPage() { const graph = getSkillGraph(); - const entryPoints = graph.nodes.filter((n) => n.inDegree === 0).length; return (
{/* 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. Hidden entirely in focus mode. */} + 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. */}

- Visualize interactions + How skills connect

- Map how skills connect. Select one to trace its incoming and outgoing references. + See which skills to use before, after, or alongside the one you are working on.

- {graph.nodes.length} skills · {graph.edges.length} links · {entryPoints} entry points + {graph.nodes.length} skills · {graph.edges.length} connections

diff --git a/components/marketing/roadmap/data.ts b/components/marketing/roadmap/data.ts index 863554d..06b1b15 100644 --- a/components/marketing/roadmap/data.ts +++ b/components/marketing/roadmap/data.ts @@ -56,8 +56,8 @@ export const ROADMAP_ITEMS: RoadmapItem[] = [ id: "skills-explorer-canvas", status: "now", category: "Website", - title: "Minimal skills explorer + relationship canvas", - description: "A quieter category-first catalog and a pan-and-zoom map for tracing how skills reference each other, with a full-viewport focus mode for reading it.", + 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`, }, { 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/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 e7a8fb2..de63bcd 100644 --- a/components/skills/skill-catalog.tsx +++ b/components/skills/skill-catalog.tsx @@ -133,14 +133,19 @@ function SkillCard({
- {/* The phrase you would actually type is the strongest recognition - signal a card can carry. Summaries and counts live in detail, where - someone is comparing rather than scanning. */} + {/* 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}

+ )} +
{firstTrigger ? ( <> - Use when + Ask with “{firstTrigger}” {restTriggers.length > 0 && ( +{restTriggers.length} 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 index 4a2a577..7943964 100644 --- a/components/skills/skill-graph-canvas.tsx +++ b/components/skills/skill-graph-canvas.tsx @@ -35,6 +35,8 @@ export type SkillCanvasApi = { 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. */ @@ -47,6 +49,8 @@ const MAX_ZOOM = 1.8; type SkillNodeData = { label: string; + title: string; + purpose: string; categoryLabel: string; outDegree: number; inDegree: number; @@ -78,7 +82,8 @@ function LaneHeading({ data }: NodeProps>) { } function SkillFlowNodeCard({ id, data }: NodeProps) { - const { label, categoryLabel, outDegree, inDegree, selected, traced, related, dimmed } = data; + 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); @@ -88,7 +93,7 @@ function SkillFlowNodeCard({ id, data }: NodeProps) { role="button" tabIndex={0} aria-pressed={selected} - aria-label={`${label}, ${categoryLabel}. ${outDegree} references out, ${inDegree} in.`} + aria-label={`${label}. ${title}. ${purpose}. ${categoryLabel}. Uses ${outDegree} skills, used by ${inDegree}.`} onClick={() => data.onSelect(id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { @@ -107,7 +112,7 @@ function SkillFlowNodeCard({ id, data }: NodeProps) { ? "0 0 0 4px color-mix(in srgb, var(--skill-node-active) 24%, transparent)" : undefined, }} - className={`flex cursor-pointer flex-col justify-center gap-1 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)] ${ + 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 @@ -123,16 +128,21 @@ function SkillFlowNodeCard({ id, data }: NodeProps) { 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 && ( - - {categoryLabel} - - {outDegree} out · {inDegree} in + <> + + {title} - + + {purpose} + + )}
); @@ -148,8 +158,11 @@ export function SkillGraphCanvas({ graphHeight, selectedId, hoveredId, + initialLane, positions, showMiniMap, + miniMapSide = "left", + rightInset = 0, reduceMotion, onSelect, onHover, @@ -163,9 +176,16 @@ export function SkillGraphCanvas({ 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; @@ -177,6 +197,9 @@ export function SkillGraphCanvas({ // 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 @@ -198,18 +221,27 @@ export function SkillGraphCanvas({ return { out, inc }; }, [edges, tracedId]); - /** Absolute placement, computed from the container box rather than from - * React Flow's measured size: measurement rides on ResizeObserver, and the - * first frame must land exactly whether or not that has been delivered. */ - const centerOn = useCallback((cx: number, cy: number, zoom: number, duration: number) => { - const inst = instance.current; - const box = wrapper.current?.getBoundingClientRect(); - if (!inst || !box || box.width === 0) return; - inst.setViewport( - { x: box.width / 2 - cx * zoom, y: box.height / 2 - cy * zoom, zoom }, - { duration }, - ); - }, []); + /** + * 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 @@ -227,9 +259,20 @@ export function SkillGraphCanvas({ if (!box || box.width === 0) return; const zoom = Math.max( MIN_ZOOM, - Math.min(1, (box.width - 72) / laneWidth, (box.height - 72) / Math.max(1, graphHeight)), + 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 }, ); - centerOn(laneWidth / 2, graphHeight / 2, zoom, reduceMotion ? 0 : 280); }, zoomIn: () => instance.current?.zoomIn({ duration: reduceMotion ? 0 : 160 }), zoomOut: () => instance.current?.zoomOut({ duration: reduceMotion ? 0 : 160 }), @@ -255,12 +298,44 @@ export function SkillGraphCanvas({ sx > insetX && sx < box.width - insetX && sy > insetY && sy < box.height - insetY; if (!force && inFrame && nextZoom === vp.zoom) return; - centerOn(cx, cy, nextZoom, reduceMotion ? 0 : 280); + 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 }); }, }), - [centerOn, graphHeight, laneWidth, reduceMotion], + [clamped, graphHeight, lanes, laneWidth, reduceMotion, rightInset], ); + const apiRef = useRef(api); + apiRef.current = api; + useEffect(() => { onReady?.(api); return () => onReady?.(null); @@ -303,6 +378,8 @@ export function SkillGraphCanvas({ height: SKILL_NODE_SIZE.height, data: { label: node.label, + title: node.title, + purpose: node.purpose, categoryLabel: node.categoryLabel, outDegree: node.outDegree, inDegree: node.inDegree, @@ -371,16 +448,31 @@ export function SkillGraphCanvas({ // 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) { - centerOn( - start.x + SKILL_NODE_SIZE.width / 2, - start.y + SKILL_NODE_SIZE.height / 2, - 1.1, - 0, - ); + // 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(); @@ -406,11 +498,11 @@ export function SkillGraphCanvas({ )} diff --git a/components/skills/skill-graph-view.tsx b/components/skills/skill-graph-view.tsx index f7c6c97..b5413ab 100644 --- a/components/skills/skill-graph-view.tsx +++ b/components/skills/skill-graph-view.tsx @@ -75,23 +75,30 @@ function RelationGroup({ title, icon, color, + note, ids, byId, + onPick, }: { title: string; - icon: React.ReactNode; - color: 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} - {title} ({ids.length}) + + + {icon && {icon}} + {title} + + {note}
{ids.length === 0 ? ( @@ -99,13 +106,14 @@ function RelationGroup({ ) : (
{ids.map((id) => ( - onPick(id)} className="rounded-full border border-[var(--border-subtle)] px-2 py-0.5 font-mono text-[11.5px] text-[var(--text-secondary)] transition-colors hover:border-[var(--skill-node)] hover:text-[color:var(--skill-node)]" > {byId.get(id)?.label ?? `cmk:${id}`} - + ))}
)} @@ -114,6 +122,82 @@ function RelationGroup({ ); } +/** + * 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() ?? false; const wideEnoughForCanvas = useMediaQuery(CANVAS_MIN_WIDTH); @@ -127,6 +211,7 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { 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); @@ -260,12 +345,18 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { // 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. - const dockInspector = canDockInspector && !focus && selectedId !== null; + // 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; @@ -300,6 +391,48 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { }; }, [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[] }; @@ -316,8 +449,15 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) {

{selected.label}

-

- {selected.categoryLabel} · {selected.outDegree} out · {selected.inDegree} in +

+ {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"}

) : ( -
-

- Pick a skill to trace what it references and what references it. -

-
+ ); if (graph.nodes.length === 0) { @@ -386,7 +547,7 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { const viewToggle = (
); @@ -420,11 +581,11 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { ); @@ -455,7 +616,8 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { {hintOpen && (
-

Drag to rearrange · Scroll to zoom · Select to trace

+

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

+

Drag to rearrange · Scroll to zoom

F focus · R reset ·{" "} Esc back @@ -482,8 +644,13 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { graphHeight={layout.height} selectedId={selectedId} hoveredId={hoveredId} + initialLane={pendingLane} positions={positions} showMiniMap={focus ? wideEnoughForCanvas === true : canDockInspector} + miniMapSide={focus ? "left" : "right"} + /* In focus mode the panel floats over the map, so "centred" has to + mean centred in what is left of it. */ + rightInset={focus && selectedId ? 344 : 0} reduceMotion={reduce} onSelect={select} onHover={setHoveredId} @@ -491,34 +658,94 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { onReady={handleReady} /> ) : ( -

-
    - {graph.nodes.map((node) => { - const on = node.id === selectedId; - 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) => ( -
  • - ); - })} -
+ ))} +
+ + )} + +
+

+ Browse by group +

+
+ {groups.map((group) => ( + + ))} +
+
+ +
+

+ All skills +

+
    + {graph.nodes.map((node) => { + const on = node.id === selectedId; + return ( +
  • + +
  • + ); + })} +
+
); @@ -533,10 +760,10 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) { {view === "canvas" && (

- references + uses - referenced by + used by

)} @@ -565,10 +792,40 @@ export function SkillGraphView({ graph }: { graph: SkillGraph }) {
{help}
)} + + {/* Wherever the rail cannot fit — focus mode, or a viewport under + 1280 — the way in becomes a strip over the map. It costs one row + and it disappears the moment a skill is selected, because from + then on the panel is the way in. */} + {showStartStrip && view === "canvas" && ( +
+ + Start with a group + + {groups.map((group) => ( + + ))} +
+ )}
{dockInspector && ( -