diff --git a/app/SkillPageClient.tsx b/app/SkillPageClient.tsx
index 0f16e1d..6d98914 100644
--- a/app/SkillPageClient.tsx
+++ b/app/SkillPageClient.tsx
@@ -10,6 +10,7 @@ import { MaskedHeading } from "@/components/motion/masked-heading";
import { DecryptReveal } from "@/components/motion/decrypt-reveal";
import BendingMarquee from "@/components/motion/bending-marquee";
import { ParticleScroll } from "@/components/motion/particle-scroll";
+import { SkillsTransitionLink } from "@/components/motion/skills-transition-link";
import { ScrambledInstallCommand, type PkgManager } from "@/components/motion/scrambled-install-command";
import { type RealSkill, CATEGORY_LABELS } from "@/lib/skill-types";
import {
@@ -382,6 +383,17 @@ function Nav({
{repoMeta.stars !== null ? formatStarCount(repoMeta.stars) : "—"}
+
+ Browse Skills
+
+
navigateToSection(e, "quickstart")}
@@ -1233,13 +1245,13 @@ function SkillCatalog({
- Every skill is read live from skills/<id>/SKILL.md — name, description, and version straight from the repo.
+ Every skill is read straight from skills/<id>/SKILL.md when the site is built — name, description, and version out of the repo, not a hand-kept copy.
08.0 Skill Catalog →
-
+
Browse every file →
-
+
diff --git a/app/globals.css b/app/globals.css
index 391a61a..0628901 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -151,3 +151,125 @@
-webkit-font-smoothing: antialiased;
}
+
+/* Glass surfaces: card backgrounds that let the animated background show
+ through. Defined once here so the whole dashboard is tuned from one place
+ — raise the percentage to calm the background down, lower it to let more
+ through. They resolve against whichever --bg-* the active theme sets, so a
+ single definition covers light and dark. Tailwind v3 cannot apply an
+ opacity modifier to a var colour, which is why these exist at all rather
+ than a `/72` suffix at each call site. */
+:root {
+ /* Fallbacks for anything rendered outside a LiquefyProvider. */
+ --glass-surface: color-mix(in srgb, var(--bg-surface) 38%, transparent);
+ --glass-frame: color-mix(in srgb, var(--bg-frame) 30%, transparent);
+ --glass-elevated: color-mix(in srgb, var(--bg-elevated) 40%, transparent);
+ --glass-line: var(--border-subtle);
+ --glass-blur: 12px;
+}
+
+/* Declared on the provider rather than :root on purpose: a custom property is
+ substituted at computed-value time on the element that declares it, and
+ --lq-* only exists from .lq-provider down. Declaring these at :root
+ resolved --lq-glass-soft to its fallback before any card could inherit it,
+ so Liquefy's theme and tint had no effect on the surfaces. */
+.lq-provider {
+ --glass-surface: color-mix(in srgb, var(--bg-surface) 38%, var(--lq-glass-soft));
+ --glass-frame: color-mix(in srgb, var(--bg-frame) 30%, var(--lq-glass-soft));
+ --glass-elevated: color-mix(in srgb, var(--bg-elevated) 40%, var(--lq-glass-soft));
+ --glass-line: var(--lq-line, var(--border-subtle));
+ --glass-blur: var(--lq-blur, 12px);
+}
+
+/* The /skills shell reads long prose and code, so its panels sit much closer
+ to opaque than the marketing pages' glass does. Declared after .lq-provider
+ so it wins on source order at equal specificity, and scoped to the shell so
+ the rest of the site keeps the translucent look. */
+.skills-shell {
+ --glass-surface: color-mix(in srgb, var(--bg-surface) 88%, transparent);
+ --glass-frame: color-mix(in srgb, var(--bg-frame) 90%, transparent);
+ --glass-elevated: color-mix(in srgb, var(--bg-elevated) 88%, transparent);
+}
+
+/* The workspace is the one surface people read and type into for minutes at a
+ time, so it opts out of translucency entirely: nested translucent panels
+ over a moving background is what makes long reading tiring. Declared as
+ token overrides so every nested surface inherits it without each component
+ having to know it is inside a workspace. */
+.skills-workspace {
+ --glass-surface: var(--bg-surface);
+ --glass-frame: var(--bg-frame);
+ --glass-elevated: var(--bg-elevated);
+}
+
+/* Measured, not guessed: on this shell's card surfaces the shared dark
+ --text-tertiary comes out at 3.67:1, under the 4.5:1 WCAG AA asks for at
+ these sizes, and here it carries real information — trigger phrases,
+ categories, the repository snapshot. Brightened locally to 4.76:1, which
+ keeps the hierarchy intact (primary 14.5, secondary 6.8, tertiary 4.8) and
+ leaves the rest of the site's text alone, the way .intro-panel-text
+ already does for the marketing sections. */
+.skills-shell {
+ /* #82AAFF is the site's accent and reads well on dark, but as *text* on a
+ light surface it measures 1.96:1 — unreadable, and it was carrying the
+ card's primary action. Light mode gets a darker blue of the same family;
+ tints and filled buttons keep the original hue, since those are
+ backgrounds rather than text. */
+ --accent: #2F63C7;
+ --text-tertiary: #5C6675;
+}
+
+.skills-shell {
+ /* The graph's colours are meaning, not decoration: a node's fill says
+ whether it is the skill you pinned, and an edge's stroke says which
+ direction the reference runs. WCAG asks meaningful non-text graphics for
+ ~3:1, and the dark palette's #82AAFF/#F472B6 sit near 1.9:1 on a light
+ card. Semantic tokens per theme, so the graph reads in both. */
+ --skill-node: #2F63C7;
+ --skill-node-active: #B4126B;
+ --skill-edge: #94A3B8;
+ --skill-edge-out: #2F63C7;
+ --skill-edge-in: #B4126B;
+}
+
+.dark .skills-shell {
+ --accent: #82AAFF;
+ --text-tertiary: #7C8593;
+ --text-disabled: #6E7684;
+ --skill-node: #82AAFF;
+ --skill-node-active: #F472B6;
+ --skill-edge: var(--border-strong);
+ --skill-edge-out: #82AAFF;
+ --skill-edge-in: #F472B6;
+}
+
+/* Scrollbars: the browser default renders as a bright white bar against the
+ dark surfaces, which reads as a UI element rather than chrome. Tint it to
+ the existing border token so it recedes, in both themes. */
+@layer base {
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-strong) transparent;
+ }
+
+ ::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+ }
+
+ ::-webkit-scrollbar-track,
+ ::-webkit-scrollbar-corner {
+ background: transparent;
+ }
+
+ ::-webkit-scrollbar-thumb {
+ background-color: var(--border-strong);
+ border: 3px solid transparent;
+ background-clip: content-box;
+ border-radius: 9999px;
+ }
+
+ ::-webkit-scrollbar-thumb:hover {
+ background-color: var(--text-disabled);
+ }
+}
diff --git a/app/skills/[skillId]/page.tsx b/app/skills/[skillId]/page.tsx
new file mode 100644
index 0000000..65db1d0
--- /dev/null
+++ b/app/skills/[skillId]/page.tsx
@@ -0,0 +1,73 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { ArrowLeft, ChevronRight } from "lucide-react";
+import { getSkillCatalog, getSkillDocument } from "@/lib/skill-catalog";
+import { getRepoSnapshot } from "@/lib/repo-snapshot";
+import { SkillDetail } from "@/components/skills/skill-detail";
+import { MarkdownPreview } from "@/components/skills/markdown-preview";
+import { RepoSnapshotChip } from "@/components/skills/repo-snapshot-chip";
+import { RememberSkill } from "@/components/skills/remember-skill";
+
+export const dynamic = "force-static";
+export const dynamicParams = false;
+
+export function generateStaticParams() {
+ return getSkillCatalog().map((skill) => ({ skillId: skill.id }));
+}
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ skillId: string }>;
+}): Promise {
+ const { skillId } = await params;
+ const skill = getSkillCatalog().find((s) => s.id === skillId);
+ if (!skill) return {};
+ return {
+ title: `${skill.handle} · AI DevKit Skills`,
+ description: skill.summary || skill.description,
+ };
+}
+
+export default async function SkillDetailPage({ params }: { params: Promise<{ skillId: string }> }) {
+ const { skillId } = await params;
+ const skills = getSkillCatalog();
+ const skill = skills.find((s) => s.id === skillId);
+ if (!skill) notFound();
+
+ const handles = Object.fromEntries(skills.map((s) => [s.id, s.handle]));
+ const document = getSkillDocument(skill.id);
+
+ return (
+
+
+
+
+
+
+
+
+
+ : null
+ }
+ />
+
+
+ );
+}
diff --git a/app/skills/[skillId]/workspace/page.tsx b/app/skills/[skillId]/workspace/page.tsx
new file mode 100644
index 0000000..31523e2
--- /dev/null
+++ b/app/skills/[skillId]/workspace/page.tsx
@@ -0,0 +1,90 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { ArrowLeft, ChevronRight, ExternalLink } from "lucide-react";
+import { getSkillCatalog } from "@/lib/skill-catalog";
+import { getSkillTreeNodes } from "@/lib/skills-tree";
+import { REPO_SKILLS_TREE } from "@/lib/repo-links";
+import SkillTreeBrowser from "@/components/skills/skill-tree-browser";
+import { RememberSkill } from "@/components/skills/remember-skill";
+
+export const dynamic = "force-static";
+export const dynamicParams = false;
+
+export function generateStaticParams() {
+ return getSkillCatalog().map((skill) => ({ skillId: skill.id }));
+}
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ skillId: string }>;
+}): Promise {
+ const { skillId } = await params;
+ const skill = getSkillCatalog().find((s) => s.id === skillId);
+ if (!skill) return {};
+ return {
+ title: `${skill.handle} workspace · AI DevKit Skills`,
+ description: `Read and mark up every file in ${skill.handle} — SKILL.md, references, and scripts.`,
+ };
+}
+
+export default async function SkillWorkspacePage({ params }: { params: Promise<{ skillId: string }> }) {
+ const { skillId } = await params;
+ const skill = getSkillCatalog().find((s) => s.id === skillId);
+ const tree = getSkillTreeNodes(skillId);
+ if (!skill || !tree) notFound();
+
+ return (
+
+
+
+
+
+
{skill.handle}
+
+ {skill.summary || skill.description}
+
+
+
+ {skill.files.length} files · read, mark up locally, copy out. Edits stay in this browser.
+
+ );
+}
diff --git a/app/skills/layout.tsx b/app/skills/layout.tsx
new file mode 100644
index 0000000..07a7182
--- /dev/null
+++ b/app/skills/layout.tsx
@@ -0,0 +1,71 @@
+import Link from "next/link";
+import { Terminal, ArrowLeft } from "lucide-react";
+import {
+ SkillsSidebarNav,
+ SkillsSectionTitle,
+} from "@/components/skills/skills-sidebar-nav";
+import { SkillsIconRail } from "@/components/skills/skills-icon-rail";
+import { SkillsBackdrop } from "@/components/skills/skills-backdrop";
+import { ThemeToggle } from "@/components/skills/theme-toggle";
+import { LiquefyTheme } from "@/components/skills/liquefy-theme";
+
+export default function SkillsLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {/* z-20, above the content column's z-10: backdrop-filter makes this
+ aside a stacking context, so the rail's hover tooltips cannot escape
+ it on z-index alone. At equal z the later sibling wins and the
+ content panel painted over them, leaving a sliver at the rail edge. */}
+
+
+
+
+
+
+
+
+
+
+ ai-devkit
+
+
+
+ ai-devkit
+ |
+
+
+
+
+
+
+
+
+ Back to catalog
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/app/skills/not-found.tsx b/app/skills/not-found.tsx
new file mode 100644
index 0000000..ec48559
--- /dev/null
+++ b/app/skills/not-found.tsx
@@ -0,0 +1,25 @@
+import Link from "next/link";
+
+// Segment-local so it renders inside app/skills/layout.tsx's sidebar shell
+// (a not-found.tsx only bypasses layouts BELOW it in the tree) — the two
+// stub routes should read as "not built yet" inside the dashboard, not
+// bounce out to Next's generic default 404 page.
+export default function SkillsNotFound() {
+ return (
+
+
+
404
+
Not built yet
+
+ This section doesn't have anything here yet — check back once it's scoped out.
+
+
+ Browse all skills
+
+
+
+ );
+}
diff --git a/app/skills/page.tsx b/app/skills/page.tsx
index eddf0d4..3930875 100644
--- a/app/skills/page.tsx
+++ b/app/skills/page.tsx
@@ -1,60 +1,78 @@
import type { Metadata } from "next";
-import Link from "next/link";
-import { Terminal, ArrowLeft } from "lucide-react";
-import { getSkillsTree } from "@/lib/skills-tree";
-import SkillTreeBrowser from "@/components/skills/skill-tree-browser";
+import { FileText, 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";
export const dynamic = "force-static";
export const metadata: Metadata = {
title: "Browse Skills · AI DevKit Skills",
- description: "Every file under skills/ in CommandOSSLabs/ai-devkit, read live from the repository — SKILL.md, references, and scripts.",
+ description:
+ "Every skill in CommandOSSLabs/ai-devkit — what it does, when to use it, and which skills it works with. Generated from the repository at build time.",
};
+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 SkillsBrowsePage() {
- const tree = getSkillsTree();
+ 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 (
-
-
-
-
-
-
-
- ai-devkit
-
-
-
- Back to catalog
-
-
-
-
-
-
-
- Browse skills
-
-
- Every file under skills/ in{" "}
+
+
+ {/* basis + flex-1 rather than shrink-to-fit: GooeyTextReveal re-splits
+ its text whenever its own width changes, so a container sized BY that
+ text re-measures on every split and the reveal restarts forever,
+ leaving the heading permanently blurred out. Sizing the column from
+ the row instead makes the width independent of the split. */}
+
+
+
Browse skills
+
+ Find the right skill for the task you are working on. Every skill in{" "}
CommandOSSLabs/ai-devkit
- , read live from the repository — click a file to read it, not just its name and size.
+ : what it does, when to reach for it, and what it works with. Open one to read it, or jump straight into its
+ files.
+
+
+
+
+
+
+ {skills.length} skills
+
+
+
+ {fileCount} files
+
+
+
-
-
+
);
}
diff --git a/app/skills/playground/page.tsx b/app/skills/playground/page.tsx
new file mode 100644
index 0000000..c624ccb
--- /dev/null
+++ b/app/skills/playground/page.tsx
@@ -0,0 +1,8 @@
+import { redirect } from "next/navigation";
+
+// The playground was never built. A 404 behind a nav item is a broken promise
+// and a "coming soon" page is a surface with nothing in it, so an old link
+// lands on the catalog instead.
+export default function PlaygroundPage() {
+ redirect("/skills");
+}
diff --git a/app/skills/prompt-inputs/page.tsx b/app/skills/prompt-inputs/page.tsx
new file mode 100644
index 0000000..b5fe42f
--- /dev/null
+++ b/app/skills/prompt-inputs/page.tsx
@@ -0,0 +1,34 @@
+import type { Metadata } from "next";
+import { PromptInputDemo } from "@/components/agents/prompt-input-demo";
+import { getSkillExamples } from "@/lib/skill-examples";
+
+export const dynamic = "force-static";
+
+export const metadata: Metadata = {
+ title: "Prompt Inputs · AI DevKit Skills",
+ description: "An auto-growing agent composer with prompt actions, model selection, keyboard submission, and animated send and stop states.",
+};
+
+export default function PromptInputsPage() {
+ const skillExamples = getSkillExamples();
+
+ return (
+
+
+
+
Prompt Inputs
+
+ An auto-growing agent composer with prompt actions, model selection, keyboard submission, and animated send and stop states.
+
+
+
+ Interactive demo
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/skills/visualize-interactions/page.tsx b/app/skills/visualize-interactions/page.tsx
new file mode 100644
index 0000000..0a4de63
--- /dev/null
+++ b/app/skills/visualize-interactions/page.tsx
@@ -0,0 +1,60 @@
+import type { Metadata } from "next";
+import { Share2, Link2, CircleDot } from "lucide-react";
+import { getSkillGraph } from "@/lib/skill-graph";
+import { SkillGraphView } from "@/components/skills/skill-graph-view";
+import { BlurHighlight } from "@/components/ui/blur-highlight";
+
+export const dynamic = "force-static";
+
+export const metadata: Metadata = {
+ title: "Visualize interactions · AI DevKit Skills",
+ description:
+ "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)] backdrop-blur-sm";
+
+export default function VisualizeInteractionsPage() {
+ const graph = getSkillGraph();
+ const entryPoints = graph.nodes.filter((n) => n.inDegree === 0).length;
+
+ return (
+
+
+
+
Visualize interactions
+
+ Skills reference each other by their cmk: handle, and the result is hub-and-spoke rather than a flat list.
+
+
+
+
+
+
+ {graph.nodes.length} skills
+
+
+
+ {graph.edges.length} references
+
+
+
+ {entryPoints} entry points
+
+
+
+
+
+
+ );
+}
diff --git a/app/skills/workspace/page.tsx b/app/skills/workspace/page.tsx
new file mode 100644
index 0000000..2698f2c
--- /dev/null
+++ b/app/skills/workspace/page.tsx
@@ -0,0 +1,18 @@
+import type { Metadata } from "next";
+import { getSkillCatalog } from "@/lib/skill-catalog";
+import { WorkspaceEntry } from "@/components/skills/workspace-entry";
+
+export const dynamic = "force-static";
+
+export const metadata: Metadata = {
+ title: "Skill workspace · AI DevKit Skills",
+ description: "Open a skill's files in the full workspace: preview, source, and local drafts.",
+};
+
+// The shareable workspace address. It resolves to the per-skill workspace
+// rather than rendering one itself, because rendering here would mean shipping
+// every skill's file contents (455KB of markdown) to anyone who opens the
+// route, when a visitor only ever wants one skill's files.
+export default function WorkspaceEntryPage() {
+ return skill.id)} />;
+}
diff --git a/components/agents/prompt-input-demo.tsx b/components/agents/prompt-input-demo.tsx
new file mode 100644
index 0000000..b608ae1
--- /dev/null
+++ b/components/agents/prompt-input-demo.tsx
@@ -0,0 +1,229 @@
+"use client";
+
+import Link from "next/link";
+import { ArrowUpRight, Cpu, FileText, ImagePlus, Puzzle, Rocket, Sparkles, Wind, X, Zap } from "lucide-react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
+import { useEffect, useRef, useState } from "react";
+import type { SkillExample } from "@/lib/skill-examples";
+import { normalizeSkillId } from "@/lib/skill-id";
+import { PromptInput, type PromptAction, type PromptModel } from "./prompt-input";
+
+// Adapted from the component doc's usage example. That example fetches
+// real provider favicons for each model icon via an external service —
+// dropped here since it's a live network dependency unrelated to what this
+// tab demonstrates, replaced with plain icons.
+//
+// The "Use a skill" action is the actual point of this tab: rather than a
+// generic notice, it opens a picker built from skillExamples (read server-
+// side straight off this repo's skills/*/SKILL.md frontmatter — see
+// lib/skill-examples.ts) so picking a skill inserts the REAL trigger
+// phrase its own description advertises, e.g. cmk-adr's "record this
+// decision". That's the concrete example of "what prompt makes a model
+// reach for this skill" — not invented copy.
+
+const MODELS: PromptModel[] = [
+ { value: "claude-sonnet-5", label: "Claude Sonnet 5", icon: },
+ { value: "gpt-5.2", label: "GPT-5.2", icon: },
+ { value: "gemini-3.6-flash", label: "Gemini 3.6 Flash", icon: },
+ { value: "grok-4.5", label: "Grok 4.5", icon: },
+ { value: "mistral-large-3", label: "Mistral Large 3", icon: },
+];
+
+const ACTIONS: PromptAction[] = [
+ {
+ value: "image",
+ label: "Attach image",
+ description: "Add a screenshot or visual reference.",
+ icon: ,
+ },
+ {
+ value: "skill",
+ label: "Use a skill",
+ description: "Insert a real trigger phrase from this repo's skills.",
+ icon: ,
+ },
+ {
+ value: "context",
+ label: "Add context",
+ description: "Include a file with supporting details.",
+ icon: ,
+ },
+];
+
+const DEFAULT_VALUE = "Review the current implementation and suggest the next improvement.";
+
+export function PromptInputDemo({ skillExamples }: { skillExamples: SkillExample[] }) {
+ const reduce = useReducedMotion() ?? false;
+ const timer = useRef(undefined);
+ const [value, setValue] = useState(DEFAULT_VALUE);
+ const [loading, setLoading] = useState(false);
+ const [sent, setSent] = useState();
+ const [notice, setNotice] = useState();
+ const [pickerOpen, setPickerOpen] = useState(false);
+ const [context, setContext] = useState(null);
+
+ // Arriving from a skill's page (…/prompt-inputs?skill=adr) should land you
+ // in that skill's context with its own trigger phrase already typed, rather
+ // than on a blank composer you have to re-find the skill in.
+ useEffect(() => {
+ const requested = normalizeSkillId(new URLSearchParams(window.location.search).get("skill"));
+ if (!requested) return;
+ const skill = skillExamples.find((s) => s.id === requested);
+ if (!skill) return;
+ setContext(skill);
+ if (skill.examples[0]) setValue(skill.examples[0]);
+ }, [skillExamples]);
+
+ useEffect(
+ () => () => {
+ if (timer.current) window.clearTimeout(timer.current);
+ },
+ [],
+ );
+
+ const submit = (prompt: string) => {
+ setSent(undefined);
+ setNotice(undefined);
+ setLoading(true);
+ timer.current = window.setTimeout(() => {
+ setLoading(false);
+ setSent(prompt);
+ setValue("");
+ }, 900);
+ };
+
+ const stop = () => {
+ if (timer.current) window.clearTimeout(timer.current);
+ setLoading(false);
+ };
+
+ const pickExample = (skill: SkillExample, example: string) => {
+ setValue(example);
+ setContext(skill);
+ setPickerOpen(false);
+ setSent(undefined);
+ setNotice(`Inserted ${skill.label}'s own trigger phrase — this is what tells a model to reach for it.`);
+ };
+
+ return (
+
+ {context && (
+
+
+ Prompting in the context of
+
+ {context.label}
+
+
+
+
+
+
+
+ );
+}
+
+export default EvalView;
diff --git a/components/skills/file-content-pane.tsx b/components/skills/file-content-pane.tsx
new file mode 100644
index 0000000..8158bcb
--- /dev/null
+++ b/components/skills/file-content-pane.tsx
@@ -0,0 +1,409 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
+import { AlertTriangle, Code2, ExternalLink, Eye, FileText, Pencil, X } from "lucide-react";
+import type { FileKind } from "@/lib/skills-tree";
+import { fileVisual } from "@/lib/file-icons";
+import { REPO_SKILLS_BLOB } from "@/lib/repo-links";
+import { CodeBlock } from "@/components/ui/code-block";
+import { MarkdownPreview } from "./markdown-preview";
+import { markdownEditorStats } from "@/lib/markdown-stats";
+
+// The editor and its toolbar only matter once someone switches to Edit, and
+// most visits never do — so it loads then rather than riding along in the
+// workspace bundle for every reader.
+const MarkdownEditor = dynamic(() => import("./markdown-editor").then((m) => m.MarkdownEditor), {
+ ssr: false,
+ loading: () => (
+
+ Loading the editor…
+
+ ),
+});
+import { EvalView, parseEvals } from "./eval-view";
+
+// Three local modes over one buffer: Preview renders it, Source shows the
+// bytes, Edit marks it up. They all read the same value, so switching never
+// drops what you typed. "View on GitHub" stays a link out — blame, history
+// and permalinks are jobs a pane in here will never do better.
+//
+// Edits are scratch buffers held by the workspace for as long as the page is
+// open. Nothing here writes to the repository, so nothing here claims to: no
+// Saved state, no autosave, and closing a tab that holds one asks what to do
+// with it rather than deciding silently.
+
+const EXT_TO_LANG: Record = {
+ ts: "typescript",
+ tsx: "tsx",
+ js: "javascript",
+ jsx: "jsx",
+ py: "python",
+ sh: "bash",
+ json: "json",
+ yaml: "yaml",
+ yml: "yaml",
+ md: "markdown",
+};
+
+export type PaneMode = "preview" | "source" | "edit";
+
+function languageFor(filename: string): string {
+ const ext = filename.split(".").pop()?.toLowerCase() ?? "";
+ return EXT_TO_LANG[ext] ?? "text";
+}
+
+function basename(id: string) {
+ return id.split("/").pop() ?? id;
+}
+
+export type ContentFile = {
+ id: string;
+ kind: FileKind;
+ size: string;
+ content: string | null;
+};
+
+export function FileContentPane({
+ openFiles,
+ activeId,
+ activeFile,
+ mode,
+ drafts,
+ onSelectTab,
+ onCloseTab,
+ onModeChange,
+ onDraftChange,
+ onDraftRevert,
+}: {
+ openFiles: ContentFile[];
+ activeId: string | null;
+ activeFile: ContentFile | null;
+ mode: PaneMode;
+ /** file id to local draft text, owned by the workspace so it outlives a tab */
+ drafts: Record;
+ onSelectTab: (id: string) => void;
+ onCloseTab: (id: string) => void;
+ onModeChange: (mode: PaneMode) => void;
+ onDraftChange: (id: string, text: string) => void;
+ onDraftRevert: (id: string) => void;
+}) {
+ const reduce = useReducedMotion();
+ const [pendingClose, setPendingClose] = useState(null);
+ const keepRef = useRef(null);
+
+ const filename = activeFile ? basename(activeFile.id) : "";
+ const lang = filename ? languageFor(filename) : "text";
+ const isMarkdown = lang === "markdown";
+ const shown = activeFile ? (drafts[activeFile.id] ?? activeFile.content) : null;
+ const edited = activeFile
+ ? drafts[activeFile.id] !== undefined && drafts[activeFile.id] !== activeFile.content
+ : false;
+ // eval.json has a known shape worth rendering as a spec rather than raw JSON
+ const evalCases = useMemo(
+ () => (filename === "eval.json" && shown ? parseEvals(shown) : null),
+ [filename, shown],
+ );
+ const isEval = evalCases !== null;
+
+ useEffect(() => {
+ if (!pendingClose) return;
+ keepRef.current?.focus();
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") setPendingClose(null);
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [pendingClose]);
+
+ const hasDraft = (file: ContentFile) => drafts[file.id] !== undefined && drafts[file.id] !== file.content;
+
+ const requestClose = (file: ContentFile) => {
+ if (hasDraft(file)) setPendingClose(file.id);
+ else onCloseTab(file.id);
+ };
+
+ if (openFiles.length === 0 || !activeFile) {
+ return (
+
+
+
Pick a file to read it.
+
+ );
+ }
+
+ const toggleClass = (on: boolean, accent = false) =>
+ `inline-flex h-6 items-center gap-1 rounded-[4px] px-2 text-[11.5px] transition-colors ${
+ on
+ ? accent
+ ? "bg-[#82AAFF]/15 font-medium text-[color:var(--accent)]"
+ : "bg-[var(--bg-surface)] text-[var(--text-primary)]"
+ : "text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
+ }`;
+
+ const pendingName = pendingClose ? basename(pendingClose) : "";
+
+ // One cluster, two homes: inline with the tabs where there is room, on its
+ // own row below lg. Sharing the toolbar with the tabs on a phone pushed the
+ // Preview/Source/Edit switch off the right edge of a scrollable strip, which
+ // is the same as not having it.
+ const actionCluster = (
+ <>
+ {(isEval || (isMarkdown && activeFile.content !== null)) && (
+
+
+
+ {isMarkdown && (
+
+ )}
+
+ )}
+
+
+ GitHub
+
+ >
+ );
+
+ return (
+ <>
+ {/* 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. */}
+
+ It was never written to the repository. Closing the tab keeps the draft for this session, so reopening
+ the file brings it back; reverting throws it away and restores the repository version.
+
+
+
+
+
+
+
+
+ )}
+
+
+
+
+ >
+ );
+}
+
+export default FileContentPane;
diff --git a/components/skills/liquefy-theme.tsx b/components/skills/liquefy-theme.tsx
new file mode 100644
index 0000000..8f14bfb
--- /dev/null
+++ b/components/skills/liquefy-theme.tsx
@@ -0,0 +1,36 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { LiquefyProvider } from "@liquefy-ui/react";
+
+// Liquefy has its own theme prop, and this site already has a theme: the
+// `.dark` class on , set pre-paint by app/layout.tsx and flipped by
+// ThemeToggle. Rather than run two sources of truth that can disagree, this
+// observes that class and feeds it to LiquefyProvider, so the existing
+// toggle drives the --lq-* tokens too.
+//
+// Note the stylesheet imported here is styles.css, not tailwind.css: the
+// latter is explicitly a Tailwind v4 interop layer (`@import 'tailwindcss'`)
+// and this project is on v3.4, so the token sheet is used directly and the
+// --lq-* values are referenced as plain CSS variables.
+import "@liquefy-ui/react/styles.css";
+
+export function LiquefyTheme({ children, tint = "#82AAFF" }: { children: React.ReactNode; tint?: string }) {
+ const [theme, setTheme] = useState<"dark" | "light">("dark");
+
+ useEffect(() => {
+ const read = () => setTheme(document.documentElement.classList.contains("dark") ? "dark" : "light");
+ read();
+ const observer = new MutationObserver(read);
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
+ return () => observer.disconnect();
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default LiquefyTheme;
diff --git a/components/skills/markdown-editor.tsx b/components/skills/markdown-editor.tsx
new file mode 100644
index 0000000..6e6290e
--- /dev/null
+++ b/components/skills/markdown-editor.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import {
+ Bold,
+ Check,
+ Code,
+ Copy,
+ Heading1,
+ Heading2,
+ Italic,
+ Link2,
+ List,
+ ListOrdered,
+ Quote,
+ RotateCcw,
+ Strikethrough,
+} from "lucide-react";
+
+// A markdown "edit" mode for the file pane. These files are the repository's,
+// read at build time rather than user drafts, so this deliberately does NOT
+// simulate a save: no "Saved"/"Saving…" status, nothing persisted to disk
+// or the repo. It's a scratch buffer scoped to the open tab — type, mark up
+// with the toolbar, copy the result out. Closing the tab (or reverting)
+// drops the edit; that's stated plainly in the footer rather than implied.
+
+function currentLine(value: string, pos: number) {
+ const start = value.lastIndexOf("\n", pos - 1) + 1;
+ const nl = value.indexOf("\n", start);
+ const end = nl === -1 ? value.length : nl;
+ return { start, end, text: value.slice(start, end) };
+}
+
+function toggleLinePrefix(value: string, selStart: number, prefix: string) {
+ const { start, text } = currentLine(value, selStart);
+ const already = text.startsWith(prefix);
+ const next = already
+ ? value.slice(0, start) + text.slice(prefix.length) + value.slice(start + text.length)
+ : value.slice(0, start) + prefix + text + value.slice(start + text.length);
+ const caret = Math.max(start, selStart + (already ? -prefix.length : prefix.length));
+ return { next, caret };
+}
+
+function wrapSelection(value: string, selStart: number, selEnd: number, before: string, after: string = before) {
+ const selected = value.slice(selStart, selEnd) || "text";
+ const next = value.slice(0, selStart) + before + selected + after + value.slice(selEnd);
+ return { next, selStart: selStart + before.length, selEnd: selStart + before.length + selected.length };
+}
+
+function ToolButton({
+ onClick,
+ label,
+ children,
+}: {
+ onClick: () => void;
+ label: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ );
+}
+
+export function MarkdownEditor({
+ value,
+ edited,
+ onChange,
+ onRevert,
+}: {
+ value: string;
+ edited: boolean;
+ onChange: (next: string) => void;
+ onRevert: () => void;
+}) {
+ const ref = useRef(null);
+ const [copied, setCopied] = useState(false);
+ // Revert throws the draft away and there is nothing to undo it with, so the
+ // button asks first rather than acting on a single stray click. The pending
+ // state clears itself so it can't sit there armed.
+ const [confirmRevert, setConfirmRevert] = useState(false);
+
+ useEffect(() => {
+ if (!confirmRevert) return;
+ const timer = window.setTimeout(() => setConfirmRevert(false), 4000);
+ return () => window.clearTimeout(timer);
+ }, [confirmRevert]);
+
+ const applyWrap = (before: string, after?: string) => {
+ const el = ref.current;
+ if (!el) return;
+ const { next, selStart, selEnd } = wrapSelection(value, el.selectionStart, el.selectionEnd, before, after);
+ onChange(next);
+ requestAnimationFrame(() => {
+ el.focus();
+ el.setSelectionRange(selStart, selEnd);
+ });
+ };
+
+ const applyLinePrefix = (prefix: string) => {
+ const el = ref.current;
+ if (!el) return;
+ const { next, caret } = toggleLinePrefix(value, el.selectionStart, prefix);
+ onChange(next);
+ requestAnimationFrame(() => {
+ el.focus();
+ el.setSelectionRange(caret, caret);
+ });
+ };
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1500);
+ } catch {
+ // clipboard permission denied — silently no-op, button just won't confirm
+ }
+ };
+
+ return (
+
+ );
+}
+
+export default MarkdownPreview;
diff --git a/components/skills/remember-skill.tsx b/components/skills/remember-skill.tsx
new file mode 100644
index 0000000..e0d2c7e
--- /dev/null
+++ b/components/skills/remember-skill.tsx
@@ -0,0 +1,14 @@
+"use client";
+
+import { useEffect } from "react";
+import { rememberSkill } from "@/lib/recent-skills";
+
+/** Records that this skill was opened, so the catalog can sort by recency. */
+export function RememberSkill({ id }: { id: string }) {
+ useEffect(() => {
+ rememberSkill(id);
+ }, [id]);
+ return null;
+}
+
+export default RememberSkill;
diff --git a/components/skills/repo-snapshot-chip.tsx b/components/skills/repo-snapshot-chip.tsx
new file mode 100644
index 0000000..1fba877
--- /dev/null
+++ b/components/skills/repo-snapshot-chip.tsx
@@ -0,0 +1,38 @@
+import { GitCommitHorizontal } from "lucide-react";
+import type { RepoSnapshot } from "@/lib/repo-snapshot";
+
+// Every /skills page is generated from the repository at build time, so this
+// says exactly that, and names the commit it read when the build environment
+// exposes one. The previous "Live from repository" badge described a runtime
+// fetch this page has never done.
+export function RepoSnapshotChip({ snapshot }: { snapshot: RepoSnapshot }) {
+ const label = snapshot.commit
+ ? `Built from ${snapshot.commit}`
+ : "Generated at build time";
+ const source = [snapshot.branch, snapshot.commit].filter(Boolean).join(" @ ");
+ const title = source
+ ? `Generated from ${source} at build time, ${snapshot.generatedAt}`
+ : `Generated from the repository at build time, ${snapshot.generatedAt}`;
+
+ const body = (
+ <>
+
+ {label}
+ >
+ );
+
+ const className =
+ "flex h-9 items-center gap-1.5 rounded-lg border border-[var(--border-subtle)] bg-[var(--glass-elevated)] px-3 text-[12px] text-[var(--text-tertiary)] transition-colors hover:text-[var(--text-primary)]";
+
+ return snapshot.commitUrl ? (
+
+ {body}
+
+ ) : (
+
+ {body}
+
+ );
+}
+
+export default RepoSnapshotChip;
diff --git a/components/skills/skill-catalog.tsx b/components/skills/skill-catalog.tsx
new file mode 100644
index 0000000..fcbf6d2
--- /dev/null
+++ b/components/skills/skill-catalog.tsx
@@ -0,0 +1,505 @@
+"use client";
+
+import Link from "next/link";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { ArrowUpRight, 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 { readRecentSkills } from "@/lib/recent-skills";
+import { SkillDetail, type SkillHandles } from "./skill-detail";
+
+// The catalog answers "which skill fits what I'm doing", and the panel beside
+// it answers "what is this one, exactly" — so /skills never opens on an empty
+// reader, and picking a card costs nothing. Both are entry points to the
+// workspace, which is a peer surface here, not a later step: every card and
+// the panel both offer Open workspace directly.
+
+type SortKey = "name" | "referenced" | "files" | "recent";
+type FlagKey = "evals" | "references" | "recent";
+
+const SORTS: { value: SortKey; label: string }[] = [
+ { value: "name", label: "Name (A–Z)" },
+ { value: "referenced", label: "Most referenced" },
+ { value: "files", label: "Most files" },
+ { value: "recent", label: "Recently opened" },
+];
+
+// Only flags the repository can actually answer. "Has tests" would match one
+// skill of 34 and "snapshot availability" is the same for every row, so
+// neither earns a control.
+const FLAGS: { value: FlagKey; label: string }[] = [
+ { value: "evals", label: "Has evals" },
+ { value: "references", label: "Has references" },
+ { value: "recent", label: "Recently opened" },
+];
+
+const ALL = "all";
+
+function hasEvals(skill: SkillSummary) {
+ return skill.files.some((f) => f.name === "eval.json");
+}
+
+function hasReferences(skill: SkillSummary) {
+ return skill.files.some((f) => f.relativePath.startsWith("references/"));
+}
+
+/**
+ * Ranked rather than merely filtered: typing "review" should put
+ * cmk:delivery-review above the eight skills that mention reviewing somewhere
+ * in a paragraph. Every term has to match something, and the best field each
+ * term hits decides its weight.
+ */
+function scoreSkill(skill: SkillSummary, terms: string[]): number {
+ if (terms.length === 0) return 1;
+
+ const id = skill.id.toLowerCase();
+ const handle = skill.handle.toLowerCase();
+ const title = skill.title.toLowerCase();
+ const triggers = skill.triggers.join(" ").toLowerCase();
+ const prose = `${skill.summary} ${skill.description}`.toLowerCase();
+ const category = skill.categoryLabel.toLowerCase();
+ const related = [...skill.references, ...skill.referencedBy].join(" ").toLowerCase();
+ const files = skill.files.map((f) => f.relativePath).join(" ").toLowerCase();
+
+ let total = 0;
+ for (const term of terms) {
+ let best = 0;
+ if (id === term || handle === term) best = 100;
+ else if (id.startsWith(term) || handle.startsWith(term)) best = 60;
+ else if (id.includes(term) || handle.includes(term)) best = 40;
+ else if (triggers.includes(term)) best = 30;
+ else if (title.includes(term)) best = 25;
+ else if (prose.includes(term)) best = 15;
+ else if (category.includes(term)) best = 10;
+ else if (related.includes(term)) best = 6;
+ else if (files.includes(term)) best = 4;
+
+ if (best === 0) return 0;
+ total += best;
+ }
+ 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,
+ splitView,
+ onSelect,
+}: {
+ skill: SkillSummary;
+ selected: boolean;
+ splitView: boolean;
+ onSelect: () => void;
+}) {
+ 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.
+ 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. */}
+ {
+ if (!splitView || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ 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"
+ >
+ {skill.handle}
+
+
+
{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. */}
+
+ {firstTrigger ? (
+ <>
+ Use when
+ “{firstTrigger}”
+ {restTriggers.length > 0 && (
+ +{restTriggers.length}
+ )}
+ >
+ ) : null}
+
+
+
+ Open workspace
+
+
+
+
+ );
+}
+
+export function SkillCatalog({
+ skills,
+ categories,
+ handles,
+}: {
+ skills: SkillSummary[];
+ categories: SkillCategoryInfo[];
+ handles: SkillHandles;
+}) {
+ const wideViewport = useMediaQuery("(min-width: 1440px)");
+ const splitView = wideViewport === true;
+
+ const [query, setQuery] = useState("");
+ const [category, setCategory] = useState(ALL);
+ const [sort, setSort] = useState("name");
+ const [flags, setFlags] = useState([]);
+ const [selectedId, setSelectedId] = useState(null);
+ const [recent, setRecent] = useState([]);
+ const [initialised, setInitialised] = useState(false);
+ const inputRef = useRef(null);
+ const initialDeepLinkSkill = useRef(null);
+
+ // Deep links are read once, after mount, rather than through
+ // useSearchParams: this page is statically generated, and reading the
+ // params during render would push it behind a Suspense boundary and trade
+ // the whole catalog for a loading shell on first paint.
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ const q = params.get("query") ?? params.get("q");
+ const c = params.get("category");
+ const s = params.get("sort");
+ const has = params.get("has");
+ const skill = normalizeSkillId(params.get("skill"));
+ initialDeepLinkSkill.current = skill;
+ if (q) setQuery(q);
+ if (c) setCategory(c);
+ if (s && SORTS.some((option) => option.value === s)) setSort(s as SortKey);
+ if (has) setFlags(has.split(",").filter((f): f is FlagKey => FLAGS.some((x) => x.value === f)));
+ if (skill && skills.some((entry) => entry.id === skill)) setSelectedId(skill);
+ setRecent(readRecentSkills());
+ setInitialised(true);
+ }, [skills]);
+
+ const indexed = useMemo(
+ () => skills.map((skill) => ({ skill, evals: hasEvals(skill), references: hasReferences(skill) })),
+ [skills],
+ );
+
+ const results = useMemo(() => {
+ const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
+ const recentSet = new Set(recent);
+
+ const scored = indexed
+ .filter(({ skill, evals, references }) => {
+ if (category !== ALL && skill.category !== category) return false;
+ if (flags.includes("evals") && !evals) return false;
+ if (flags.includes("references") && !references) return false;
+ if (flags.includes("recent") && !recentSet.has(skill.id)) return false;
+ return true;
+ })
+ .map(({ skill }) => ({ skill, score: scoreSkill(skill, terms) }))
+ .filter((entry) => entry.score > 0);
+
+ const byName = (a: SkillSummary, b: SkillSummary) => a.id.localeCompare(b.id);
+ const rank = (id: string) => {
+ const i = recent.indexOf(id);
+ return i === -1 ? Number.MAX_SAFE_INTEGER : i;
+ };
+
+ // A query is itself a ranking, so relevance wins while one is typed.
+ if (terms.length > 0) {
+ scored.sort((a, b) => b.score - a.score || byName(a.skill, b.skill));
+ return scored.map((entry) => entry.skill);
+ }
+
+ const list = scored.map((entry) => entry.skill);
+ switch (sort) {
+ case "referenced":
+ return list.sort((a, b) => b.referencedBy.length - a.referencedBy.length || byName(a, b));
+ case "files":
+ return list.sort((a, b) => b.files.length - a.files.length || byName(a, b));
+ case "recent":
+ return list.sort((a, b) => rank(a.id) - rank(b.id) || byName(a, b));
+ default:
+ return list.sort(byName);
+ }
+ }, [indexed, query, category, flags, sort, recent]);
+
+ // Never leave the panel empty or pointing at a skill the filters just hid.
+ useEffect(() => {
+ // Let the one-time URL reader establish a deep-linked selection first;
+ // otherwise this fallback can race it on the initial mount and select the
+ // alphabetically first skill instead.
+ if (!initialised) return;
+ if (results.length === 0) return;
+ if (selectedId && results.some((skill) => skill.id === selectedId)) return;
+ setSelectedId(results[0].id);
+ }, [initialised, results, selectedId]);
+
+ const selected = results.find((skill) => skill.id === selectedId) ?? results[0] ?? null;
+
+ // Narrow viewports have no panel to select into, so /skills?skill=
+ // means the same thing there that tapping a card does: open that skill's
+ // page. Waiting for `wideViewport` to be measured (not null) keeps a
+ // desktop deep link from being redirected on the first client render.
+ const pendingNarrowDeepLink = wideViewport === false && initialDeepLinkSkill.current !== null;
+
+ useEffect(() => {
+ if (!initialised || wideViewport !== false) return;
+ const id = initialDeepLinkSkill.current;
+ if (!id) return;
+ if (skills.some((skill) => skill.id === id)) {
+ // A hard navigation prevents the catalog's replaceState effect from
+ // restoring the old query URL after the route transition starts.
+ window.location.assign(`/skills/${id}`);
+ }
+ }, [initialised, wideViewport, skills]);
+
+ useEffect(() => {
+ if (!initialised) return;
+ // Don't rewrite the query string out from under a deep link that is still
+ // waiting to be resolved into a route.
+ if (pendingNarrowDeepLink) return;
+ const params = new URLSearchParams();
+ if (query) params.set("query", query);
+ if (category !== ALL) params.set("category", category);
+ if (sort !== "name") params.set("sort", sort);
+ if (flags.length > 0) params.set("has", flags.join(","));
+ // While a panel is on screen the address bar names the skill it shows, so
+ // the link someone copies matches what they are looking at.
+ if (splitView && selected) params.set("skill", selected.id);
+ const search = params.toString();
+ window.history.replaceState(null, "", search ? `?${search}` : window.location.pathname);
+ }, [query, category, sort, flags, splitView, selected, initialised, pendingNarrowDeepLink]);
+
+ const toggleFlag = useCallback((flag: FlagKey) => {
+ setFlags((prev) => (prev.includes(flag) ? prev.filter((f) => f !== flag) : [...prev, flag]));
+ }, []);
+
+ const filtered = query.trim() !== "" || category !== ALL || flags.length > 0;
+
+ const controls = (
+
+ );
+}
+
+export default SkillDetail;
diff --git a/components/skills/skill-graph-view.tsx b/components/skills/skill-graph-view.tsx
new file mode 100644
index 0000000..bd62f5a
--- /dev/null
+++ b/components/skills/skill-graph-view.tsx
@@ -0,0 +1,372 @@
+"use client";
+
+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 { normalizeSkillId } from "@/lib/skill-id";
+
+// A circular (chord) layout rather than a force simulation: with 34 nodes and
+// 108 edges a physics layout settles differently on every load, which makes
+// the picture impossible to refer back to. Fixed positions mean "the hub at
+// the top right" stays the hub at the top right, and it needs no animation
+// frame budget next to the fluid background already running on this page.
+
+const SIZE = 900;
+const C = SIZE / 2;
+const R = 310;
+// Read from the shell's tokens so the graph carries meaning in both themes;
+// see the --skill-* block in globals.css.
+const OUT_COLOR = "var(--skill-edge-out)";
+const IN_COLOR = "var(--skill-edge-in)";
+const NODE_COLOR = "var(--skill-node)";
+const NODE_ACTIVE = "var(--skill-node-active)";
+const EDGE_COLOR = "var(--skill-edge)";
+
+type Pt = { x: number; y: number; angle: number };
+
+// Coordinates are rounded before they reach the DOM. Full-precision floats
+// serialize differently on the server and the client (…4313 vs …43124), which
+// React reports as a hydration mismatch on every label in the ring.
+const r2 = (n: number) => Math.round(n * 100) / 100;
+
+export function SkillGraphView({ graph }: { graph: SkillGraph }) {
+ const reduce = useReducedMotion();
+ const [hovered, setHovered] = useState(null);
+ const [pinned, setPinned] = useState(null);
+ const [view, setView] = useState<"graph" | "list">("graph");
+ const [focused, setFocused] = useState(null);
+ // Hover traces a different skill without taking the pin away: `active`
+ // drives the trace, `pinned` keeps its own marker regardless. Losing the
+ // selection the moment the cursor moved made the pin useless.
+ const active = hovered ?? pinned;
+
+ // A skill page links here as …?focus=delivery-review, so "see what this
+ // connects to" lands on that node already traced instead of on 34
+ // undifferentiated dots. Read after mount to keep the page static.
+ useEffect(() => {
+ 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);
+ }, [graph.nodes]);
+
+ const { points, byId } = useMemo(() => {
+ const points = new Map();
+ const n = graph.nodes.length || 1;
+ graph.nodes.forEach((node, i) => {
+ // start at 12 o'clock so the ordering reads clockwise from the top
+ const angle = (i / n) * Math.PI * 2 - Math.PI / 2;
+ points.set(node.id, { x: r2(C + Math.cos(angle) * R), y: r2(C + Math.sin(angle) * R), angle });
+ });
+ return { points, byId: new Map(graph.nodes.map((nd) => [nd.id, nd])) };
+ }, [graph]);
+
+ const related = useMemo(() => {
+ if (!active) return null;
+ const out = graph.edges.filter((e) => e.source === active).map((e) => e.target);
+ const inc = graph.edges.filter((e) => e.target === active).map((e) => e.source);
+ return { out, inc, touching: new Set([...out, ...inc, active]) };
+ }, [active, graph.edges]);
+
+ const activeNode = active ? byId.get(active) : null;
+ const maxDeg = Math.max(...graph.nodes.map((n) => n.inDegree + n.outDegree), 1);
+
+ 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. */
+