Simplify the skills explorer and add a 2D relationship canvas - #24

Merged
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas
Aug 29, 2026
Merged

Simplify the skills explorer and add a 2D relationship canvas#24
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas

Conversation

@harrymove-ctrl

@harrymove-ctrlharrymove-ctrl commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Product intent

/skills had two costs. Choosing one skill out of 34 meant reading 34 summary
paragraphs, while the things that decide the choice — the handle you would type
and the phrase that triggers it — sat inside prose written for another purpose.
And the reference graph, which is the real structure of the kit, was drawn as a
fixed circular ring: every skill the same weight, no grouping, nothing you could
move. It answered "are these connected" and nothing else.

This makes the catalog something you scan and the graph something you explore.

2D over 3D

A 3D force graph was considered and rejected, and that is recorded in the design
doc as prior art rather than a promise. It costs a WebGL context and a physics
loop on a page whose job is reading, it occludes its own labels, it degrades
badly on touch, and it has no honest accessible equivalent — the list would
quietly become the real interface for a large share of readers while the 3D view
took the budget. A force simulation was rejected for a smaller reason: placement
that differs on every load is a map you cannot refer back to.

What changed

Catalog. Header is a word and a sentence. A card carries the handle, title,
category and one trigger phrase; summaries, file counts and reference counts
move to detail where someone is comparing rather than scanning. Sort and
capability flags fold behind one Filters control, with active filters shown as
removable chips. The default view groups by category. Search, ranking, filters
and every deep link behave exactly as before.

Relationship canvas. Skills sit in fixed category lanes, alphabetical within
a lane, so the same graph produces the same map every load. React Flow owns pan,
zoom, drag, minimap and controls; selection, pinning, URL sync and persistence
stay in the view. Dragged positions persist per browser tab under a versioned
key, and Reset layout returns to canonical. The list is a first-class mode
carrying every skill and both directions of every relationship, and it is the
default under 768px.

Preserved. Detail, workspace, Preview, Source, Edit and browser-local drafts
are untouched. This changed how you find a skill, not what you can do with it.

URL contracts

URLBehavior
/skills?query=&category=&sort=&has=&skill=unchanged
/skills/visualize-interactions?skill=<id>selects and pins, cmk: prefix accepted
/skills/visualize-interactions?view=canvas|listexplicit view on any viewport
/skills/[skillId]/workspace?file=&mode=unchanged

Pan and zoom are local UI state and deliberately stay out of the URL.

Responsive

ViewportResult
390 × 8441 column, visualization defaults to List, no overflow
768 × 10242 columns, no overflow
1280 × 8002 columns, no inspector, no overflow
1440 × 900catalog plus inspector, canvas readable
1512 × 900inspector 834px, no collapsed panel, no overflow

Verified

  • Deterministic layout: identical node transforms across loads with storage cleared.
  • Stored layout restores (translate(1234px, 987px) from a seeded payload) and a
    wrong-version payload is discarded back to the canonical position.
  • ?skill=cmk:delivery-review selects and normalizes to ?skill=delivery-review.
  • ?view=list renders the list and never mounts the canvas.
  • Inspector content renders synchronously, not behind an exit animation.
  • Canvas chunk loads only when the canvas view is active: the visualization
    route's first load is 113 kB, down from 161 kB.
  • type-check, lint, clean build (85 static pages) and git diff --check pass.

Known accepted gaps

  • Idle edges measure below 3:1 by design; they are texture, and every
    relationship they hint at is at full contrast in the traced state, the
    inspector and the list.
  • Node dragging is exercised through React Flow's own handlers; the persistence
    layer either side of it is verified directly, but a physical drag was not
    scripted.
  • No automated axe or Lighthouse run: the devtools integration could not attach
    to its browser profile in this environment.

Scope

PR #22 and PR #21 were not opened, inspected, reviewed, modified, rebased or
merged. Nothing outside the skills dashboard, docs, roadmap and changelog is
touched.


Self-review corrections (d9a8968)

Checking this branch against the plan rather than trusting the first pass found
four defects, three of which the documentation had already claimed as working.

  • Hover tracing did not exist.SKEX-2.2 and the design doc both described
    it. Hover now traces while the pin keeps its marker and the inspector stays on
    the pinned skill.
  • The pin's halo never rendered. It was a Tailwind arbitrary shadow holding
    a color-mix() with commas, which the class parser drops silently, so pinned
    and hovered nodes were pixel-identical. Inline style now.
  • Nodes had no declared size, so React Flow held all 42 at
    visibility: hidden pending measurement. Where ResizeObserver delivery is
    throttled the map renders blank, and hidden nodes cannot take focus, which is
    why canvas keyboard access did not work. Sizes are declared now: 42/42 visible
    and node focus confirmed working.
  • The mobile inspector was a bare sheet. It is a dialog now: labelled,
    aria-modal, focus moved in, Tab trapped, Escape closes.

Also corrected: the catalog renders 2 columns at 1280, matching the agreed
matrix. The table above said 3, which was wrong. A third column has nowhere to
live, because the inspector takes over at 1440 — say the word if you would
rather move the inspector breakpoint up and keep a 3-column band.

Re-verified after the fixes

CheckResult
Node visibility42/42 visible
Canvas node focusdocument.activeElement is the node, name reads cmk:cicd, Setup & Infra. 7 references out…
Pin vs hoverpinned keeps a 4px halo, hovered has none, inspector stays pinned
Canvas node contrasthandle 8.16 dark / 5.63 light, meta 5.03 / 5.81 — AA in both
Mobile drawerrole=dialog, aria-modal, labelled, focus in, Escape closes and clears ?skill=
Catalog bundleneither the editor chunk nor React Flow is fetched on /skills
Repeated navigation3 client-side round trips: 1 flow instance on the graph route, 0 after leaving, canvas count steady at 1
1280 / 1440 / 15122 columns / inspector / inspector at 834px, no overflow at any width

Still not verified

No automated axe or Lighthouse run — the devtools integration cannot attach to
its browser profile here. A physical pointer drag of a node was not scripted;
the persistence layer either side of it is verified directly. And I cannot
attach screenshots to this PR from my environment, so the evidence above is
measurements rather than images.


Gate closure

Node count, 34 vs 42. The canvas renders 42 React Flow nodes: 34 skill
nodes, matching the catalog exactly, plus 8 lane heading nodes with ids
lane:delivery, lane:docs, lane:setup, lane:agent, lane:testing,
lane:sui, lane:sync, lane:session. Headings are nodes so the category
structure pans and zooms with the map instead of floating over it. No dangling
reference is rendered as a skill.

Real pointer drag, full lifecycle. Not just the persistence layer:

  1. Dragged adr with a real pointer from translate(0px, 372px).
  2. Landed at translate(295.619px, 554.857px).
  3. sessionStorage holds {"version":1,"positions":{"adr":{"x":295.62,"y":554.86}}}.
  4. Reloaded — restored to exactly translate(295.619px, 554.857px).
  5. Pressed Reset layout — back to translate(0px, 372px), storage cleared.

Live resize, not just reload.

StepResult
Reload at 1280no inspector, ?query=adr
1280 → 1440inspector mounts at 762px, URL gains &skill=adr
1440 → 1280inspector unmounts, URL drops skill, filter intact

No 40px panel, no stale selection, no URL overwrite.

Technical gate from clean state.git status shows only untracked
.claude/, which is not in the diff. 15 files changed against origin/main,
none outside skills UI, docs, roadmap and changelog. git diff --check clean.
No commit in this branch references PR #22 or PR #21. type-check, lint and a
clean build (85 static pages) all pass.

Canvas first revision (44957ea)

Review feedback on the previous head was that the feature was correct and
unpleasant: the map opened fitted to the viewport, so eight lanes were squeezed
into a panel and nodes rendered at roughly a centimetre of unreadable colour,
while the shell, the page header, three stat cards and a permanently docked
inspector took the rest of the screen. It read as a dashboard containing a
small graph. This revision makes the canvas the page.

Focus mode.F gives the canvas the whole application viewport: rail,
header, heading and stats collapse away, the inspector becomes an overlay, and
a floating toolbar carries Exit focus, Canvas/List, zoom, Fit all, Reset and
help. It is application level rather than the browser Fullscreen API, and it
works by setting data-skills-focus on <html> and collapsing the shell around
a canvas that never changes position in the React tree. Measured: the same
.react-flow element survives both directions of the toggle, exactly one
instance exists, and the viewport transform is unchanged across 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 round trips through
?focus=canvas.

Readable by default. No blanket fitView on load. With ?skill= the canvas
centres that node at zoom 1.1; otherwise it anchors the map's top left corner at
zoom 1, so the first thing on screen is a whole card. Fit all is now an
explicit control. Nodes and their type are larger, lanes are three columns wide
so no column starts off screen, and below zoom 0.62 a card drops its metadata
row rather than drawing it at sub 8px. Selecting a skill moves the viewport only
when the node is off frame or the zoom is below legible. LAYOUT_VERSION is 2,
because stored positions describe geometry that no longer exists.

Less chrome. 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. The
docked inspector takes layout only while a skill is selected, so the empty state
is all canvas: 976px of a 1280 viewport, 100% of the panel.

Automated accessibility, now closed

The DevTools integration could not attach (The browser is already running for /Users/.../chrome-devtools-mcp/chrome-profile), so the audit was run a
different way: axe-core 4.13.0 served same origin and executed in the page, with
transitions disabled first, because this environment throttles rAF and a
mid-transition getComputedStyle reports the previous theme's colours.

SurfaceViolationsPasses
/skills023
Canvas, docked inspector024
Canvas, focus mode024
List view024
Mobile inspector drawer, 390px024
Skill detail025
Workspace029

Scope: WCAG 2.0 and 2.1, levels A and AA. The run found four real defects, all
fixed in this commit:

  1. Informational text at --text-disabled measuring 2.54:1 to 4.27:1 on the
    light palette, across 22 nodes in the catalog and the lane counts on the map.
  2. aria-label on a bare div, which names nothing.
  3. role="tablist" owning links and buttons rather than only tabs, reported
    critical.
  4. Once the close control moved inside its tab to fix (3), a focusable button
    nested inside a widget role. The close affordance is now pointer only and out
    of the accessibility tree, with Delete or Backspace closing the focused
    tab; both paths verified.

GooeyTextReveal also stopped asking GSAP for aria: "auto", which was putting
a prohibited aria-label on a <p> while hiding every line beneath it, so a
paragraph could read as empty to assistive technology.

axe returns color-contrast as incomplete over the canvas, where backgrounds
are color-mix() values it will not resolve. Those were measured directly
instead, compositing each background through a canvas so color(srgb ...) and
color-mix() resolve to real pixels: the lowest ratio on graph and inspector
text is 5.03:1 dark and 5.35:1 light, against a 4.5:1 requirement.

End to end, re-run after the UX change

  • Focus: enter and exit by button and by F; canvas 820px to 1440px and
    back; probe on the DOM node survives both toggles; one .react-flow instance
    throughout; URL gains and drops focus=canvas.
  • Selection: pin from the canvas, from the list and from ?skill=; overlay
    drawer is role="dialog" + aria-modal, labelled, focus moves in, Tab is
    trapped, Esc closes it and focus returns to the node.
  • Drag lifecycle: real pointer drag moved adr from translate(0px, 392px)
    to translate(108px, 312px); sessionStorage recorded
    {"version":2,"positions":{"adr":{"x":108,"y":312}}}; reload restored it
    exactly; R returned it to canonical and cleared storage.
  • Viewport:Fit all reached zoom 0.458 with all eight lanes visible and
    metadata correctly dropped; zoom in/out; pan.
  • Responsive, reloaded at each width: 390 list by default with React Flow
    never loaded and no horizontal overflow; 768 canvas with focus available and
    no minimap; 1280 canvas 976px empty, 660px with the inspector docked; 1440
    focus mode at the full 1440.
  • Catalog and workspace: search, category chips, URL persistence, detail
    preview; workspace draft written, survived navigation away and back, and the
    close confirmation (role="alertdialog") still guards it.

Technical gate

rm -rf .next then type-check, lint, build: clean, 85 static pages.
git diff --check clean. Nothing outside the skills UI, the shared components
the audit implicated, docs, roadmap and changelog. .claude/ stays untracked.
No commit references PR #21 or #22.

React Flow is in neither /skills, the workspace, nor even the initial bundle
for /skills/visualize-interactions; it is a lazy chunk, confirmed against
app-build-manifest.json.

Files added to the diff by the audit

components/skills/file-content-pane.tsx and
components/motion/gooey-text-reveal.tsx were not part of this PR's original
scope. They are here because the accessibility run this PR was asked to perform
found a critical and a serious violation in them, and app/skills/layout.tsx
gains three class hooks so focus mode has something to collapse.

Clarity pass (6c25bda)

Review feedback on the previous head: the technical gates proved the canvas was
not blank, that drag and zoom worked, and that the page was accessible and
responsive — none of which proves a reader understands what the map is saying.
The page spoke graph theory (references, referenced by, entry points, out, in)
and a node card answered "how connected is this?" before it answered "what is
this?".

One vocabulary, defined once. references → uses, referenced by →
used by, out · inUses n · Used by n, graph → map, entry points
starting points, Fit all → Show whole map, Reset layout → Reset
map
, List → Browse list, and the page is How skills connect. The map,
the browse list, the skill detail page and the catalog now all use it, so the
direction is learned once.

Two things had to exist before the words could work.
SKILL_PURPOSE in lib/skill-types.ts is a curated one-line purpose per
skill, living in the site layer beside CATEGORY_MAP for the same reason that
map does: frontmatter has 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; this way changing a line
is a copy edit in one file, not a change to the skills themselves, and
skillPurpose() falls back to the first trigger phrase for anything new.
oftenUsedWith in lib/skill-graph.ts is not an edge — two skills keep
company when some third skill pulls in both, and ranking by shared parents
answers what else a reader will want open. For cmk:delivery-review it yields
cicd, delivery-pipeline, delivery-workflow.

A way in before anything is selected. The rail used to appear only on
selection, so the one moment a reader most needs help was the moment the page
showed them 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. Below 1280 and in
focus mode the same groups are a strip over the map; the browse list carries
them as its first two sections. A group jump requested from the list is handed
to the canvas as its opening frame, because at that moment the canvas does not
exist to be moved.

Starting points are honest. In-degree zero gives five skills, one of which
(cmk:codebase-docs) has no connections in either direction. Offering it as a
place to begin is worse than saying nothing, so a starting point is a skill
nothing pulls in that pulls in something itself — four of them.

The node card is a handle, a title and what the skill is for. The counts
moved to the panel, where each sits beside the list it describes.

Framing is clamped to the map and centred against the visible width, so a
deep link to a first-row skill no longer opens with a third of the canvas
empty, and the focus drawer no longer covers the node the map just centred.

Verification, re-run end to end

37 automated checks against a production build, in a real browser (the
in-app preview stops compositing when its pane is hidden, which stalls every
animated viewport move and produced false failures — every result below comes
from headless Chrome over CDP):

AreaChecks
Copy and vocabularyheading, subtitle, stat line, no surviving old terms
Node cardhandle + title + purpose, exactly
InspectorOften used with / Uses / Used by, no raw degree pair, both CTAs
Start panelpresent when nothing is selected; starting points exclude the orphan
Group jumpframes the lane from the map and from the browse list
Focus modeF enters, full 1440 viewport, Esc peels hint → selection → focus
Browse listthree sections, rows read Uses n · Used by n
Dragmoves, persists at version: 3, restores on reload, R resets and clears
Catalogcard carries the purpose line and says "Ask with"
Detailsays Uses / Used by, no References / Referenced by
Responsive390 list-only with no React Flow and no overflow, 768 strip, 1280 rail

Accessibility: axe-core 4.13 at WCAG 2.0/2.1 A and AA on all seven
surfaces — catalog 27 passes, map + inspector 24, focus mode 24, browse list
24, mobile list + drawer 25, detail 25, workspace 29. Zero violations. The
run found one more real defect, now fixed: the rendered-document and eval
scrollers have no focusable child when a skill's body carries no links, which
left a keyboard user unable to scroll them.

Build:rm -rf .next then type-check, lint and build clean, 85 static
pages. React Flow remains absent from every bundle including the map route's
initial load.

Gaps that remain open

  1. Screenshots are not attached to this PR. Twelve fresh captures at 2x,
    desktop and mobile, light and dark, rendered from the production build to
    ~/Desktop/pr24-skills-canvas/ and ready to drop into this description.
    Nothing is committed as test media.
  2. The purpose lines are new copy. Thirty-four one-liners written for this
    PR and grounded in each skill's own description and heading. They deserve a
    read as copy, not as code.
  3. No human review yet, and specifically none by someone reading the page
    cold. That is the remaining merge gate, and the one the automated suite
    cannot stand in for.

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.
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.
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.
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.
The changelog entry and the roadmap item were written before the PR existed, so
both carried a guessed number. They name PR #24 now.
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.
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 <html> 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.
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.
The canvas panel was translucent, so the animated wash behind the shell
came through it and lifted the surface to something lighter than the
page itself. The dot grid, drawn at the same value as a panel border,
then had nothing to sit against and disappeared: the map read as a pale
rectangle with cards floating on it rather than as a space you look
into.
The map now has its own surface token, opaque and below the page
instead of above it — near black in dark, white in light — with a dot
colour picked to read against it, and it is a fraction larger. Focus
mode drops the wash entirely; there is nothing decorative left to see
past. The minimap takes the canvas colour too, since an elevated panel
in that corner was the brightest thing on a near-black screen, which is
backwards for an overview.
37 end-to-end checks still pass, axe still reports nothing on all seven
surfaces.
@harrymove-ctrl
harrymove-ctrl merged commit 125a113 into mainAug 29, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@harrymove-ctrl@hien-p
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Simplify the skills explorer and add a 2D relationship canvas - #24

Merged
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas
Aug 29, 2026
Merged

Simplify the skills explorer and add a 2D relationship canvas#24
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas

Conversation

@harrymove-ctrl

@harrymove-ctrlharrymove-ctrl commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Product intent

/skills had two costs. Choosing one skill out of 34 meant reading 34 summary
paragraphs, while the things that decide the choice — the handle you would type
and the phrase that triggers it — sat inside prose written for another purpose.
And the reference graph, which is the real structure of the kit, was drawn as a
fixed circular ring: every skill the same weight, no grouping, nothing you could
move. It answered "are these connected" and nothing else.

This makes the catalog something you scan and the graph something you explore.

2D over 3D

A 3D force graph was considered and rejected, and that is recorded in the design
doc as prior art rather than a promise. It costs a WebGL context and a physics
loop on a page whose job is reading, it occludes its own labels, it degrades
badly on touch, and it has no honest accessible equivalent — the list would
quietly become the real interface for a large share of readers while the 3D view
took the budget. A force simulation was rejected for a smaller reason: placement
that differs on every load is a map you cannot refer back to.

What changed

Catalog. Header is a word and a sentence. A card carries the handle, title,
category and one trigger phrase; summaries, file counts and reference counts
move to detail where someone is comparing rather than scanning. Sort and
capability flags fold behind one Filters control, with active filters shown as
removable chips. The default view groups by category. Search, ranking, filters
and every deep link behave exactly as before.

Relationship canvas. Skills sit in fixed category lanes, alphabetical within
a lane, so the same graph produces the same map every load. React Flow owns pan,
zoom, drag, minimap and controls; selection, pinning, URL sync and persistence
stay in the view. Dragged positions persist per browser tab under a versioned
key, and Reset layout returns to canonical. The list is a first-class mode
carrying every skill and both directions of every relationship, and it is the
default under 768px.

Preserved. Detail, workspace, Preview, Source, Edit and browser-local drafts
are untouched. This changed how you find a skill, not what you can do with it.

URL contracts

URLBehavior
/skills?query=&category=&sort=&has=&skill=unchanged
/skills/visualize-interactions?skill=<id>selects and pins, cmk: prefix accepted
/skills/visualize-interactions?view=canvas|listexplicit view on any viewport
/skills/[skillId]/workspace?file=&mode=unchanged

Pan and zoom are local UI state and deliberately stay out of the URL.

Responsive

ViewportResult
390 × 8441 column, visualization defaults to List, no overflow
768 × 10242 columns, no overflow
1280 × 8002 columns, no inspector, no overflow
1440 × 900catalog plus inspector, canvas readable
1512 × 900inspector 834px, no collapsed panel, no overflow

Verified

  • Deterministic layout: identical node transforms across loads with storage cleared.
  • Stored layout restores (translate(1234px, 987px) from a seeded payload) and a
    wrong-version payload is discarded back to the canonical position.
  • ?skill=cmk:delivery-review selects and normalizes to ?skill=delivery-review.
  • ?view=list renders the list and never mounts the canvas.
  • Inspector content renders synchronously, not behind an exit animation.
  • Canvas chunk loads only when the canvas view is active: the visualization
    route's first load is 113 kB, down from 161 kB.
  • type-check, lint, clean build (85 static pages) and git diff --check pass.

Known accepted gaps

  • Idle edges measure below 3:1 by design; they are texture, and every
    relationship they hint at is at full contrast in the traced state, the
    inspector and the list.
  • Node dragging is exercised through React Flow's own handlers; the persistence
    layer either side of it is verified directly, but a physical drag was not
    scripted.
  • No automated axe or Lighthouse run: the devtools integration could not attach
    to its browser profile in this environment.

Scope

PR #22 and PR #21 were not opened, inspected, reviewed, modified, rebased or
merged. Nothing outside the skills dashboard, docs, roadmap and changelog is
touched.


Self-review corrections (d9a8968)

Checking this branch against the plan rather than trusting the first pass found
four defects, three of which the documentation had already claimed as working.

  • Hover tracing did not exist.SKEX-2.2 and the design doc both described
    it. Hover now traces while the pin keeps its marker and the inspector stays on
    the pinned skill.
  • The pin's halo never rendered. It was a Tailwind arbitrary shadow holding
    a color-mix() with commas, which the class parser drops silently, so pinned
    and hovered nodes were pixel-identical. Inline style now.
  • Nodes had no declared size, so React Flow held all 42 at
    visibility: hidden pending measurement. Where ResizeObserver delivery is
    throttled the map renders blank, and hidden nodes cannot take focus, which is
    why canvas keyboard access did not work. Sizes are declared now: 42/42 visible
    and node focus confirmed working.
  • The mobile inspector was a bare sheet. It is a dialog now: labelled,
    aria-modal, focus moved in, Tab trapped, Escape closes.

Also corrected: the catalog renders 2 columns at 1280, matching the agreed
matrix. The table above said 3, which was wrong. A third column has nowhere to
live, because the inspector takes over at 1440 — say the word if you would
rather move the inspector breakpoint up and keep a 3-column band.

Re-verified after the fixes

CheckResult
Node visibility42/42 visible
Canvas node focusdocument.activeElement is the node, name reads cmk:cicd, Setup & Infra. 7 references out…
Pin vs hoverpinned keeps a 4px halo, hovered has none, inspector stays pinned
Canvas node contrasthandle 8.16 dark / 5.63 light, meta 5.03 / 5.81 — AA in both
Mobile drawerrole=dialog, aria-modal, labelled, focus in, Escape closes and clears ?skill=
Catalog bundleneither the editor chunk nor React Flow is fetched on /skills
Repeated navigation3 client-side round trips: 1 flow instance on the graph route, 0 after leaving, canvas count steady at 1
1280 / 1440 / 15122 columns / inspector / inspector at 834px, no overflow at any width

Still not verified

No automated axe or Lighthouse run — the devtools integration cannot attach to
its browser profile here. A physical pointer drag of a node was not scripted;
the persistence layer either side of it is verified directly. And I cannot
attach screenshots to this PR from my environment, so the evidence above is
measurements rather than images.


Gate closure

Node count, 34 vs 42. The canvas renders 42 React Flow nodes: 34 skill
nodes, matching the catalog exactly, plus 8 lane heading nodes with ids
lane:delivery, lane:docs, lane:setup, lane:agent, lane:testing,
lane:sui, lane:sync, lane:session. Headings are nodes so the category
structure pans and zooms with the map instead of floating over it. No dangling
reference is rendered as a skill.

Real pointer drag, full lifecycle. Not just the persistence layer:

  1. Dragged adr with a real pointer from translate(0px, 372px).
  2. Landed at translate(295.619px, 554.857px).
  3. sessionStorage holds {"version":1,"positions":{"adr":{"x":295.62,"y":554.86}}}.
  4. Reloaded — restored to exactly translate(295.619px, 554.857px).
  5. Pressed Reset layout — back to translate(0px, 372px), storage cleared.

Live resize, not just reload.

StepResult
Reload at 1280no inspector, ?query=adr
1280 → 1440inspector mounts at 762px, URL gains &skill=adr
1440 → 1280inspector unmounts, URL drops skill, filter intact

No 40px panel, no stale selection, no URL overwrite.

Technical gate from clean state.git status shows only untracked
.claude/, which is not in the diff. 15 files changed against origin/main,
none outside skills UI, docs, roadmap and changelog. git diff --check clean.
No commit in this branch references PR #22 or PR #21. type-check, lint and a
clean build (85 static pages) all pass.

Canvas first revision (44957ea)

Review feedback on the previous head was that the feature was correct and
unpleasant: the map opened fitted to the viewport, so eight lanes were squeezed
into a panel and nodes rendered at roughly a centimetre of unreadable colour,
while the shell, the page header, three stat cards and a permanently docked
inspector took the rest of the screen. It read as a dashboard containing a
small graph. This revision makes the canvas the page.

Focus mode.F gives the canvas the whole application viewport: rail,
header, heading and stats collapse away, the inspector becomes an overlay, and
a floating toolbar carries Exit focus, Canvas/List, zoom, Fit all, Reset and
help. It is application level rather than the browser Fullscreen API, and it
works by setting data-skills-focus on <html> and collapsing the shell around
a canvas that never changes position in the React tree. Measured: the same
.react-flow element survives both directions of the toggle, exactly one
instance exists, and the viewport transform is unchanged across 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 round trips through
?focus=canvas.

Readable by default. No blanket fitView on load. With ?skill= the canvas
centres that node at zoom 1.1; otherwise it anchors the map's top left corner at
zoom 1, so the first thing on screen is a whole card. Fit all is now an
explicit control. Nodes and their type are larger, lanes are three columns wide
so no column starts off screen, and below zoom 0.62 a card drops its metadata
row rather than drawing it at sub 8px. Selecting a skill moves the viewport only
when the node is off frame or the zoom is below legible. LAYOUT_VERSION is 2,
because stored positions describe geometry that no longer exists.

Less chrome. 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. The
docked inspector takes layout only while a skill is selected, so the empty state
is all canvas: 976px of a 1280 viewport, 100% of the panel.

Automated accessibility, now closed

The DevTools integration could not attach (The browser is already running for /Users/.../chrome-devtools-mcp/chrome-profile), so the audit was run a
different way: axe-core 4.13.0 served same origin and executed in the page, with
transitions disabled first, because this environment throttles rAF and a
mid-transition getComputedStyle reports the previous theme's colours.

SurfaceViolationsPasses
/skills023
Canvas, docked inspector024
Canvas, focus mode024
List view024
Mobile inspector drawer, 390px024
Skill detail025
Workspace029

Scope: WCAG 2.0 and 2.1, levels A and AA. The run found four real defects, all
fixed in this commit:

  1. Informational text at --text-disabled measuring 2.54:1 to 4.27:1 on the
    light palette, across 22 nodes in the catalog and the lane counts on the map.
  2. aria-label on a bare div, which names nothing.
  3. role="tablist" owning links and buttons rather than only tabs, reported
    critical.
  4. Once the close control moved inside its tab to fix (3), a focusable button
    nested inside a widget role. The close affordance is now pointer only and out
    of the accessibility tree, with Delete or Backspace closing the focused
    tab; both paths verified.

GooeyTextReveal also stopped asking GSAP for aria: "auto", which was putting
a prohibited aria-label on a <p> while hiding every line beneath it, so a
paragraph could read as empty to assistive technology.

axe returns color-contrast as incomplete over the canvas, where backgrounds
are color-mix() values it will not resolve. Those were measured directly
instead, compositing each background through a canvas so color(srgb ...) and
color-mix() resolve to real pixels: the lowest ratio on graph and inspector
text is 5.03:1 dark and 5.35:1 light, against a 4.5:1 requirement.

End to end, re-run after the UX change

  • Focus: enter and exit by button and by F; canvas 820px to 1440px and
    back; probe on the DOM node survives both toggles; one .react-flow instance
    throughout; URL gains and drops focus=canvas.
  • Selection: pin from the canvas, from the list and from ?skill=; overlay
    drawer is role="dialog" + aria-modal, labelled, focus moves in, Tab is
    trapped, Esc closes it and focus returns to the node.
  • Drag lifecycle: real pointer drag moved adr from translate(0px, 392px)
    to translate(108px, 312px); sessionStorage recorded
    {"version":2,"positions":{"adr":{"x":108,"y":312}}}; reload restored it
    exactly; R returned it to canonical and cleared storage.
  • Viewport:Fit all reached zoom 0.458 with all eight lanes visible and
    metadata correctly dropped; zoom in/out; pan.
  • Responsive, reloaded at each width: 390 list by default with React Flow
    never loaded and no horizontal overflow; 768 canvas with focus available and
    no minimap; 1280 canvas 976px empty, 660px with the inspector docked; 1440
    focus mode at the full 1440.
  • Catalog and workspace: search, category chips, URL persistence, detail
    preview; workspace draft written, survived navigation away and back, and the
    close confirmation (role="alertdialog") still guards it.

Technical gate

rm -rf .next then type-check, lint, build: clean, 85 static pages.
git diff --check clean. Nothing outside the skills UI, the shared components
the audit implicated, docs, roadmap and changelog. .claude/ stays untracked.
No commit references PR #21 or #22.

React Flow is in neither /skills, the workspace, nor even the initial bundle
for /skills/visualize-interactions; it is a lazy chunk, confirmed against
app-build-manifest.json.

Files added to the diff by the audit

components/skills/file-content-pane.tsx and
components/motion/gooey-text-reveal.tsx were not part of this PR's original
scope. They are here because the accessibility run this PR was asked to perform
found a critical and a serious violation in them, and app/skills/layout.tsx
gains three class hooks so focus mode has something to collapse.

Clarity pass (6c25bda)

Review feedback on the previous head: the technical gates proved the canvas was
not blank, that drag and zoom worked, and that the page was accessible and
responsive — none of which proves a reader understands what the map is saying.
The page spoke graph theory (references, referenced by, entry points, out, in)
and a node card answered "how connected is this?" before it answered "what is
this?".

One vocabulary, defined once. references → uses, referenced by →
used by, out · inUses n · Used by n, graph → map, entry points
starting points, Fit all → Show whole map, Reset layout → Reset
map
, List → Browse list, and the page is How skills connect. The map,
the browse list, the skill detail page and the catalog now all use it, so the
direction is learned once.

Two things had to exist before the words could work.
SKILL_PURPOSE in lib/skill-types.ts is a curated one-line purpose per
skill, living in the site layer beside CATEGORY_MAP for the same reason that
map does: frontmatter has 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; this way changing a line
is a copy edit in one file, not a change to the skills themselves, and
skillPurpose() falls back to the first trigger phrase for anything new.
oftenUsedWith in lib/skill-graph.ts is not an edge — two skills keep
company when some third skill pulls in both, and ranking by shared parents
answers what else a reader will want open. For cmk:delivery-review it yields
cicd, delivery-pipeline, delivery-workflow.

A way in before anything is selected. The rail used to appear only on
selection, so the one moment a reader most needs help was the moment the page
showed them 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. Below 1280 and in
focus mode the same groups are a strip over the map; the browse list carries
them as its first two sections. A group jump requested from the list is handed
to the canvas as its opening frame, because at that moment the canvas does not
exist to be moved.

Starting points are honest. In-degree zero gives five skills, one of which
(cmk:codebase-docs) has no connections in either direction. Offering it as a
place to begin is worse than saying nothing, so a starting point is a skill
nothing pulls in that pulls in something itself — four of them.

The node card is a handle, a title and what the skill is for. The counts
moved to the panel, where each sits beside the list it describes.

Framing is clamped to the map and centred against the visible width, so a
deep link to a first-row skill no longer opens with a third of the canvas
empty, and the focus drawer no longer covers the node the map just centred.

Verification, re-run end to end

37 automated checks against a production build, in a real browser (the
in-app preview stops compositing when its pane is hidden, which stalls every
animated viewport move and produced false failures — every result below comes
from headless Chrome over CDP):

AreaChecks
Copy and vocabularyheading, subtitle, stat line, no surviving old terms
Node cardhandle + title + purpose, exactly
InspectorOften used with / Uses / Used by, no raw degree pair, both CTAs
Start panelpresent when nothing is selected; starting points exclude the orphan
Group jumpframes the lane from the map and from the browse list
Focus modeF enters, full 1440 viewport, Esc peels hint → selection → focus
Browse listthree sections, rows read Uses n · Used by n
Dragmoves, persists at version: 3, restores on reload, R resets and clears
Catalogcard carries the purpose line and says "Ask with"
Detailsays Uses / Used by, no References / Referenced by
Responsive390 list-only with no React Flow and no overflow, 768 strip, 1280 rail

Accessibility: axe-core 4.13 at WCAG 2.0/2.1 A and AA on all seven
surfaces — catalog 27 passes, map + inspector 24, focus mode 24, browse list
24, mobile list + drawer 25, detail 25, workspace 29. Zero violations. The
run found one more real defect, now fixed: the rendered-document and eval
scrollers have no focusable child when a skill's body carries no links, which
left a keyboard user unable to scroll them.

Build:rm -rf .next then type-check, lint and build clean, 85 static
pages. React Flow remains absent from every bundle including the map route's
initial load.

Gaps that remain open

  1. Screenshots are not attached to this PR. Twelve fresh captures at 2x,
    desktop and mobile, light and dark, rendered from the production build to
    ~/Desktop/pr24-skills-canvas/ and ready to drop into this description.
    Nothing is committed as test media.
  2. The purpose lines are new copy. Thirty-four one-liners written for this
    PR and grounded in each skill's own description and heading. They deserve a
    read as copy, not as code.
  3. No human review yet, and specifically none by someone reading the page
    cold. That is the remaining merge gate, and the one the automated suite
    cannot stand in for.

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.
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.
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.
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.
The changelog entry and the roadmap item were written before the PR existed, so
both carried a guessed number. They name PR #24 now.
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.
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 <html> 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.
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.
The canvas panel was translucent, so the animated wash behind the shell
came through it and lifted the surface to something lighter than the
page itself. The dot grid, drawn at the same value as a panel border,
then had nothing to sit against and disappeared: the map read as a pale
rectangle with cards floating on it rather than as a space you look
into.
The map now has its own surface token, opaque and below the page
instead of above it — near black in dark, white in light — with a dot
colour picked to read against it, and it is a fraction larger. Focus
mode drops the wash entirely; there is nothing decorative left to see
past. The minimap takes the canvas colour too, since an elevated panel
in that corner was the brightest thing on a near-black screen, which is
backwards for an overview.
37 end-to-end checks still pass, axe still reports nothing on all seven
surfaces.
@harrymove-ctrl
harrymove-ctrl merged commit 125a113 into mainAug 29, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@harrymove-ctrl@hien-p
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Simplify the skills explorer and add a 2D relationship canvas - #24

Merged
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas
Aug 29, 2026
Merged

Simplify the skills explorer and add a 2D relationship canvas#24
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas

Conversation

@harrymove-ctrl

@harrymove-ctrlharrymove-ctrl commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Product intent

/skills had two costs. Choosing one skill out of 34 meant reading 34 summary
paragraphs, while the things that decide the choice — the handle you would type
and the phrase that triggers it — sat inside prose written for another purpose.
And the reference graph, which is the real structure of the kit, was drawn as a
fixed circular ring: every skill the same weight, no grouping, nothing you could
move. It answered "are these connected" and nothing else.

This makes the catalog something you scan and the graph something you explore.

2D over 3D

A 3D force graph was considered and rejected, and that is recorded in the design
doc as prior art rather than a promise. It costs a WebGL context and a physics
loop on a page whose job is reading, it occludes its own labels, it degrades
badly on touch, and it has no honest accessible equivalent — the list would
quietly become the real interface for a large share of readers while the 3D view
took the budget. A force simulation was rejected for a smaller reason: placement
that differs on every load is a map you cannot refer back to.

What changed

Catalog. Header is a word and a sentence. A card carries the handle, title,
category and one trigger phrase; summaries, file counts and reference counts
move to detail where someone is comparing rather than scanning. Sort and
capability flags fold behind one Filters control, with active filters shown as
removable chips. The default view groups by category. Search, ranking, filters
and every deep link behave exactly as before.

Relationship canvas. Skills sit in fixed category lanes, alphabetical within
a lane, so the same graph produces the same map every load. React Flow owns pan,
zoom, drag, minimap and controls; selection, pinning, URL sync and persistence
stay in the view. Dragged positions persist per browser tab under a versioned
key, and Reset layout returns to canonical. The list is a first-class mode
carrying every skill and both directions of every relationship, and it is the
default under 768px.

Preserved. Detail, workspace, Preview, Source, Edit and browser-local drafts
are untouched. This changed how you find a skill, not what you can do with it.

URL contracts

URLBehavior
/skills?query=&category=&sort=&has=&skill=unchanged
/skills/visualize-interactions?skill=<id>selects and pins, cmk: prefix accepted
/skills/visualize-interactions?view=canvas|listexplicit view on any viewport
/skills/[skillId]/workspace?file=&mode=unchanged

Pan and zoom are local UI state and deliberately stay out of the URL.

Responsive

ViewportResult
390 × 8441 column, visualization defaults to List, no overflow
768 × 10242 columns, no overflow
1280 × 8002 columns, no inspector, no overflow
1440 × 900catalog plus inspector, canvas readable
1512 × 900inspector 834px, no collapsed panel, no overflow

Verified

  • Deterministic layout: identical node transforms across loads with storage cleared.
  • Stored layout restores (translate(1234px, 987px) from a seeded payload) and a
    wrong-version payload is discarded back to the canonical position.
  • ?skill=cmk:delivery-review selects and normalizes to ?skill=delivery-review.
  • ?view=list renders the list and never mounts the canvas.
  • Inspector content renders synchronously, not behind an exit animation.
  • Canvas chunk loads only when the canvas view is active: the visualization
    route's first load is 113 kB, down from 161 kB.
  • type-check, lint, clean build (85 static pages) and git diff --check pass.

Known accepted gaps

  • Idle edges measure below 3:1 by design; they are texture, and every
    relationship they hint at is at full contrast in the traced state, the
    inspector and the list.
  • Node dragging is exercised through React Flow's own handlers; the persistence
    layer either side of it is verified directly, but a physical drag was not
    scripted.
  • No automated axe or Lighthouse run: the devtools integration could not attach
    to its browser profile in this environment.

Scope

PR #22 and PR #21 were not opened, inspected, reviewed, modified, rebased or
merged. Nothing outside the skills dashboard, docs, roadmap and changelog is
touched.


Self-review corrections (d9a8968)

Checking this branch against the plan rather than trusting the first pass found
four defects, three of which the documentation had already claimed as working.

  • Hover tracing did not exist.SKEX-2.2 and the design doc both described
    it. Hover now traces while the pin keeps its marker and the inspector stays on
    the pinned skill.
  • The pin's halo never rendered. It was a Tailwind arbitrary shadow holding
    a color-mix() with commas, which the class parser drops silently, so pinned
    and hovered nodes were pixel-identical. Inline style now.
  • Nodes had no declared size, so React Flow held all 42 at
    visibility: hidden pending measurement. Where ResizeObserver delivery is
    throttled the map renders blank, and hidden nodes cannot take focus, which is
    why canvas keyboard access did not work. Sizes are declared now: 42/42 visible
    and node focus confirmed working.
  • The mobile inspector was a bare sheet. It is a dialog now: labelled,
    aria-modal, focus moved in, Tab trapped, Escape closes.

Also corrected: the catalog renders 2 columns at 1280, matching the agreed
matrix. The table above said 3, which was wrong. A third column has nowhere to
live, because the inspector takes over at 1440 — say the word if you would
rather move the inspector breakpoint up and keep a 3-column band.

Re-verified after the fixes

CheckResult
Node visibility42/42 visible
Canvas node focusdocument.activeElement is the node, name reads cmk:cicd, Setup & Infra. 7 references out…
Pin vs hoverpinned keeps a 4px halo, hovered has none, inspector stays pinned
Canvas node contrasthandle 8.16 dark / 5.63 light, meta 5.03 / 5.81 — AA in both
Mobile drawerrole=dialog, aria-modal, labelled, focus in, Escape closes and clears ?skill=
Catalog bundleneither the editor chunk nor React Flow is fetched on /skills
Repeated navigation3 client-side round trips: 1 flow instance on the graph route, 0 after leaving, canvas count steady at 1
1280 / 1440 / 15122 columns / inspector / inspector at 834px, no overflow at any width

Still not verified

No automated axe or Lighthouse run — the devtools integration cannot attach to
its browser profile here. A physical pointer drag of a node was not scripted;
the persistence layer either side of it is verified directly. And I cannot
attach screenshots to this PR from my environment, so the evidence above is
measurements rather than images.


Gate closure

Node count, 34 vs 42. The canvas renders 42 React Flow nodes: 34 skill
nodes, matching the catalog exactly, plus 8 lane heading nodes with ids
lane:delivery, lane:docs, lane:setup, lane:agent, lane:testing,
lane:sui, lane:sync, lane:session. Headings are nodes so the category
structure pans and zooms with the map instead of floating over it. No dangling
reference is rendered as a skill.

Real pointer drag, full lifecycle. Not just the persistence layer:

  1. Dragged adr with a real pointer from translate(0px, 372px).
  2. Landed at translate(295.619px, 554.857px).
  3. sessionStorage holds {"version":1,"positions":{"adr":{"x":295.62,"y":554.86}}}.
  4. Reloaded — restored to exactly translate(295.619px, 554.857px).
  5. Pressed Reset layout — back to translate(0px, 372px), storage cleared.

Live resize, not just reload.

StepResult
Reload at 1280no inspector, ?query=adr
1280 → 1440inspector mounts at 762px, URL gains &skill=adr
1440 → 1280inspector unmounts, URL drops skill, filter intact

No 40px panel, no stale selection, no URL overwrite.

Technical gate from clean state.git status shows only untracked
.claude/, which is not in the diff. 15 files changed against origin/main,
none outside skills UI, docs, roadmap and changelog. git diff --check clean.
No commit in this branch references PR #22 or PR #21. type-check, lint and a
clean build (85 static pages) all pass.

Canvas first revision (44957ea)

Review feedback on the previous head was that the feature was correct and
unpleasant: the map opened fitted to the viewport, so eight lanes were squeezed
into a panel and nodes rendered at roughly a centimetre of unreadable colour,
while the shell, the page header, three stat cards and a permanently docked
inspector took the rest of the screen. It read as a dashboard containing a
small graph. This revision makes the canvas the page.

Focus mode.F gives the canvas the whole application viewport: rail,
header, heading and stats collapse away, the inspector becomes an overlay, and
a floating toolbar carries Exit focus, Canvas/List, zoom, Fit all, Reset and
help. It is application level rather than the browser Fullscreen API, and it
works by setting data-skills-focus on <html> and collapsing the shell around
a canvas that never changes position in the React tree. Measured: the same
.react-flow element survives both directions of the toggle, exactly one
instance exists, and the viewport transform is unchanged across 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 round trips through
?focus=canvas.

Readable by default. No blanket fitView on load. With ?skill= the canvas
centres that node at zoom 1.1; otherwise it anchors the map's top left corner at
zoom 1, so the first thing on screen is a whole card. Fit all is now an
explicit control. Nodes and their type are larger, lanes are three columns wide
so no column starts off screen, and below zoom 0.62 a card drops its metadata
row rather than drawing it at sub 8px. Selecting a skill moves the viewport only
when the node is off frame or the zoom is below legible. LAYOUT_VERSION is 2,
because stored positions describe geometry that no longer exists.

Less chrome. 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. The
docked inspector takes layout only while a skill is selected, so the empty state
is all canvas: 976px of a 1280 viewport, 100% of the panel.

Automated accessibility, now closed

The DevTools integration could not attach (The browser is already running for /Users/.../chrome-devtools-mcp/chrome-profile), so the audit was run a
different way: axe-core 4.13.0 served same origin and executed in the page, with
transitions disabled first, because this environment throttles rAF and a
mid-transition getComputedStyle reports the previous theme's colours.

SurfaceViolationsPasses
/skills023
Canvas, docked inspector024
Canvas, focus mode024
List view024
Mobile inspector drawer, 390px024
Skill detail025
Workspace029

Scope: WCAG 2.0 and 2.1, levels A and AA. The run found four real defects, all
fixed in this commit:

  1. Informational text at --text-disabled measuring 2.54:1 to 4.27:1 on the
    light palette, across 22 nodes in the catalog and the lane counts on the map.
  2. aria-label on a bare div, which names nothing.
  3. role="tablist" owning links and buttons rather than only tabs, reported
    critical.
  4. Once the close control moved inside its tab to fix (3), a focusable button
    nested inside a widget role. The close affordance is now pointer only and out
    of the accessibility tree, with Delete or Backspace closing the focused
    tab; both paths verified.

GooeyTextReveal also stopped asking GSAP for aria: "auto", which was putting
a prohibited aria-label on a <p> while hiding every line beneath it, so a
paragraph could read as empty to assistive technology.

axe returns color-contrast as incomplete over the canvas, where backgrounds
are color-mix() values it will not resolve. Those were measured directly
instead, compositing each background through a canvas so color(srgb ...) and
color-mix() resolve to real pixels: the lowest ratio on graph and inspector
text is 5.03:1 dark and 5.35:1 light, against a 4.5:1 requirement.

End to end, re-run after the UX change

  • Focus: enter and exit by button and by F; canvas 820px to 1440px and
    back; probe on the DOM node survives both toggles; one .react-flow instance
    throughout; URL gains and drops focus=canvas.
  • Selection: pin from the canvas, from the list and from ?skill=; overlay
    drawer is role="dialog" + aria-modal, labelled, focus moves in, Tab is
    trapped, Esc closes it and focus returns to the node.
  • Drag lifecycle: real pointer drag moved adr from translate(0px, 392px)
    to translate(108px, 312px); sessionStorage recorded
    {"version":2,"positions":{"adr":{"x":108,"y":312}}}; reload restored it
    exactly; R returned it to canonical and cleared storage.
  • Viewport:Fit all reached zoom 0.458 with all eight lanes visible and
    metadata correctly dropped; zoom in/out; pan.
  • Responsive, reloaded at each width: 390 list by default with React Flow
    never loaded and no horizontal overflow; 768 canvas with focus available and
    no minimap; 1280 canvas 976px empty, 660px with the inspector docked; 1440
    focus mode at the full 1440.
  • Catalog and workspace: search, category chips, URL persistence, detail
    preview; workspace draft written, survived navigation away and back, and the
    close confirmation (role="alertdialog") still guards it.

Technical gate

rm -rf .next then type-check, lint, build: clean, 85 static pages.
git diff --check clean. Nothing outside the skills UI, the shared components
the audit implicated, docs, roadmap and changelog. .claude/ stays untracked.
No commit references PR #21 or #22.

React Flow is in neither /skills, the workspace, nor even the initial bundle
for /skills/visualize-interactions; it is a lazy chunk, confirmed against
app-build-manifest.json.

Files added to the diff by the audit

components/skills/file-content-pane.tsx and
components/motion/gooey-text-reveal.tsx were not part of this PR's original
scope. They are here because the accessibility run this PR was asked to perform
found a critical and a serious violation in them, and app/skills/layout.tsx
gains three class hooks so focus mode has something to collapse.

Clarity pass (6c25bda)

Review feedback on the previous head: the technical gates proved the canvas was
not blank, that drag and zoom worked, and that the page was accessible and
responsive — none of which proves a reader understands what the map is saying.
The page spoke graph theory (references, referenced by, entry points, out, in)
and a node card answered "how connected is this?" before it answered "what is
this?".

One vocabulary, defined once. references → uses, referenced by →
used by, out · inUses n · Used by n, graph → map, entry points
starting points, Fit all → Show whole map, Reset layout → Reset
map
, List → Browse list, and the page is How skills connect. The map,
the browse list, the skill detail page and the catalog now all use it, so the
direction is learned once.

Two things had to exist before the words could work.
SKILL_PURPOSE in lib/skill-types.ts is a curated one-line purpose per
skill, living in the site layer beside CATEGORY_MAP for the same reason that
map does: frontmatter has 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; this way changing a line
is a copy edit in one file, not a change to the skills themselves, and
skillPurpose() falls back to the first trigger phrase for anything new.
oftenUsedWith in lib/skill-graph.ts is not an edge — two skills keep
company when some third skill pulls in both, and ranking by shared parents
answers what else a reader will want open. For cmk:delivery-review it yields
cicd, delivery-pipeline, delivery-workflow.

A way in before anything is selected. The rail used to appear only on
selection, so the one moment a reader most needs help was the moment the page
showed them 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. Below 1280 and in
focus mode the same groups are a strip over the map; the browse list carries
them as its first two sections. A group jump requested from the list is handed
to the canvas as its opening frame, because at that moment the canvas does not
exist to be moved.

Starting points are honest. In-degree zero gives five skills, one of which
(cmk:codebase-docs) has no connections in either direction. Offering it as a
place to begin is worse than saying nothing, so a starting point is a skill
nothing pulls in that pulls in something itself — four of them.

The node card is a handle, a title and what the skill is for. The counts
moved to the panel, where each sits beside the list it describes.

Framing is clamped to the map and centred against the visible width, so a
deep link to a first-row skill no longer opens with a third of the canvas
empty, and the focus drawer no longer covers the node the map just centred.

Verification, re-run end to end

37 automated checks against a production build, in a real browser (the
in-app preview stops compositing when its pane is hidden, which stalls every
animated viewport move and produced false failures — every result below comes
from headless Chrome over CDP):

AreaChecks
Copy and vocabularyheading, subtitle, stat line, no surviving old terms
Node cardhandle + title + purpose, exactly
InspectorOften used with / Uses / Used by, no raw degree pair, both CTAs
Start panelpresent when nothing is selected; starting points exclude the orphan
Group jumpframes the lane from the map and from the browse list
Focus modeF enters, full 1440 viewport, Esc peels hint → selection → focus
Browse listthree sections, rows read Uses n · Used by n
Dragmoves, persists at version: 3, restores on reload, R resets and clears
Catalogcard carries the purpose line and says "Ask with"
Detailsays Uses / Used by, no References / Referenced by
Responsive390 list-only with no React Flow and no overflow, 768 strip, 1280 rail

Accessibility: axe-core 4.13 at WCAG 2.0/2.1 A and AA on all seven
surfaces — catalog 27 passes, map + inspector 24, focus mode 24, browse list
24, mobile list + drawer 25, detail 25, workspace 29. Zero violations. The
run found one more real defect, now fixed: the rendered-document and eval
scrollers have no focusable child when a skill's body carries no links, which
left a keyboard user unable to scroll them.

Build:rm -rf .next then type-check, lint and build clean, 85 static
pages. React Flow remains absent from every bundle including the map route's
initial load.

Gaps that remain open

  1. Screenshots are not attached to this PR. Twelve fresh captures at 2x,
    desktop and mobile, light and dark, rendered from the production build to
    ~/Desktop/pr24-skills-canvas/ and ready to drop into this description.
    Nothing is committed as test media.
  2. The purpose lines are new copy. Thirty-four one-liners written for this
    PR and grounded in each skill's own description and heading. They deserve a
    read as copy, not as code.
  3. No human review yet, and specifically none by someone reading the page
    cold. That is the remaining merge gate, and the one the automated suite
    cannot stand in for.

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.
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.
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.
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.
The changelog entry and the roadmap item were written before the PR existed, so
both carried a guessed number. They name PR #24 now.
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.
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 <html> 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.
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.
The canvas panel was translucent, so the animated wash behind the shell
came through it and lifted the surface to something lighter than the
page itself. The dot grid, drawn at the same value as a panel border,
then had nothing to sit against and disappeared: the map read as a pale
rectangle with cards floating on it rather than as a space you look
into.
The map now has its own surface token, opaque and below the page
instead of above it — near black in dark, white in light — with a dot
colour picked to read against it, and it is a fraction larger. Focus
mode drops the wash entirely; there is nothing decorative left to see
past. The minimap takes the canvas colour too, since an elevated panel
in that corner was the brightest thing on a near-black screen, which is
backwards for an overview.
37 end-to-end checks still pass, axe still reports nothing on all seven
surfaces.
@harrymove-ctrl
harrymove-ctrl merged commit 125a113 into mainAug 29, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@harrymove-ctrl@hien-p
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Simplify the skills explorer and add a 2D relationship canvas - #24

Merged
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas
Aug 29, 2026
Merged

Simplify the skills explorer and add a 2D relationship canvas#24
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas

Conversation

@harrymove-ctrl

@harrymove-ctrlharrymove-ctrl commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Product intent

/skills had two costs. Choosing one skill out of 34 meant reading 34 summary
paragraphs, while the things that decide the choice — the handle you would type
and the phrase that triggers it — sat inside prose written for another purpose.
And the reference graph, which is the real structure of the kit, was drawn as a
fixed circular ring: every skill the same weight, no grouping, nothing you could
move. It answered "are these connected" and nothing else.

This makes the catalog something you scan and the graph something you explore.

2D over 3D

A 3D force graph was considered and rejected, and that is recorded in the design
doc as prior art rather than a promise. It costs a WebGL context and a physics
loop on a page whose job is reading, it occludes its own labels, it degrades
badly on touch, and it has no honest accessible equivalent — the list would
quietly become the real interface for a large share of readers while the 3D view
took the budget. A force simulation was rejected for a smaller reason: placement
that differs on every load is a map you cannot refer back to.

What changed

Catalog. Header is a word and a sentence. A card carries the handle, title,
category and one trigger phrase; summaries, file counts and reference counts
move to detail where someone is comparing rather than scanning. Sort and
capability flags fold behind one Filters control, with active filters shown as
removable chips. The default view groups by category. Search, ranking, filters
and every deep link behave exactly as before.

Relationship canvas. Skills sit in fixed category lanes, alphabetical within
a lane, so the same graph produces the same map every load. React Flow owns pan,
zoom, drag, minimap and controls; selection, pinning, URL sync and persistence
stay in the view. Dragged positions persist per browser tab under a versioned
key, and Reset layout returns to canonical. The list is a first-class mode
carrying every skill and both directions of every relationship, and it is the
default under 768px.

Preserved. Detail, workspace, Preview, Source, Edit and browser-local drafts
are untouched. This changed how you find a skill, not what you can do with it.

URL contracts

URLBehavior
/skills?query=&category=&sort=&has=&skill=unchanged
/skills/visualize-interactions?skill=<id>selects and pins, cmk: prefix accepted
/skills/visualize-interactions?view=canvas|listexplicit view on any viewport
/skills/[skillId]/workspace?file=&mode=unchanged

Pan and zoom are local UI state and deliberately stay out of the URL.

Responsive

ViewportResult
390 × 8441 column, visualization defaults to List, no overflow
768 × 10242 columns, no overflow
1280 × 8002 columns, no inspector, no overflow
1440 × 900catalog plus inspector, canvas readable
1512 × 900inspector 834px, no collapsed panel, no overflow

Verified

  • Deterministic layout: identical node transforms across loads with storage cleared.
  • Stored layout restores (translate(1234px, 987px) from a seeded payload) and a
    wrong-version payload is discarded back to the canonical position.
  • ?skill=cmk:delivery-review selects and normalizes to ?skill=delivery-review.
  • ?view=list renders the list and never mounts the canvas.
  • Inspector content renders synchronously, not behind an exit animation.
  • Canvas chunk loads only when the canvas view is active: the visualization
    route's first load is 113 kB, down from 161 kB.
  • type-check, lint, clean build (85 static pages) and git diff --check pass.

Known accepted gaps

  • Idle edges measure below 3:1 by design; they are texture, and every
    relationship they hint at is at full contrast in the traced state, the
    inspector and the list.
  • Node dragging is exercised through React Flow's own handlers; the persistence
    layer either side of it is verified directly, but a physical drag was not
    scripted.
  • No automated axe or Lighthouse run: the devtools integration could not attach
    to its browser profile in this environment.

Scope

PR #22 and PR #21 were not opened, inspected, reviewed, modified, rebased or
merged. Nothing outside the skills dashboard, docs, roadmap and changelog is
touched.


Self-review corrections (d9a8968)

Checking this branch against the plan rather than trusting the first pass found
four defects, three of which the documentation had already claimed as working.

  • Hover tracing did not exist.SKEX-2.2 and the design doc both described
    it. Hover now traces while the pin keeps its marker and the inspector stays on
    the pinned skill.
  • The pin's halo never rendered. It was a Tailwind arbitrary shadow holding
    a color-mix() with commas, which the class parser drops silently, so pinned
    and hovered nodes were pixel-identical. Inline style now.
  • Nodes had no declared size, so React Flow held all 42 at
    visibility: hidden pending measurement. Where ResizeObserver delivery is
    throttled the map renders blank, and hidden nodes cannot take focus, which is
    why canvas keyboard access did not work. Sizes are declared now: 42/42 visible
    and node focus confirmed working.
  • The mobile inspector was a bare sheet. It is a dialog now: labelled,
    aria-modal, focus moved in, Tab trapped, Escape closes.

Also corrected: the catalog renders 2 columns at 1280, matching the agreed
matrix. The table above said 3, which was wrong. A third column has nowhere to
live, because the inspector takes over at 1440 — say the word if you would
rather move the inspector breakpoint up and keep a 3-column band.

Re-verified after the fixes

CheckResult
Node visibility42/42 visible
Canvas node focusdocument.activeElement is the node, name reads cmk:cicd, Setup & Infra. 7 references out…
Pin vs hoverpinned keeps a 4px halo, hovered has none, inspector stays pinned
Canvas node contrasthandle 8.16 dark / 5.63 light, meta 5.03 / 5.81 — AA in both
Mobile drawerrole=dialog, aria-modal, labelled, focus in, Escape closes and clears ?skill=
Catalog bundleneither the editor chunk nor React Flow is fetched on /skills
Repeated navigation3 client-side round trips: 1 flow instance on the graph route, 0 after leaving, canvas count steady at 1
1280 / 1440 / 15122 columns / inspector / inspector at 834px, no overflow at any width

Still not verified

No automated axe or Lighthouse run — the devtools integration cannot attach to
its browser profile here. A physical pointer drag of a node was not scripted;
the persistence layer either side of it is verified directly. And I cannot
attach screenshots to this PR from my environment, so the evidence above is
measurements rather than images.


Gate closure

Node count, 34 vs 42. The canvas renders 42 React Flow nodes: 34 skill
nodes, matching the catalog exactly, plus 8 lane heading nodes with ids
lane:delivery, lane:docs, lane:setup, lane:agent, lane:testing,
lane:sui, lane:sync, lane:session. Headings are nodes so the category
structure pans and zooms with the map instead of floating over it. No dangling
reference is rendered as a skill.

Real pointer drag, full lifecycle. Not just the persistence layer:

  1. Dragged adr with a real pointer from translate(0px, 372px).
  2. Landed at translate(295.619px, 554.857px).
  3. sessionStorage holds {"version":1,"positions":{"adr":{"x":295.62,"y":554.86}}}.
  4. Reloaded — restored to exactly translate(295.619px, 554.857px).
  5. Pressed Reset layout — back to translate(0px, 372px), storage cleared.

Live resize, not just reload.

StepResult
Reload at 1280no inspector, ?query=adr
1280 → 1440inspector mounts at 762px, URL gains &skill=adr
1440 → 1280inspector unmounts, URL drops skill, filter intact

No 40px panel, no stale selection, no URL overwrite.

Technical gate from clean state.git status shows only untracked
.claude/, which is not in the diff. 15 files changed against origin/main,
none outside skills UI, docs, roadmap and changelog. git diff --check clean.
No commit in this branch references PR #22 or PR #21. type-check, lint and a
clean build (85 static pages) all pass.

Canvas first revision (44957ea)

Review feedback on the previous head was that the feature was correct and
unpleasant: the map opened fitted to the viewport, so eight lanes were squeezed
into a panel and nodes rendered at roughly a centimetre of unreadable colour,
while the shell, the page header, three stat cards and a permanently docked
inspector took the rest of the screen. It read as a dashboard containing a
small graph. This revision makes the canvas the page.

Focus mode.F gives the canvas the whole application viewport: rail,
header, heading and stats collapse away, the inspector becomes an overlay, and
a floating toolbar carries Exit focus, Canvas/List, zoom, Fit all, Reset and
help. It is application level rather than the browser Fullscreen API, and it
works by setting data-skills-focus on <html> and collapsing the shell around
a canvas that never changes position in the React tree. Measured: the same
.react-flow element survives both directions of the toggle, exactly one
instance exists, and the viewport transform is unchanged across 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 round trips through
?focus=canvas.

Readable by default. No blanket fitView on load. With ?skill= the canvas
centres that node at zoom 1.1; otherwise it anchors the map's top left corner at
zoom 1, so the first thing on screen is a whole card. Fit all is now an
explicit control. Nodes and their type are larger, lanes are three columns wide
so no column starts off screen, and below zoom 0.62 a card drops its metadata
row rather than drawing it at sub 8px. Selecting a skill moves the viewport only
when the node is off frame or the zoom is below legible. LAYOUT_VERSION is 2,
because stored positions describe geometry that no longer exists.

Less chrome. 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. The
docked inspector takes layout only while a skill is selected, so the empty state
is all canvas: 976px of a 1280 viewport, 100% of the panel.

Automated accessibility, now closed

The DevTools integration could not attach (The browser is already running for /Users/.../chrome-devtools-mcp/chrome-profile), so the audit was run a
different way: axe-core 4.13.0 served same origin and executed in the page, with
transitions disabled first, because this environment throttles rAF and a
mid-transition getComputedStyle reports the previous theme's colours.

SurfaceViolationsPasses
/skills023
Canvas, docked inspector024
Canvas, focus mode024
List view024
Mobile inspector drawer, 390px024
Skill detail025
Workspace029

Scope: WCAG 2.0 and 2.1, levels A and AA. The run found four real defects, all
fixed in this commit:

  1. Informational text at --text-disabled measuring 2.54:1 to 4.27:1 on the
    light palette, across 22 nodes in the catalog and the lane counts on the map.
  2. aria-label on a bare div, which names nothing.
  3. role="tablist" owning links and buttons rather than only tabs, reported
    critical.
  4. Once the close control moved inside its tab to fix (3), a focusable button
    nested inside a widget role. The close affordance is now pointer only and out
    of the accessibility tree, with Delete or Backspace closing the focused
    tab; both paths verified.

GooeyTextReveal also stopped asking GSAP for aria: "auto", which was putting
a prohibited aria-label on a <p> while hiding every line beneath it, so a
paragraph could read as empty to assistive technology.

axe returns color-contrast as incomplete over the canvas, where backgrounds
are color-mix() values it will not resolve. Those were measured directly
instead, compositing each background through a canvas so color(srgb ...) and
color-mix() resolve to real pixels: the lowest ratio on graph and inspector
text is 5.03:1 dark and 5.35:1 light, against a 4.5:1 requirement.

End to end, re-run after the UX change

  • Focus: enter and exit by button and by F; canvas 820px to 1440px and
    back; probe on the DOM node survives both toggles; one .react-flow instance
    throughout; URL gains and drops focus=canvas.
  • Selection: pin from the canvas, from the list and from ?skill=; overlay
    drawer is role="dialog" + aria-modal, labelled, focus moves in, Tab is
    trapped, Esc closes it and focus returns to the node.
  • Drag lifecycle: real pointer drag moved adr from translate(0px, 392px)
    to translate(108px, 312px); sessionStorage recorded
    {"version":2,"positions":{"adr":{"x":108,"y":312}}}; reload restored it
    exactly; R returned it to canonical and cleared storage.
  • Viewport:Fit all reached zoom 0.458 with all eight lanes visible and
    metadata correctly dropped; zoom in/out; pan.
  • Responsive, reloaded at each width: 390 list by default with React Flow
    never loaded and no horizontal overflow; 768 canvas with focus available and
    no minimap; 1280 canvas 976px empty, 660px with the inspector docked; 1440
    focus mode at the full 1440.
  • Catalog and workspace: search, category chips, URL persistence, detail
    preview; workspace draft written, survived navigation away and back, and the
    close confirmation (role="alertdialog") still guards it.

Technical gate

rm -rf .next then type-check, lint, build: clean, 85 static pages.
git diff --check clean. Nothing outside the skills UI, the shared components
the audit implicated, docs, roadmap and changelog. .claude/ stays untracked.
No commit references PR #21 or #22.

React Flow is in neither /skills, the workspace, nor even the initial bundle
for /skills/visualize-interactions; it is a lazy chunk, confirmed against
app-build-manifest.json.

Files added to the diff by the audit

components/skills/file-content-pane.tsx and
components/motion/gooey-text-reveal.tsx were not part of this PR's original
scope. They are here because the accessibility run this PR was asked to perform
found a critical and a serious violation in them, and app/skills/layout.tsx
gains three class hooks so focus mode has something to collapse.

Clarity pass (6c25bda)

Review feedback on the previous head: the technical gates proved the canvas was
not blank, that drag and zoom worked, and that the page was accessible and
responsive — none of which proves a reader understands what the map is saying.
The page spoke graph theory (references, referenced by, entry points, out, in)
and a node card answered "how connected is this?" before it answered "what is
this?".

One vocabulary, defined once. references → uses, referenced by →
used by, out · inUses n · Used by n, graph → map, entry points
starting points, Fit all → Show whole map, Reset layout → Reset
map
, List → Browse list, and the page is How skills connect. The map,
the browse list, the skill detail page and the catalog now all use it, so the
direction is learned once.

Two things had to exist before the words could work.
SKILL_PURPOSE in lib/skill-types.ts is a curated one-line purpose per
skill, living in the site layer beside CATEGORY_MAP for the same reason that
map does: frontmatter has 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; this way changing a line
is a copy edit in one file, not a change to the skills themselves, and
skillPurpose() falls back to the first trigger phrase for anything new.
oftenUsedWith in lib/skill-graph.ts is not an edge — two skills keep
company when some third skill pulls in both, and ranking by shared parents
answers what else a reader will want open. For cmk:delivery-review it yields
cicd, delivery-pipeline, delivery-workflow.

A way in before anything is selected. The rail used to appear only on
selection, so the one moment a reader most needs help was the moment the page
showed them 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. Below 1280 and in
focus mode the same groups are a strip over the map; the browse list carries
them as its first two sections. A group jump requested from the list is handed
to the canvas as its opening frame, because at that moment the canvas does not
exist to be moved.

Starting points are honest. In-degree zero gives five skills, one of which
(cmk:codebase-docs) has no connections in either direction. Offering it as a
place to begin is worse than saying nothing, so a starting point is a skill
nothing pulls in that pulls in something itself — four of them.

The node card is a handle, a title and what the skill is for. The counts
moved to the panel, where each sits beside the list it describes.

Framing is clamped to the map and centred against the visible width, so a
deep link to a first-row skill no longer opens with a third of the canvas
empty, and the focus drawer no longer covers the node the map just centred.

Verification, re-run end to end

37 automated checks against a production build, in a real browser (the
in-app preview stops compositing when its pane is hidden, which stalls every
animated viewport move and produced false failures — every result below comes
from headless Chrome over CDP):

AreaChecks
Copy and vocabularyheading, subtitle, stat line, no surviving old terms
Node cardhandle + title + purpose, exactly
InspectorOften used with / Uses / Used by, no raw degree pair, both CTAs
Start panelpresent when nothing is selected; starting points exclude the orphan
Group jumpframes the lane from the map and from the browse list
Focus modeF enters, full 1440 viewport, Esc peels hint → selection → focus
Browse listthree sections, rows read Uses n · Used by n
Dragmoves, persists at version: 3, restores on reload, R resets and clears
Catalogcard carries the purpose line and says "Ask with"
Detailsays Uses / Used by, no References / Referenced by
Responsive390 list-only with no React Flow and no overflow, 768 strip, 1280 rail

Accessibility: axe-core 4.13 at WCAG 2.0/2.1 A and AA on all seven
surfaces — catalog 27 passes, map + inspector 24, focus mode 24, browse list
24, mobile list + drawer 25, detail 25, workspace 29. Zero violations. The
run found one more real defect, now fixed: the rendered-document and eval
scrollers have no focusable child when a skill's body carries no links, which
left a keyboard user unable to scroll them.

Build:rm -rf .next then type-check, lint and build clean, 85 static
pages. React Flow remains absent from every bundle including the map route's
initial load.

Gaps that remain open

  1. Screenshots are not attached to this PR. Twelve fresh captures at 2x,
    desktop and mobile, light and dark, rendered from the production build to
    ~/Desktop/pr24-skills-canvas/ and ready to drop into this description.
    Nothing is committed as test media.
  2. The purpose lines are new copy. Thirty-four one-liners written for this
    PR and grounded in each skill's own description and heading. They deserve a
    read as copy, not as code.
  3. No human review yet, and specifically none by someone reading the page
    cold. That is the remaining merge gate, and the one the automated suite
    cannot stand in for.

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.
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.
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.
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.
The changelog entry and the roadmap item were written before the PR existed, so
both carried a guessed number. They name PR #24 now.
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.
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 <html> 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.
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.
The canvas panel was translucent, so the animated wash behind the shell
came through it and lifted the surface to something lighter than the
page itself. The dot grid, drawn at the same value as a panel border,
then had nothing to sit against and disappeared: the map read as a pale
rectangle with cards floating on it rather than as a space you look
into.
The map now has its own surface token, opaque and below the page
instead of above it — near black in dark, white in light — with a dot
colour picked to read against it, and it is a fraction larger. Focus
mode drops the wash entirely; there is nothing decorative left to see
past. The minimap takes the canvas colour too, since an elevated panel
in that corner was the brightest thing on a near-black screen, which is
backwards for an overview.
37 end-to-end checks still pass, axe still reports nothing on all seven
surfaces.
@harrymove-ctrl
harrymove-ctrl merged commit 125a113 into mainAug 29, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@harrymove-ctrl@hien-p
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Simplify the skills explorer and add a 2D relationship canvas - #24

Merged
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas
Aug 29, 2026
Merged

Simplify the skills explorer and add a 2D relationship canvas#24
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas

Conversation

@harrymove-ctrl

@harrymove-ctrlharrymove-ctrl commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Product intent

/skills had two costs. Choosing one skill out of 34 meant reading 34 summary
paragraphs, while the things that decide the choice — the handle you would type
and the phrase that triggers it — sat inside prose written for another purpose.
And the reference graph, which is the real structure of the kit, was drawn as a
fixed circular ring: every skill the same weight, no grouping, nothing you could
move. It answered "are these connected" and nothing else.

This makes the catalog something you scan and the graph something you explore.

2D over 3D

A 3D force graph was considered and rejected, and that is recorded in the design
doc as prior art rather than a promise. It costs a WebGL context and a physics
loop on a page whose job is reading, it occludes its own labels, it degrades
badly on touch, and it has no honest accessible equivalent — the list would
quietly become the real interface for a large share of readers while the 3D view
took the budget. A force simulation was rejected for a smaller reason: placement
that differs on every load is a map you cannot refer back to.

What changed

Catalog. Header is a word and a sentence. A card carries the handle, title,
category and one trigger phrase; summaries, file counts and reference counts
move to detail where someone is comparing rather than scanning. Sort and
capability flags fold behind one Filters control, with active filters shown as
removable chips. The default view groups by category. Search, ranking, filters
and every deep link behave exactly as before.

Relationship canvas. Skills sit in fixed category lanes, alphabetical within
a lane, so the same graph produces the same map every load. React Flow owns pan,
zoom, drag, minimap and controls; selection, pinning, URL sync and persistence
stay in the view. Dragged positions persist per browser tab under a versioned
key, and Reset layout returns to canonical. The list is a first-class mode
carrying every skill and both directions of every relationship, and it is the
default under 768px.

Preserved. Detail, workspace, Preview, Source, Edit and browser-local drafts
are untouched. This changed how you find a skill, not what you can do with it.

URL contracts

URLBehavior
/skills?query=&category=&sort=&has=&skill=unchanged
/skills/visualize-interactions?skill=<id>selects and pins, cmk: prefix accepted
/skills/visualize-interactions?view=canvas|listexplicit view on any viewport
/skills/[skillId]/workspace?file=&mode=unchanged

Pan and zoom are local UI state and deliberately stay out of the URL.

Responsive

ViewportResult
390 × 8441 column, visualization defaults to List, no overflow
768 × 10242 columns, no overflow
1280 × 8002 columns, no inspector, no overflow
1440 × 900catalog plus inspector, canvas readable
1512 × 900inspector 834px, no collapsed panel, no overflow

Verified

  • Deterministic layout: identical node transforms across loads with storage cleared.
  • Stored layout restores (translate(1234px, 987px) from a seeded payload) and a
    wrong-version payload is discarded back to the canonical position.
  • ?skill=cmk:delivery-review selects and normalizes to ?skill=delivery-review.
  • ?view=list renders the list and never mounts the canvas.
  • Inspector content renders synchronously, not behind an exit animation.
  • Canvas chunk loads only when the canvas view is active: the visualization
    route's first load is 113 kB, down from 161 kB.
  • type-check, lint, clean build (85 static pages) and git diff --check pass.

Known accepted gaps

  • Idle edges measure below 3:1 by design; they are texture, and every
    relationship they hint at is at full contrast in the traced state, the
    inspector and the list.
  • Node dragging is exercised through React Flow's own handlers; the persistence
    layer either side of it is verified directly, but a physical drag was not
    scripted.
  • No automated axe or Lighthouse run: the devtools integration could not attach
    to its browser profile in this environment.

Scope

PR #22 and PR #21 were not opened, inspected, reviewed, modified, rebased or
merged. Nothing outside the skills dashboard, docs, roadmap and changelog is
touched.


Self-review corrections (d9a8968)

Checking this branch against the plan rather than trusting the first pass found
four defects, three of which the documentation had already claimed as working.

  • Hover tracing did not exist.SKEX-2.2 and the design doc both described
    it. Hover now traces while the pin keeps its marker and the inspector stays on
    the pinned skill.
  • The pin's halo never rendered. It was a Tailwind arbitrary shadow holding
    a color-mix() with commas, which the class parser drops silently, so pinned
    and hovered nodes were pixel-identical. Inline style now.
  • Nodes had no declared size, so React Flow held all 42 at
    visibility: hidden pending measurement. Where ResizeObserver delivery is
    throttled the map renders blank, and hidden nodes cannot take focus, which is
    why canvas keyboard access did not work. Sizes are declared now: 42/42 visible
    and node focus confirmed working.
  • The mobile inspector was a bare sheet. It is a dialog now: labelled,
    aria-modal, focus moved in, Tab trapped, Escape closes.

Also corrected: the catalog renders 2 columns at 1280, matching the agreed
matrix. The table above said 3, which was wrong. A third column has nowhere to
live, because the inspector takes over at 1440 — say the word if you would
rather move the inspector breakpoint up and keep a 3-column band.

Re-verified after the fixes

CheckResult
Node visibility42/42 visible
Canvas node focusdocument.activeElement is the node, name reads cmk:cicd, Setup & Infra. 7 references out…
Pin vs hoverpinned keeps a 4px halo, hovered has none, inspector stays pinned
Canvas node contrasthandle 8.16 dark / 5.63 light, meta 5.03 / 5.81 — AA in both
Mobile drawerrole=dialog, aria-modal, labelled, focus in, Escape closes and clears ?skill=
Catalog bundleneither the editor chunk nor React Flow is fetched on /skills
Repeated navigation3 client-side round trips: 1 flow instance on the graph route, 0 after leaving, canvas count steady at 1
1280 / 1440 / 15122 columns / inspector / inspector at 834px, no overflow at any width

Still not verified

No automated axe or Lighthouse run — the devtools integration cannot attach to
its browser profile here. A physical pointer drag of a node was not scripted;
the persistence layer either side of it is verified directly. And I cannot
attach screenshots to this PR from my environment, so the evidence above is
measurements rather than images.


Gate closure

Node count, 34 vs 42. The canvas renders 42 React Flow nodes: 34 skill
nodes, matching the catalog exactly, plus 8 lane heading nodes with ids
lane:delivery, lane:docs, lane:setup, lane:agent, lane:testing,
lane:sui, lane:sync, lane:session. Headings are nodes so the category
structure pans and zooms with the map instead of floating over it. No dangling
reference is rendered as a skill.

Real pointer drag, full lifecycle. Not just the persistence layer:

  1. Dragged adr with a real pointer from translate(0px, 372px).
  2. Landed at translate(295.619px, 554.857px).
  3. sessionStorage holds {"version":1,"positions":{"adr":{"x":295.62,"y":554.86}}}.
  4. Reloaded — restored to exactly translate(295.619px, 554.857px).
  5. Pressed Reset layout — back to translate(0px, 372px), storage cleared.

Live resize, not just reload.

StepResult
Reload at 1280no inspector, ?query=adr
1280 → 1440inspector mounts at 762px, URL gains &skill=adr
1440 → 1280inspector unmounts, URL drops skill, filter intact

No 40px panel, no stale selection, no URL overwrite.

Technical gate from clean state.git status shows only untracked
.claude/, which is not in the diff. 15 files changed against origin/main,
none outside skills UI, docs, roadmap and changelog. git diff --check clean.
No commit in this branch references PR #22 or PR #21. type-check, lint and a
clean build (85 static pages) all pass.

Canvas first revision (44957ea)

Review feedback on the previous head was that the feature was correct and
unpleasant: the map opened fitted to the viewport, so eight lanes were squeezed
into a panel and nodes rendered at roughly a centimetre of unreadable colour,
while the shell, the page header, three stat cards and a permanently docked
inspector took the rest of the screen. It read as a dashboard containing a
small graph. This revision makes the canvas the page.

Focus mode.F gives the canvas the whole application viewport: rail,
header, heading and stats collapse away, the inspector becomes an overlay, and
a floating toolbar carries Exit focus, Canvas/List, zoom, Fit all, Reset and
help. It is application level rather than the browser Fullscreen API, and it
works by setting data-skills-focus on <html> and collapsing the shell around
a canvas that never changes position in the React tree. Measured: the same
.react-flow element survives both directions of the toggle, exactly one
instance exists, and the viewport transform is unchanged across 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 round trips through
?focus=canvas.

Readable by default. No blanket fitView on load. With ?skill= the canvas
centres that node at zoom 1.1; otherwise it anchors the map's top left corner at
zoom 1, so the first thing on screen is a whole card. Fit all is now an
explicit control. Nodes and their type are larger, lanes are three columns wide
so no column starts off screen, and below zoom 0.62 a card drops its metadata
row rather than drawing it at sub 8px. Selecting a skill moves the viewport only
when the node is off frame or the zoom is below legible. LAYOUT_VERSION is 2,
because stored positions describe geometry that no longer exists.

Less chrome. 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. The
docked inspector takes layout only while a skill is selected, so the empty state
is all canvas: 976px of a 1280 viewport, 100% of the panel.

Automated accessibility, now closed

The DevTools integration could not attach (The browser is already running for /Users/.../chrome-devtools-mcp/chrome-profile), so the audit was run a
different way: axe-core 4.13.0 served same origin and executed in the page, with
transitions disabled first, because this environment throttles rAF and a
mid-transition getComputedStyle reports the previous theme's colours.

SurfaceViolationsPasses
/skills023
Canvas, docked inspector024
Canvas, focus mode024
List view024
Mobile inspector drawer, 390px024
Skill detail025
Workspace029

Scope: WCAG 2.0 and 2.1, levels A and AA. The run found four real defects, all
fixed in this commit:

  1. Informational text at --text-disabled measuring 2.54:1 to 4.27:1 on the
    light palette, across 22 nodes in the catalog and the lane counts on the map.
  2. aria-label on a bare div, which names nothing.
  3. role="tablist" owning links and buttons rather than only tabs, reported
    critical.
  4. Once the close control moved inside its tab to fix (3), a focusable button
    nested inside a widget role. The close affordance is now pointer only and out
    of the accessibility tree, with Delete or Backspace closing the focused
    tab; both paths verified.

GooeyTextReveal also stopped asking GSAP for aria: "auto", which was putting
a prohibited aria-label on a <p> while hiding every line beneath it, so a
paragraph could read as empty to assistive technology.

axe returns color-contrast as incomplete over the canvas, where backgrounds
are color-mix() values it will not resolve. Those were measured directly
instead, compositing each background through a canvas so color(srgb ...) and
color-mix() resolve to real pixels: the lowest ratio on graph and inspector
text is 5.03:1 dark and 5.35:1 light, against a 4.5:1 requirement.

End to end, re-run after the UX change

  • Focus: enter and exit by button and by F; canvas 820px to 1440px and
    back; probe on the DOM node survives both toggles; one .react-flow instance
    throughout; URL gains and drops focus=canvas.
  • Selection: pin from the canvas, from the list and from ?skill=; overlay
    drawer is role="dialog" + aria-modal, labelled, focus moves in, Tab is
    trapped, Esc closes it and focus returns to the node.
  • Drag lifecycle: real pointer drag moved adr from translate(0px, 392px)
    to translate(108px, 312px); sessionStorage recorded
    {"version":2,"positions":{"adr":{"x":108,"y":312}}}; reload restored it
    exactly; R returned it to canonical and cleared storage.
  • Viewport:Fit all reached zoom 0.458 with all eight lanes visible and
    metadata correctly dropped; zoom in/out; pan.
  • Responsive, reloaded at each width: 390 list by default with React Flow
    never loaded and no horizontal overflow; 768 canvas with focus available and
    no minimap; 1280 canvas 976px empty, 660px with the inspector docked; 1440
    focus mode at the full 1440.
  • Catalog and workspace: search, category chips, URL persistence, detail
    preview; workspace draft written, survived navigation away and back, and the
    close confirmation (role="alertdialog") still guards it.

Technical gate

rm -rf .next then type-check, lint, build: clean, 85 static pages.
git diff --check clean. Nothing outside the skills UI, the shared components
the audit implicated, docs, roadmap and changelog. .claude/ stays untracked.
No commit references PR #21 or #22.

React Flow is in neither /skills, the workspace, nor even the initial bundle
for /skills/visualize-interactions; it is a lazy chunk, confirmed against
app-build-manifest.json.

Files added to the diff by the audit

components/skills/file-content-pane.tsx and
components/motion/gooey-text-reveal.tsx were not part of this PR's original
scope. They are here because the accessibility run this PR was asked to perform
found a critical and a serious violation in them, and app/skills/layout.tsx
gains three class hooks so focus mode has something to collapse.

Clarity pass (6c25bda)

Review feedback on the previous head: the technical gates proved the canvas was
not blank, that drag and zoom worked, and that the page was accessible and
responsive — none of which proves a reader understands what the map is saying.
The page spoke graph theory (references, referenced by, entry points, out, in)
and a node card answered "how connected is this?" before it answered "what is
this?".

One vocabulary, defined once. references → uses, referenced by →
used by, out · inUses n · Used by n, graph → map, entry points
starting points, Fit all → Show whole map, Reset layout → Reset
map
, List → Browse list, and the page is How skills connect. The map,
the browse list, the skill detail page and the catalog now all use it, so the
direction is learned once.

Two things had to exist before the words could work.
SKILL_PURPOSE in lib/skill-types.ts is a curated one-line purpose per
skill, living in the site layer beside CATEGORY_MAP for the same reason that
map does: frontmatter has 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; this way changing a line
is a copy edit in one file, not a change to the skills themselves, and
skillPurpose() falls back to the first trigger phrase for anything new.
oftenUsedWith in lib/skill-graph.ts is not an edge — two skills keep
company when some third skill pulls in both, and ranking by shared parents
answers what else a reader will want open. For cmk:delivery-review it yields
cicd, delivery-pipeline, delivery-workflow.

A way in before anything is selected. The rail used to appear only on
selection, so the one moment a reader most needs help was the moment the page
showed them 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. Below 1280 and in
focus mode the same groups are a strip over the map; the browse list carries
them as its first two sections. A group jump requested from the list is handed
to the canvas as its opening frame, because at that moment the canvas does not
exist to be moved.

Starting points are honest. In-degree zero gives five skills, one of which
(cmk:codebase-docs) has no connections in either direction. Offering it as a
place to begin is worse than saying nothing, so a starting point is a skill
nothing pulls in that pulls in something itself — four of them.

The node card is a handle, a title and what the skill is for. The counts
moved to the panel, where each sits beside the list it describes.

Framing is clamped to the map and centred against the visible width, so a
deep link to a first-row skill no longer opens with a third of the canvas
empty, and the focus drawer no longer covers the node the map just centred.

Verification, re-run end to end

37 automated checks against a production build, in a real browser (the
in-app preview stops compositing when its pane is hidden, which stalls every
animated viewport move and produced false failures — every result below comes
from headless Chrome over CDP):

AreaChecks
Copy and vocabularyheading, subtitle, stat line, no surviving old terms
Node cardhandle + title + purpose, exactly
InspectorOften used with / Uses / Used by, no raw degree pair, both CTAs
Start panelpresent when nothing is selected; starting points exclude the orphan
Group jumpframes the lane from the map and from the browse list
Focus modeF enters, full 1440 viewport, Esc peels hint → selection → focus
Browse listthree sections, rows read Uses n · Used by n
Dragmoves, persists at version: 3, restores on reload, R resets and clears
Catalogcard carries the purpose line and says "Ask with"
Detailsays Uses / Used by, no References / Referenced by
Responsive390 list-only with no React Flow and no overflow, 768 strip, 1280 rail

Accessibility: axe-core 4.13 at WCAG 2.0/2.1 A and AA on all seven
surfaces — catalog 27 passes, map + inspector 24, focus mode 24, browse list
24, mobile list + drawer 25, detail 25, workspace 29. Zero violations. The
run found one more real defect, now fixed: the rendered-document and eval
scrollers have no focusable child when a skill's body carries no links, which
left a keyboard user unable to scroll them.

Build:rm -rf .next then type-check, lint and build clean, 85 static
pages. React Flow remains absent from every bundle including the map route's
initial load.

Gaps that remain open

  1. Screenshots are not attached to this PR. Twelve fresh captures at 2x,
    desktop and mobile, light and dark, rendered from the production build to
    ~/Desktop/pr24-skills-canvas/ and ready to drop into this description.
    Nothing is committed as test media.
  2. The purpose lines are new copy. Thirty-four one-liners written for this
    PR and grounded in each skill's own description and heading. They deserve a
    read as copy, not as code.
  3. No human review yet, and specifically none by someone reading the page
    cold. That is the remaining merge gate, and the one the automated suite
    cannot stand in for.

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.
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.
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.
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.
The changelog entry and the roadmap item were written before the PR existed, so
both carried a guessed number. They name PR #24 now.
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.
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 <html> 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.
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.
The canvas panel was translucent, so the animated wash behind the shell
came through it and lifted the surface to something lighter than the
page itself. The dot grid, drawn at the same value as a panel border,
then had nothing to sit against and disappeared: the map read as a pale
rectangle with cards floating on it rather than as a space you look
into.
The map now has its own surface token, opaque and below the page
instead of above it — near black in dark, white in light — with a dot
colour picked to read against it, and it is a fraction larger. Focus
mode drops the wash entirely; there is nothing decorative left to see
past. The minimap takes the canvas colour too, since an elevated panel
in that corner was the brightest thing on a near-black screen, which is
backwards for an overview.
37 end-to-end checks still pass, axe still reports nothing on all seven
surfaces.
@harrymove-ctrl
harrymove-ctrl merged commit 125a113 into mainAug 29, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@harrymove-ctrl@hien-p
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Simplify the skills explorer and add a 2D relationship canvas - #24

Merged
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas
Aug 29, 2026
Merged

Simplify the skills explorer and add a 2D relationship canvas#24
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas

Conversation

@harrymove-ctrl

@harrymove-ctrlharrymove-ctrl commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Product intent

/skills had two costs. Choosing one skill out of 34 meant reading 34 summary
paragraphs, while the things that decide the choice — the handle you would type
and the phrase that triggers it — sat inside prose written for another purpose.
And the reference graph, which is the real structure of the kit, was drawn as a
fixed circular ring: every skill the same weight, no grouping, nothing you could
move. It answered "are these connected" and nothing else.

This makes the catalog something you scan and the graph something you explore.

2D over 3D

A 3D force graph was considered and rejected, and that is recorded in the design
doc as prior art rather than a promise. It costs a WebGL context and a physics
loop on a page whose job is reading, it occludes its own labels, it degrades
badly on touch, and it has no honest accessible equivalent — the list would
quietly become the real interface for a large share of readers while the 3D view
took the budget. A force simulation was rejected for a smaller reason: placement
that differs on every load is a map you cannot refer back to.

What changed

Catalog. Header is a word and a sentence. A card carries the handle, title,
category and one trigger phrase; summaries, file counts and reference counts
move to detail where someone is comparing rather than scanning. Sort and
capability flags fold behind one Filters control, with active filters shown as
removable chips. The default view groups by category. Search, ranking, filters
and every deep link behave exactly as before.

Relationship canvas. Skills sit in fixed category lanes, alphabetical within
a lane, so the same graph produces the same map every load. React Flow owns pan,
zoom, drag, minimap and controls; selection, pinning, URL sync and persistence
stay in the view. Dragged positions persist per browser tab under a versioned
key, and Reset layout returns to canonical. The list is a first-class mode
carrying every skill and both directions of every relationship, and it is the
default under 768px.

Preserved. Detail, workspace, Preview, Source, Edit and browser-local drafts
are untouched. This changed how you find a skill, not what you can do with it.

URL contracts

URLBehavior
/skills?query=&category=&sort=&has=&skill=unchanged
/skills/visualize-interactions?skill=<id>selects and pins, cmk: prefix accepted
/skills/visualize-interactions?view=canvas|listexplicit view on any viewport
/skills/[skillId]/workspace?file=&mode=unchanged

Pan and zoom are local UI state and deliberately stay out of the URL.

Responsive

ViewportResult
390 × 8441 column, visualization defaults to List, no overflow
768 × 10242 columns, no overflow
1280 × 8002 columns, no inspector, no overflow
1440 × 900catalog plus inspector, canvas readable
1512 × 900inspector 834px, no collapsed panel, no overflow

Verified

  • Deterministic layout: identical node transforms across loads with storage cleared.
  • Stored layout restores (translate(1234px, 987px) from a seeded payload) and a
    wrong-version payload is discarded back to the canonical position.
  • ?skill=cmk:delivery-review selects and normalizes to ?skill=delivery-review.
  • ?view=list renders the list and never mounts the canvas.
  • Inspector content renders synchronously, not behind an exit animation.
  • Canvas chunk loads only when the canvas view is active: the visualization
    route's first load is 113 kB, down from 161 kB.
  • type-check, lint, clean build (85 static pages) and git diff --check pass.

Known accepted gaps

  • Idle edges measure below 3:1 by design; they are texture, and every
    relationship they hint at is at full contrast in the traced state, the
    inspector and the list.
  • Node dragging is exercised through React Flow's own handlers; the persistence
    layer either side of it is verified directly, but a physical drag was not
    scripted.
  • No automated axe or Lighthouse run: the devtools integration could not attach
    to its browser profile in this environment.

Scope

PR #22 and PR #21 were not opened, inspected, reviewed, modified, rebased or
merged. Nothing outside the skills dashboard, docs, roadmap and changelog is
touched.


Self-review corrections (d9a8968)

Checking this branch against the plan rather than trusting the first pass found
four defects, three of which the documentation had already claimed as working.

  • Hover tracing did not exist.SKEX-2.2 and the design doc both described
    it. Hover now traces while the pin keeps its marker and the inspector stays on
    the pinned skill.
  • The pin's halo never rendered. It was a Tailwind arbitrary shadow holding
    a color-mix() with commas, which the class parser drops silently, so pinned
    and hovered nodes were pixel-identical. Inline style now.
  • Nodes had no declared size, so React Flow held all 42 at
    visibility: hidden pending measurement. Where ResizeObserver delivery is
    throttled the map renders blank, and hidden nodes cannot take focus, which is
    why canvas keyboard access did not work. Sizes are declared now: 42/42 visible
    and node focus confirmed working.
  • The mobile inspector was a bare sheet. It is a dialog now: labelled,
    aria-modal, focus moved in, Tab trapped, Escape closes.

Also corrected: the catalog renders 2 columns at 1280, matching the agreed
matrix. The table above said 3, which was wrong. A third column has nowhere to
live, because the inspector takes over at 1440 — say the word if you would
rather move the inspector breakpoint up and keep a 3-column band.

Re-verified after the fixes

CheckResult
Node visibility42/42 visible
Canvas node focusdocument.activeElement is the node, name reads cmk:cicd, Setup & Infra. 7 references out…
Pin vs hoverpinned keeps a 4px halo, hovered has none, inspector stays pinned
Canvas node contrasthandle 8.16 dark / 5.63 light, meta 5.03 / 5.81 — AA in both
Mobile drawerrole=dialog, aria-modal, labelled, focus in, Escape closes and clears ?skill=
Catalog bundleneither the editor chunk nor React Flow is fetched on /skills
Repeated navigation3 client-side round trips: 1 flow instance on the graph route, 0 after leaving, canvas count steady at 1
1280 / 1440 / 15122 columns / inspector / inspector at 834px, no overflow at any width

Still not verified

No automated axe or Lighthouse run — the devtools integration cannot attach to
its browser profile here. A physical pointer drag of a node was not scripted;
the persistence layer either side of it is verified directly. And I cannot
attach screenshots to this PR from my environment, so the evidence above is
measurements rather than images.


Gate closure

Node count, 34 vs 42. The canvas renders 42 React Flow nodes: 34 skill
nodes, matching the catalog exactly, plus 8 lane heading nodes with ids
lane:delivery, lane:docs, lane:setup, lane:agent, lane:testing,
lane:sui, lane:sync, lane:session. Headings are nodes so the category
structure pans and zooms with the map instead of floating over it. No dangling
reference is rendered as a skill.

Real pointer drag, full lifecycle. Not just the persistence layer:

  1. Dragged adr with a real pointer from translate(0px, 372px).
  2. Landed at translate(295.619px, 554.857px).
  3. sessionStorage holds {"version":1,"positions":{"adr":{"x":295.62,"y":554.86}}}.
  4. Reloaded — restored to exactly translate(295.619px, 554.857px).
  5. Pressed Reset layout — back to translate(0px, 372px), storage cleared.

Live resize, not just reload.

StepResult
Reload at 1280no inspector, ?query=adr
1280 → 1440inspector mounts at 762px, URL gains &skill=adr
1440 → 1280inspector unmounts, URL drops skill, filter intact

No 40px panel, no stale selection, no URL overwrite.

Technical gate from clean state.git status shows only untracked
.claude/, which is not in the diff. 15 files changed against origin/main,
none outside skills UI, docs, roadmap and changelog. git diff --check clean.
No commit in this branch references PR #22 or PR #21. type-check, lint and a
clean build (85 static pages) all pass.

Canvas first revision (44957ea)

Review feedback on the previous head was that the feature was correct and
unpleasant: the map opened fitted to the viewport, so eight lanes were squeezed
into a panel and nodes rendered at roughly a centimetre of unreadable colour,
while the shell, the page header, three stat cards and a permanently docked
inspector took the rest of the screen. It read as a dashboard containing a
small graph. This revision makes the canvas the page.

Focus mode.F gives the canvas the whole application viewport: rail,
header, heading and stats collapse away, the inspector becomes an overlay, and
a floating toolbar carries Exit focus, Canvas/List, zoom, Fit all, Reset and
help. It is application level rather than the browser Fullscreen API, and it
works by setting data-skills-focus on <html> and collapsing the shell around
a canvas that never changes position in the React tree. Measured: the same
.react-flow element survives both directions of the toggle, exactly one
instance exists, and the viewport transform is unchanged across 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 round trips through
?focus=canvas.

Readable by default. No blanket fitView on load. With ?skill= the canvas
centres that node at zoom 1.1; otherwise it anchors the map's top left corner at
zoom 1, so the first thing on screen is a whole card. Fit all is now an
explicit control. Nodes and their type are larger, lanes are three columns wide
so no column starts off screen, and below zoom 0.62 a card drops its metadata
row rather than drawing it at sub 8px. Selecting a skill moves the viewport only
when the node is off frame or the zoom is below legible. LAYOUT_VERSION is 2,
because stored positions describe geometry that no longer exists.

Less chrome. 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. The
docked inspector takes layout only while a skill is selected, so the empty state
is all canvas: 976px of a 1280 viewport, 100% of the panel.

Automated accessibility, now closed

The DevTools integration could not attach (The browser is already running for /Users/.../chrome-devtools-mcp/chrome-profile), so the audit was run a
different way: axe-core 4.13.0 served same origin and executed in the page, with
transitions disabled first, because this environment throttles rAF and a
mid-transition getComputedStyle reports the previous theme's colours.

SurfaceViolationsPasses
/skills023
Canvas, docked inspector024
Canvas, focus mode024
List view024
Mobile inspector drawer, 390px024
Skill detail025
Workspace029

Scope: WCAG 2.0 and 2.1, levels A and AA. The run found four real defects, all
fixed in this commit:

  1. Informational text at --text-disabled measuring 2.54:1 to 4.27:1 on the
    light palette, across 22 nodes in the catalog and the lane counts on the map.
  2. aria-label on a bare div, which names nothing.
  3. role="tablist" owning links and buttons rather than only tabs, reported
    critical.
  4. Once the close control moved inside its tab to fix (3), a focusable button
    nested inside a widget role. The close affordance is now pointer only and out
    of the accessibility tree, with Delete or Backspace closing the focused
    tab; both paths verified.

GooeyTextReveal also stopped asking GSAP for aria: "auto", which was putting
a prohibited aria-label on a <p> while hiding every line beneath it, so a
paragraph could read as empty to assistive technology.

axe returns color-contrast as incomplete over the canvas, where backgrounds
are color-mix() values it will not resolve. Those were measured directly
instead, compositing each background through a canvas so color(srgb ...) and
color-mix() resolve to real pixels: the lowest ratio on graph and inspector
text is 5.03:1 dark and 5.35:1 light, against a 4.5:1 requirement.

End to end, re-run after the UX change

  • Focus: enter and exit by button and by F; canvas 820px to 1440px and
    back; probe on the DOM node survives both toggles; one .react-flow instance
    throughout; URL gains and drops focus=canvas.
  • Selection: pin from the canvas, from the list and from ?skill=; overlay
    drawer is role="dialog" + aria-modal, labelled, focus moves in, Tab is
    trapped, Esc closes it and focus returns to the node.
  • Drag lifecycle: real pointer drag moved adr from translate(0px, 392px)
    to translate(108px, 312px); sessionStorage recorded
    {"version":2,"positions":{"adr":{"x":108,"y":312}}}; reload restored it
    exactly; R returned it to canonical and cleared storage.
  • Viewport:Fit all reached zoom 0.458 with all eight lanes visible and
    metadata correctly dropped; zoom in/out; pan.
  • Responsive, reloaded at each width: 390 list by default with React Flow
    never loaded and no horizontal overflow; 768 canvas with focus available and
    no minimap; 1280 canvas 976px empty, 660px with the inspector docked; 1440
    focus mode at the full 1440.
  • Catalog and workspace: search, category chips, URL persistence, detail
    preview; workspace draft written, survived navigation away and back, and the
    close confirmation (role="alertdialog") still guards it.

Technical gate

rm -rf .next then type-check, lint, build: clean, 85 static pages.
git diff --check clean. Nothing outside the skills UI, the shared components
the audit implicated, docs, roadmap and changelog. .claude/ stays untracked.
No commit references PR #21 or #22.

React Flow is in neither /skills, the workspace, nor even the initial bundle
for /skills/visualize-interactions; it is a lazy chunk, confirmed against
app-build-manifest.json.

Files added to the diff by the audit

components/skills/file-content-pane.tsx and
components/motion/gooey-text-reveal.tsx were not part of this PR's original
scope. They are here because the accessibility run this PR was asked to perform
found a critical and a serious violation in them, and app/skills/layout.tsx
gains three class hooks so focus mode has something to collapse.

Clarity pass (6c25bda)

Review feedback on the previous head: the technical gates proved the canvas was
not blank, that drag and zoom worked, and that the page was accessible and
responsive — none of which proves a reader understands what the map is saying.
The page spoke graph theory (references, referenced by, entry points, out, in)
and a node card answered "how connected is this?" before it answered "what is
this?".

One vocabulary, defined once. references → uses, referenced by →
used by, out · inUses n · Used by n, graph → map, entry points
starting points, Fit all → Show whole map, Reset layout → Reset
map
, List → Browse list, and the page is How skills connect. The map,
the browse list, the skill detail page and the catalog now all use it, so the
direction is learned once.

Two things had to exist before the words could work.
SKILL_PURPOSE in lib/skill-types.ts is a curated one-line purpose per
skill, living in the site layer beside CATEGORY_MAP for the same reason that
map does: frontmatter has 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; this way changing a line
is a copy edit in one file, not a change to the skills themselves, and
skillPurpose() falls back to the first trigger phrase for anything new.
oftenUsedWith in lib/skill-graph.ts is not an edge — two skills keep
company when some third skill pulls in both, and ranking by shared parents
answers what else a reader will want open. For cmk:delivery-review it yields
cicd, delivery-pipeline, delivery-workflow.

A way in before anything is selected. The rail used to appear only on
selection, so the one moment a reader most needs help was the moment the page
showed them 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. Below 1280 and in
focus mode the same groups are a strip over the map; the browse list carries
them as its first two sections. A group jump requested from the list is handed
to the canvas as its opening frame, because at that moment the canvas does not
exist to be moved.

Starting points are honest. In-degree zero gives five skills, one of which
(cmk:codebase-docs) has no connections in either direction. Offering it as a
place to begin is worse than saying nothing, so a starting point is a skill
nothing pulls in that pulls in something itself — four of them.

The node card is a handle, a title and what the skill is for. The counts
moved to the panel, where each sits beside the list it describes.

Framing is clamped to the map and centred against the visible width, so a
deep link to a first-row skill no longer opens with a third of the canvas
empty, and the focus drawer no longer covers the node the map just centred.

Verification, re-run end to end

37 automated checks against a production build, in a real browser (the
in-app preview stops compositing when its pane is hidden, which stalls every
animated viewport move and produced false failures — every result below comes
from headless Chrome over CDP):

AreaChecks
Copy and vocabularyheading, subtitle, stat line, no surviving old terms
Node cardhandle + title + purpose, exactly
InspectorOften used with / Uses / Used by, no raw degree pair, both CTAs
Start panelpresent when nothing is selected; starting points exclude the orphan
Group jumpframes the lane from the map and from the browse list
Focus modeF enters, full 1440 viewport, Esc peels hint → selection → focus
Browse listthree sections, rows read Uses n · Used by n
Dragmoves, persists at version: 3, restores on reload, R resets and clears
Catalogcard carries the purpose line and says "Ask with"
Detailsays Uses / Used by, no References / Referenced by
Responsive390 list-only with no React Flow and no overflow, 768 strip, 1280 rail

Accessibility: axe-core 4.13 at WCAG 2.0/2.1 A and AA on all seven
surfaces — catalog 27 passes, map + inspector 24, focus mode 24, browse list
24, mobile list + drawer 25, detail 25, workspace 29. Zero violations. The
run found one more real defect, now fixed: the rendered-document and eval
scrollers have no focusable child when a skill's body carries no links, which
left a keyboard user unable to scroll them.

Build:rm -rf .next then type-check, lint and build clean, 85 static
pages. React Flow remains absent from every bundle including the map route's
initial load.

Gaps that remain open

  1. Screenshots are not attached to this PR. Twelve fresh captures at 2x,
    desktop and mobile, light and dark, rendered from the production build to
    ~/Desktop/pr24-skills-canvas/ and ready to drop into this description.
    Nothing is committed as test media.
  2. The purpose lines are new copy. Thirty-four one-liners written for this
    PR and grounded in each skill's own description and heading. They deserve a
    read as copy, not as code.
  3. No human review yet, and specifically none by someone reading the page
    cold. That is the remaining merge gate, and the one the automated suite
    cannot stand in for.

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.
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.
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.
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.
The changelog entry and the roadmap item were written before the PR existed, so
both carried a guessed number. They name PR #24 now.
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.
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 <html> 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.
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.
The canvas panel was translucent, so the animated wash behind the shell
came through it and lifted the surface to something lighter than the
page itself. The dot grid, drawn at the same value as a panel border,
then had nothing to sit against and disappeared: the map read as a pale
rectangle with cards floating on it rather than as a space you look
into.
The map now has its own surface token, opaque and below the page
instead of above it — near black in dark, white in light — with a dot
colour picked to read against it, and it is a fraction larger. Focus
mode drops the wash entirely; there is nothing decorative left to see
past. The minimap takes the canvas colour too, since an elevated panel
in that corner was the brightest thing on a near-black screen, which is
backwards for an overview.
37 end-to-end checks still pass, axe still reports nothing on all seven
surfaces.
@harrymove-ctrl
harrymove-ctrl merged commit 125a113 into mainAug 29, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@harrymove-ctrl@hien-p
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Simplify the skills explorer and add a 2D relationship canvas - #24

Merged
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas
Aug 29, 2026
Merged

Simplify the skills explorer and add a 2D relationship canvas#24
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas

Conversation

@harrymove-ctrl

@harrymove-ctrlharrymove-ctrl commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Product intent

/skills had two costs. Choosing one skill out of 34 meant reading 34 summary
paragraphs, while the things that decide the choice — the handle you would type
and the phrase that triggers it — sat inside prose written for another purpose.
And the reference graph, which is the real structure of the kit, was drawn as a
fixed circular ring: every skill the same weight, no grouping, nothing you could
move. It answered "are these connected" and nothing else.

This makes the catalog something you scan and the graph something you explore.

2D over 3D

A 3D force graph was considered and rejected, and that is recorded in the design
doc as prior art rather than a promise. It costs a WebGL context and a physics
loop on a page whose job is reading, it occludes its own labels, it degrades
badly on touch, and it has no honest accessible equivalent — the list would
quietly become the real interface for a large share of readers while the 3D view
took the budget. A force simulation was rejected for a smaller reason: placement
that differs on every load is a map you cannot refer back to.

What changed

Catalog. Header is a word and a sentence. A card carries the handle, title,
category and one trigger phrase; summaries, file counts and reference counts
move to detail where someone is comparing rather than scanning. Sort and
capability flags fold behind one Filters control, with active filters shown as
removable chips. The default view groups by category. Search, ranking, filters
and every deep link behave exactly as before.

Relationship canvas. Skills sit in fixed category lanes, alphabetical within
a lane, so the same graph produces the same map every load. React Flow owns pan,
zoom, drag, minimap and controls; selection, pinning, URL sync and persistence
stay in the view. Dragged positions persist per browser tab under a versioned
key, and Reset layout returns to canonical. The list is a first-class mode
carrying every skill and both directions of every relationship, and it is the
default under 768px.

Preserved. Detail, workspace, Preview, Source, Edit and browser-local drafts
are untouched. This changed how you find a skill, not what you can do with it.

URL contracts

URLBehavior
/skills?query=&category=&sort=&has=&skill=unchanged
/skills/visualize-interactions?skill=<id>selects and pins, cmk: prefix accepted
/skills/visualize-interactions?view=canvas|listexplicit view on any viewport
/skills/[skillId]/workspace?file=&mode=unchanged

Pan and zoom are local UI state and deliberately stay out of the URL.

Responsive

ViewportResult
390 × 8441 column, visualization defaults to List, no overflow
768 × 10242 columns, no overflow
1280 × 8002 columns, no inspector, no overflow
1440 × 900catalog plus inspector, canvas readable
1512 × 900inspector 834px, no collapsed panel, no overflow

Verified

  • Deterministic layout: identical node transforms across loads with storage cleared.
  • Stored layout restores (translate(1234px, 987px) from a seeded payload) and a
    wrong-version payload is discarded back to the canonical position.
  • ?skill=cmk:delivery-review selects and normalizes to ?skill=delivery-review.
  • ?view=list renders the list and never mounts the canvas.
  • Inspector content renders synchronously, not behind an exit animation.
  • Canvas chunk loads only when the canvas view is active: the visualization
    route's first load is 113 kB, down from 161 kB.
  • type-check, lint, clean build (85 static pages) and git diff --check pass.

Known accepted gaps

  • Idle edges measure below 3:1 by design; they are texture, and every
    relationship they hint at is at full contrast in the traced state, the
    inspector and the list.
  • Node dragging is exercised through React Flow's own handlers; the persistence
    layer either side of it is verified directly, but a physical drag was not
    scripted.
  • No automated axe or Lighthouse run: the devtools integration could not attach
    to its browser profile in this environment.

Scope

PR #22 and PR #21 were not opened, inspected, reviewed, modified, rebased or
merged. Nothing outside the skills dashboard, docs, roadmap and changelog is
touched.


Self-review corrections (d9a8968)

Checking this branch against the plan rather than trusting the first pass found
four defects, three of which the documentation had already claimed as working.

  • Hover tracing did not exist.SKEX-2.2 and the design doc both described
    it. Hover now traces while the pin keeps its marker and the inspector stays on
    the pinned skill.
  • The pin's halo never rendered. It was a Tailwind arbitrary shadow holding
    a color-mix() with commas, which the class parser drops silently, so pinned
    and hovered nodes were pixel-identical. Inline style now.
  • Nodes had no declared size, so React Flow held all 42 at
    visibility: hidden pending measurement. Where ResizeObserver delivery is
    throttled the map renders blank, and hidden nodes cannot take focus, which is
    why canvas keyboard access did not work. Sizes are declared now: 42/42 visible
    and node focus confirmed working.
  • The mobile inspector was a bare sheet. It is a dialog now: labelled,
    aria-modal, focus moved in, Tab trapped, Escape closes.

Also corrected: the catalog renders 2 columns at 1280, matching the agreed
matrix. The table above said 3, which was wrong. A third column has nowhere to
live, because the inspector takes over at 1440 — say the word if you would
rather move the inspector breakpoint up and keep a 3-column band.

Re-verified after the fixes

CheckResult
Node visibility42/42 visible
Canvas node focusdocument.activeElement is the node, name reads cmk:cicd, Setup & Infra. 7 references out…
Pin vs hoverpinned keeps a 4px halo, hovered has none, inspector stays pinned
Canvas node contrasthandle 8.16 dark / 5.63 light, meta 5.03 / 5.81 — AA in both
Mobile drawerrole=dialog, aria-modal, labelled, focus in, Escape closes and clears ?skill=
Catalog bundleneither the editor chunk nor React Flow is fetched on /skills
Repeated navigation3 client-side round trips: 1 flow instance on the graph route, 0 after leaving, canvas count steady at 1
1280 / 1440 / 15122 columns / inspector / inspector at 834px, no overflow at any width

Still not verified

No automated axe or Lighthouse run — the devtools integration cannot attach to
its browser profile here. A physical pointer drag of a node was not scripted;
the persistence layer either side of it is verified directly. And I cannot
attach screenshots to this PR from my environment, so the evidence above is
measurements rather than images.


Gate closure

Node count, 34 vs 42. The canvas renders 42 React Flow nodes: 34 skill
nodes, matching the catalog exactly, plus 8 lane heading nodes with ids
lane:delivery, lane:docs, lane:setup, lane:agent, lane:testing,
lane:sui, lane:sync, lane:session. Headings are nodes so the category
structure pans and zooms with the map instead of floating over it. No dangling
reference is rendered as a skill.

Real pointer drag, full lifecycle. Not just the persistence layer:

  1. Dragged adr with a real pointer from translate(0px, 372px).
  2. Landed at translate(295.619px, 554.857px).
  3. sessionStorage holds {"version":1,"positions":{"adr":{"x":295.62,"y":554.86}}}.
  4. Reloaded — restored to exactly translate(295.619px, 554.857px).
  5. Pressed Reset layout — back to translate(0px, 372px), storage cleared.

Live resize, not just reload.

StepResult
Reload at 1280no inspector, ?query=adr
1280 → 1440inspector mounts at 762px, URL gains &skill=adr
1440 → 1280inspector unmounts, URL drops skill, filter intact

No 40px panel, no stale selection, no URL overwrite.

Technical gate from clean state.git status shows only untracked
.claude/, which is not in the diff. 15 files changed against origin/main,
none outside skills UI, docs, roadmap and changelog. git diff --check clean.
No commit in this branch references PR #22 or PR #21. type-check, lint and a
clean build (85 static pages) all pass.

Canvas first revision (44957ea)

Review feedback on the previous head was that the feature was correct and
unpleasant: the map opened fitted to the viewport, so eight lanes were squeezed
into a panel and nodes rendered at roughly a centimetre of unreadable colour,
while the shell, the page header, three stat cards and a permanently docked
inspector took the rest of the screen. It read as a dashboard containing a
small graph. This revision makes the canvas the page.

Focus mode.F gives the canvas the whole application viewport: rail,
header, heading and stats collapse away, the inspector becomes an overlay, and
a floating toolbar carries Exit focus, Canvas/List, zoom, Fit all, Reset and
help. It is application level rather than the browser Fullscreen API, and it
works by setting data-skills-focus on <html> and collapsing the shell around
a canvas that never changes position in the React tree. Measured: the same
.react-flow element survives both directions of the toggle, exactly one
instance exists, and the viewport transform is unchanged across 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 round trips through
?focus=canvas.

Readable by default. No blanket fitView on load. With ?skill= the canvas
centres that node at zoom 1.1; otherwise it anchors the map's top left corner at
zoom 1, so the first thing on screen is a whole card. Fit all is now an
explicit control. Nodes and their type are larger, lanes are three columns wide
so no column starts off screen, and below zoom 0.62 a card drops its metadata
row rather than drawing it at sub 8px. Selecting a skill moves the viewport only
when the node is off frame or the zoom is below legible. LAYOUT_VERSION is 2,
because stored positions describe geometry that no longer exists.

Less chrome. 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. The
docked inspector takes layout only while a skill is selected, so the empty state
is all canvas: 976px of a 1280 viewport, 100% of the panel.

Automated accessibility, now closed

The DevTools integration could not attach (The browser is already running for /Users/.../chrome-devtools-mcp/chrome-profile), so the audit was run a
different way: axe-core 4.13.0 served same origin and executed in the page, with
transitions disabled first, because this environment throttles rAF and a
mid-transition getComputedStyle reports the previous theme's colours.

SurfaceViolationsPasses
/skills023
Canvas, docked inspector024
Canvas, focus mode024
List view024
Mobile inspector drawer, 390px024
Skill detail025
Workspace029

Scope: WCAG 2.0 and 2.1, levels A and AA. The run found four real defects, all
fixed in this commit:

  1. Informational text at --text-disabled measuring 2.54:1 to 4.27:1 on the
    light palette, across 22 nodes in the catalog and the lane counts on the map.
  2. aria-label on a bare div, which names nothing.
  3. role="tablist" owning links and buttons rather than only tabs, reported
    critical.
  4. Once the close control moved inside its tab to fix (3), a focusable button
    nested inside a widget role. The close affordance is now pointer only and out
    of the accessibility tree, with Delete or Backspace closing the focused
    tab; both paths verified.

GooeyTextReveal also stopped asking GSAP for aria: "auto", which was putting
a prohibited aria-label on a <p> while hiding every line beneath it, so a
paragraph could read as empty to assistive technology.

axe returns color-contrast as incomplete over the canvas, where backgrounds
are color-mix() values it will not resolve. Those were measured directly
instead, compositing each background through a canvas so color(srgb ...) and
color-mix() resolve to real pixels: the lowest ratio on graph and inspector
text is 5.03:1 dark and 5.35:1 light, against a 4.5:1 requirement.

End to end, re-run after the UX change

  • Focus: enter and exit by button and by F; canvas 820px to 1440px and
    back; probe on the DOM node survives both toggles; one .react-flow instance
    throughout; URL gains and drops focus=canvas.
  • Selection: pin from the canvas, from the list and from ?skill=; overlay
    drawer is role="dialog" + aria-modal, labelled, focus moves in, Tab is
    trapped, Esc closes it and focus returns to the node.
  • Drag lifecycle: real pointer drag moved adr from translate(0px, 392px)
    to translate(108px, 312px); sessionStorage recorded
    {"version":2,"positions":{"adr":{"x":108,"y":312}}}; reload restored it
    exactly; R returned it to canonical and cleared storage.
  • Viewport:Fit all reached zoom 0.458 with all eight lanes visible and
    metadata correctly dropped; zoom in/out; pan.
  • Responsive, reloaded at each width: 390 list by default with React Flow
    never loaded and no horizontal overflow; 768 canvas with focus available and
    no minimap; 1280 canvas 976px empty, 660px with the inspector docked; 1440
    focus mode at the full 1440.
  • Catalog and workspace: search, category chips, URL persistence, detail
    preview; workspace draft written, survived navigation away and back, and the
    close confirmation (role="alertdialog") still guards it.

Technical gate

rm -rf .next then type-check, lint, build: clean, 85 static pages.
git diff --check clean. Nothing outside the skills UI, the shared components
the audit implicated, docs, roadmap and changelog. .claude/ stays untracked.
No commit references PR #21 or #22.

React Flow is in neither /skills, the workspace, nor even the initial bundle
for /skills/visualize-interactions; it is a lazy chunk, confirmed against
app-build-manifest.json.

Files added to the diff by the audit

components/skills/file-content-pane.tsx and
components/motion/gooey-text-reveal.tsx were not part of this PR's original
scope. They are here because the accessibility run this PR was asked to perform
found a critical and a serious violation in them, and app/skills/layout.tsx
gains three class hooks so focus mode has something to collapse.

Clarity pass (6c25bda)

Review feedback on the previous head: the technical gates proved the canvas was
not blank, that drag and zoom worked, and that the page was accessible and
responsive — none of which proves a reader understands what the map is saying.
The page spoke graph theory (references, referenced by, entry points, out, in)
and a node card answered "how connected is this?" before it answered "what is
this?".

One vocabulary, defined once. references → uses, referenced by →
used by, out · inUses n · Used by n, graph → map, entry points
starting points, Fit all → Show whole map, Reset layout → Reset
map
, List → Browse list, and the page is How skills connect. The map,
the browse list, the skill detail page and the catalog now all use it, so the
direction is learned once.

Two things had to exist before the words could work.
SKILL_PURPOSE in lib/skill-types.ts is a curated one-line purpose per
skill, living in the site layer beside CATEGORY_MAP for the same reason that
map does: frontmatter has 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; this way changing a line
is a copy edit in one file, not a change to the skills themselves, and
skillPurpose() falls back to the first trigger phrase for anything new.
oftenUsedWith in lib/skill-graph.ts is not an edge — two skills keep
company when some third skill pulls in both, and ranking by shared parents
answers what else a reader will want open. For cmk:delivery-review it yields
cicd, delivery-pipeline, delivery-workflow.

A way in before anything is selected. The rail used to appear only on
selection, so the one moment a reader most needs help was the moment the page
showed them 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. Below 1280 and in
focus mode the same groups are a strip over the map; the browse list carries
them as its first two sections. A group jump requested from the list is handed
to the canvas as its opening frame, because at that moment the canvas does not
exist to be moved.

Starting points are honest. In-degree zero gives five skills, one of which
(cmk:codebase-docs) has no connections in either direction. Offering it as a
place to begin is worse than saying nothing, so a starting point is a skill
nothing pulls in that pulls in something itself — four of them.

The node card is a handle, a title and what the skill is for. The counts
moved to the panel, where each sits beside the list it describes.

Framing is clamped to the map and centred against the visible width, so a
deep link to a first-row skill no longer opens with a third of the canvas
empty, and the focus drawer no longer covers the node the map just centred.

Verification, re-run end to end

37 automated checks against a production build, in a real browser (the
in-app preview stops compositing when its pane is hidden, which stalls every
animated viewport move and produced false failures — every result below comes
from headless Chrome over CDP):

AreaChecks
Copy and vocabularyheading, subtitle, stat line, no surviving old terms
Node cardhandle + title + purpose, exactly
InspectorOften used with / Uses / Used by, no raw degree pair, both CTAs
Start panelpresent when nothing is selected; starting points exclude the orphan
Group jumpframes the lane from the map and from the browse list
Focus modeF enters, full 1440 viewport, Esc peels hint → selection → focus
Browse listthree sections, rows read Uses n · Used by n
Dragmoves, persists at version: 3, restores on reload, R resets and clears
Catalogcard carries the purpose line and says "Ask with"
Detailsays Uses / Used by, no References / Referenced by
Responsive390 list-only with no React Flow and no overflow, 768 strip, 1280 rail

Accessibility: axe-core 4.13 at WCAG 2.0/2.1 A and AA on all seven
surfaces — catalog 27 passes, map + inspector 24, focus mode 24, browse list
24, mobile list + drawer 25, detail 25, workspace 29. Zero violations. The
run found one more real defect, now fixed: the rendered-document and eval
scrollers have no focusable child when a skill's body carries no links, which
left a keyboard user unable to scroll them.

Build:rm -rf .next then type-check, lint and build clean, 85 static
pages. React Flow remains absent from every bundle including the map route's
initial load.

Gaps that remain open

  1. Screenshots are not attached to this PR. Twelve fresh captures at 2x,
    desktop and mobile, light and dark, rendered from the production build to
    ~/Desktop/pr24-skills-canvas/ and ready to drop into this description.
    Nothing is committed as test media.
  2. The purpose lines are new copy. Thirty-four one-liners written for this
    PR and grounded in each skill's own description and heading. They deserve a
    read as copy, not as code.
  3. No human review yet, and specifically none by someone reading the page
    cold. That is the remaining merge gate, and the one the automated suite
    cannot stand in for.

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.
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.
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.
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.
The changelog entry and the roadmap item were written before the PR existed, so
both carried a guessed number. They name PR #24 now.
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.
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 <html> 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.
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.
The canvas panel was translucent, so the animated wash behind the shell
came through it and lifted the surface to something lighter than the
page itself. The dot grid, drawn at the same value as a panel border,
then had nothing to sit against and disappeared: the map read as a pale
rectangle with cards floating on it rather than as a space you look
into.
The map now has its own surface token, opaque and below the page
instead of above it — near black in dark, white in light — with a dot
colour picked to read against it, and it is a fraction larger. Focus
mode drops the wash entirely; there is nothing decorative left to see
past. The minimap takes the canvas colour too, since an elevated panel
in that corner was the brightest thing on a near-black screen, which is
backwards for an overview.
37 end-to-end checks still pass, axe still reports nothing on all seven
surfaces.
@harrymove-ctrl
harrymove-ctrl merged commit 125a113 into mainAug 29, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@harrymove-ctrl@hien-p
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Simplify the skills explorer and add a 2D relationship canvas - #24

Merged
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas
Aug 29, 2026
Merged

Simplify the skills explorer and add a 2D relationship canvas#24
harrymove-ctrl merged 9 commits into
mainfrom
feat/skills-explorer-canvas

Conversation

@harrymove-ctrl

@harrymove-ctrlharrymove-ctrl commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Product intent

/skills had two costs. Choosing one skill out of 34 meant reading 34 summary
paragraphs, while the things that decide the choice — the handle you would type
and the phrase that triggers it — sat inside prose written for another purpose.
And the reference graph, which is the real structure of the kit, was drawn as a
fixed circular ring: every skill the same weight, no grouping, nothing you could
move. It answered "are these connected" and nothing else.

This makes the catalog something you scan and the graph something you explore.

2D over 3D

A 3D force graph was considered and rejected, and that is recorded in the design
doc as prior art rather than a promise. It costs a WebGL context and a physics
loop on a page whose job is reading, it occludes its own labels, it degrades
badly on touch, and it has no honest accessible equivalent — the list would
quietly become the real interface for a large share of readers while the 3D view
took the budget. A force simulation was rejected for a smaller reason: placement
that differs on every load is a map you cannot refer back to.

What changed

Catalog. Header is a word and a sentence. A card carries the handle, title,
category and one trigger phrase; summaries, file counts and reference counts
move to detail where someone is comparing rather than scanning. Sort and
capability flags fold behind one Filters control, with active filters shown as
removable chips. The default view groups by category. Search, ranking, filters
and every deep link behave exactly as before.

Relationship canvas. Skills sit in fixed category lanes, alphabetical within
a lane, so the same graph produces the same map every load. React Flow owns pan,
zoom, drag, minimap and controls; selection, pinning, URL sync and persistence
stay in the view. Dragged positions persist per browser tab under a versioned
key, and Reset layout returns to canonical. The list is a first-class mode
carrying every skill and both directions of every relationship, and it is the
default under 768px.

Preserved. Detail, workspace, Preview, Source, Edit and browser-local drafts
are untouched. This changed how you find a skill, not what you can do with it.

URL contracts

URLBehavior
/skills?query=&category=&sort=&has=&skill=unchanged
/skills/visualize-interactions?skill=<id>selects and pins, cmk: prefix accepted
/skills/visualize-interactions?view=canvas|listexplicit view on any viewport
/skills/[skillId]/workspace?file=&mode=unchanged

Pan and zoom are local UI state and deliberately stay out of the URL.

Responsive

ViewportResult
390 × 8441 column, visualization defaults to List, no overflow
768 × 10242 columns, no overflow
1280 × 8002 columns, no inspector, no overflow
1440 × 900catalog plus inspector, canvas readable
1512 × 900inspector 834px, no collapsed panel, no overflow

Verified

  • Deterministic layout: identical node transforms across loads with storage cleared.
  • Stored layout restores (translate(1234px, 987px) from a seeded payload) and a
    wrong-version payload is discarded back to the canonical position.
  • ?skill=cmk:delivery-review selects and normalizes to ?skill=delivery-review.
  • ?view=list renders the list and never mounts the canvas.
  • Inspector content renders synchronously, not behind an exit animation.
  • Canvas chunk loads only when the canvas view is active: the visualization
    route's first load is 113 kB, down from 161 kB.
  • type-check, lint, clean build (85 static pages) and git diff --check pass.

Known accepted gaps

  • Idle edges measure below 3:1 by design; they are texture, and every
    relationship they hint at is at full contrast in the traced state, the
    inspector and the list.
  • Node dragging is exercised through React Flow's own handlers; the persistence
    layer either side of it is verified directly, but a physical drag was not
    scripted.
  • No automated axe or Lighthouse run: the devtools integration could not attach
    to its browser profile in this environment.

Scope

PR #22 and PR #21 were not opened, inspected, reviewed, modified, rebased or
merged. Nothing outside the skills dashboard, docs, roadmap and changelog is
touched.


Self-review corrections (d9a8968)

Checking this branch against the plan rather than trusting the first pass found
four defects, three of which the documentation had already claimed as working.

  • Hover tracing did not exist.SKEX-2.2 and the design doc both described
    it. Hover now traces while the pin keeps its marker and the inspector stays on
    the pinned skill.
  • The pin's halo never rendered. It was a Tailwind arbitrary shadow holding
    a color-mix() with commas, which the class parser drops silently, so pinned
    and hovered nodes were pixel-identical. Inline style now.
  • Nodes had no declared size, so React Flow held all 42 at
    visibility: hidden pending measurement. Where ResizeObserver delivery is
    throttled the map renders blank, and hidden nodes cannot take focus, which is
    why canvas keyboard access did not work. Sizes are declared now: 42/42 visible
    and node focus confirmed working.
  • The mobile inspector was a bare sheet. It is a dialog now: labelled,
    aria-modal, focus moved in, Tab trapped, Escape closes.

Also corrected: the catalog renders 2 columns at 1280, matching the agreed
matrix. The table above said 3, which was wrong. A third column has nowhere to
live, because the inspector takes over at 1440 — say the word if you would
rather move the inspector breakpoint up and keep a 3-column band.

Re-verified after the fixes

CheckResult
Node visibility42/42 visible
Canvas node focusdocument.activeElement is the node, name reads cmk:cicd, Setup & Infra. 7 references out…
Pin vs hoverpinned keeps a 4px halo, hovered has none, inspector stays pinned
Canvas node contrasthandle 8.16 dark / 5.63 light, meta 5.03 / 5.81 — AA in both
Mobile drawerrole=dialog, aria-modal, labelled, focus in, Escape closes and clears ?skill=
Catalog bundleneither the editor chunk nor React Flow is fetched on /skills
Repeated navigation3 client-side round trips: 1 flow instance on the graph route, 0 after leaving, canvas count steady at 1
1280 / 1440 / 15122 columns / inspector / inspector at 834px, no overflow at any width

Still not verified

No automated axe or Lighthouse run — the devtools integration cannot attach to
its browser profile here. A physical pointer drag of a node was not scripted;
the persistence layer either side of it is verified directly. And I cannot
attach screenshots to this PR from my environment, so the evidence above is
measurements rather than images.


Gate closure

Node count, 34 vs 42. The canvas renders 42 React Flow nodes: 34 skill
nodes, matching the catalog exactly, plus 8 lane heading nodes with ids
lane:delivery, lane:docs, lane:setup, lane:agent, lane:testing,
lane:sui, lane:sync, lane:session. Headings are nodes so the category
structure pans and zooms with the map instead of floating over it. No dangling
reference is rendered as a skill.

Real pointer drag, full lifecycle. Not just the persistence layer:

  1. Dragged adr with a real pointer from translate(0px, 372px).
  2. Landed at translate(295.619px, 554.857px).
  3. sessionStorage holds {"version":1,"positions":{"adr":{"x":295.62,"y":554.86}}}.
  4. Reloaded — restored to exactly translate(295.619px, 554.857px).
  5. Pressed Reset layout — back to translate(0px, 372px), storage cleared.

Live resize, not just reload.

StepResult
Reload at 1280no inspector, ?query=adr
1280 → 1440inspector mounts at 762px, URL gains &skill=adr
1440 → 1280inspector unmounts, URL drops skill, filter intact

No 40px panel, no stale selection, no URL overwrite.

Technical gate from clean state.git status shows only untracked
.claude/, which is not in the diff. 15 files changed against origin/main,
none outside skills UI, docs, roadmap and changelog. git diff --check clean.
No commit in this branch references PR #22 or PR #21. type-check, lint and a
clean build (85 static pages) all pass.

Canvas first revision (44957ea)

Review feedback on the previous head was that the feature was correct and
unpleasant: the map opened fitted to the viewport, so eight lanes were squeezed
into a panel and nodes rendered at roughly a centimetre of unreadable colour,
while the shell, the page header, three stat cards and a permanently docked
inspector took the rest of the screen. It read as a dashboard containing a
small graph. This revision makes the canvas the page.

Focus mode.F gives the canvas the whole application viewport: rail,
header, heading and stats collapse away, the inspector becomes an overlay, and
a floating toolbar carries Exit focus, Canvas/List, zoom, Fit all, Reset and
help. It is application level rather than the browser Fullscreen API, and it
works by setting data-skills-focus on <html> and collapsing the shell around
a canvas that never changes position in the React tree. Measured: the same
.react-flow element survives both directions of the toggle, exactly one
instance exists, and the viewport transform is unchanged across 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 round trips through
?focus=canvas.

Readable by default. No blanket fitView on load. With ?skill= the canvas
centres that node at zoom 1.1; otherwise it anchors the map's top left corner at
zoom 1, so the first thing on screen is a whole card. Fit all is now an
explicit control. Nodes and their type are larger, lanes are three columns wide
so no column starts off screen, and below zoom 0.62 a card drops its metadata
row rather than drawing it at sub 8px. Selecting a skill moves the viewport only
when the node is off frame or the zoom is below legible. LAYOUT_VERSION is 2,
because stored positions describe geometry that no longer exists.

Less chrome. 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. The
docked inspector takes layout only while a skill is selected, so the empty state
is all canvas: 976px of a 1280 viewport, 100% of the panel.

Automated accessibility, now closed

The DevTools integration could not attach (The browser is already running for /Users/.../chrome-devtools-mcp/chrome-profile), so the audit was run a
different way: axe-core 4.13.0 served same origin and executed in the page, with
transitions disabled first, because this environment throttles rAF and a
mid-transition getComputedStyle reports the previous theme's colours.

SurfaceViolationsPasses
/skills023
Canvas, docked inspector024
Canvas, focus mode024
List view024
Mobile inspector drawer, 390px024
Skill detail025
Workspace029

Scope: WCAG 2.0 and 2.1, levels A and AA. The run found four real defects, all
fixed in this commit:

  1. Informational text at --text-disabled measuring 2.54:1 to 4.27:1 on the
    light palette, across 22 nodes in the catalog and the lane counts on the map.
  2. aria-label on a bare div, which names nothing.
  3. role="tablist" owning links and buttons rather than only tabs, reported
    critical.
  4. Once the close control moved inside its tab to fix (3), a focusable button
    nested inside a widget role. The close affordance is now pointer only and out
    of the accessibility tree, with Delete or Backspace closing the focused
    tab; both paths verified.

GooeyTextReveal also stopped asking GSAP for aria: "auto", which was putting
a prohibited aria-label on a <p> while hiding every line beneath it, so a
paragraph could read as empty to assistive technology.

axe returns color-contrast as incomplete over the canvas, where backgrounds
are color-mix() values it will not resolve. Those were measured directly
instead, compositing each background through a canvas so color(srgb ...) and
color-mix() resolve to real pixels: the lowest ratio on graph and inspector
text is 5.03:1 dark and 5.35:1 light, against a 4.5:1 requirement.

End to end, re-run after the UX change

  • Focus: enter and exit by button and by F; canvas 820px to 1440px and
    back; probe on the DOM node survives both toggles; one .react-flow instance
    throughout; URL gains and drops focus=canvas.
  • Selection: pin from the canvas, from the list and from ?skill=; overlay
    drawer is role="dialog" + aria-modal, labelled, focus moves in, Tab is
    trapped, Esc closes it and focus returns to the node.
  • Drag lifecycle: real pointer drag moved adr from translate(0px, 392px)
    to translate(108px, 312px); sessionStorage recorded
    {"version":2,"positions":{"adr":{"x":108,"y":312}}}; reload restored it
    exactly; R returned it to canonical and cleared storage.
  • Viewport:Fit all reached zoom 0.458 with all eight lanes visible and
    metadata correctly dropped; zoom in/out; pan.
  • Responsive, reloaded at each width: 390 list by default with React Flow
    never loaded and no horizontal overflow; 768 canvas with focus available and
    no minimap; 1280 canvas 976px empty, 660px with the inspector docked; 1440
    focus mode at the full 1440.
  • Catalog and workspace: search, category chips, URL persistence, detail
    preview; workspace draft written, survived navigation away and back, and the
    close confirmation (role="alertdialog") still guards it.

Technical gate

rm -rf .next then type-check, lint, build: clean, 85 static pages.
git diff --check clean. Nothing outside the skills UI, the shared components
the audit implicated, docs, roadmap and changelog. .claude/ stays untracked.
No commit references PR #21 or #22.

React Flow is in neither /skills, the workspace, nor even the initial bundle
for /skills/visualize-interactions; it is a lazy chunk, confirmed against
app-build-manifest.json.

Files added to the diff by the audit

components/skills/file-content-pane.tsx and
components/motion/gooey-text-reveal.tsx were not part of this PR's original
scope. They are here because the accessibility run this PR was asked to perform
found a critical and a serious violation in them, and app/skills/layout.tsx
gains three class hooks so focus mode has something to collapse.

Clarity pass (6c25bda)

Review feedback on the previous head: the technical gates proved the canvas was
not blank, that drag and zoom worked, and that the page was accessible and
responsive — none of which proves a reader understands what the map is saying.
The page spoke graph theory (references, referenced by, entry points, out, in)
and a node card answered "how connected is this?" before it answered "what is
this?".

One vocabulary, defined once. references → uses, referenced by →
used by, out · inUses n · Used by n, graph → map, entry points
starting points, Fit all → Show whole map, Reset layout → Reset
map
, List → Browse list, and the page is How skills connect. The map,
the browse list, the skill detail page and the catalog now all use it, so the
direction is learned once.

Two things had to exist before the words could work.
SKILL_PURPOSE in lib/skill-types.ts is a curated one-line purpose per
skill, living in the site layer beside CATEGORY_MAP for the same reason that
map does: frontmatter has 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; this way changing a line
is a copy edit in one file, not a change to the skills themselves, and
skillPurpose() falls back to the first trigger phrase for anything new.
oftenUsedWith in lib/skill-graph.ts is not an edge — two skills keep
company when some third skill pulls in both, and ranking by shared parents
answers what else a reader will want open. For cmk:delivery-review it yields
cicd, delivery-pipeline, delivery-workflow.

A way in before anything is selected. The rail used to appear only on
selection, so the one moment a reader most needs help was the moment the page
showed them 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. Below 1280 and in
focus mode the same groups are a strip over the map; the browse list carries
them as its first two sections. A group jump requested from the list is handed
to the canvas as its opening frame, because at that moment the canvas does not
exist to be moved.

Starting points are honest. In-degree zero gives five skills, one of which
(cmk:codebase-docs) has no connections in either direction. Offering it as a
place to begin is worse than saying nothing, so a starting point is a skill
nothing pulls in that pulls in something itself — four of them.

The node card is a handle, a title and what the skill is for. The counts
moved to the panel, where each sits beside the list it describes.

Framing is clamped to the map and centred against the visible width, so a
deep link to a first-row skill no longer opens with a third of the canvas
empty, and the focus drawer no longer covers the node the map just centred.

Verification, re-run end to end

37 automated checks against a production build, in a real browser (the
in-app preview stops compositing when its pane is hidden, which stalls every
animated viewport move and produced false failures — every result below comes
from headless Chrome over CDP):

AreaChecks
Copy and vocabularyheading, subtitle, stat line, no surviving old terms
Node cardhandle + title + purpose, exactly
InspectorOften used with / Uses / Used by, no raw degree pair, both CTAs
Start panelpresent when nothing is selected; starting points exclude the orphan
Group jumpframes the lane from the map and from the browse list
Focus modeF enters, full 1440 viewport, Esc peels hint → selection → focus
Browse listthree sections, rows read Uses n · Used by n
Dragmoves, persists at version: 3, restores on reload, R resets and clears
Catalogcard carries the purpose line and says "Ask with"
Detailsays Uses / Used by, no References / Referenced by
Responsive390 list-only with no React Flow and no overflow, 768 strip, 1280 rail

Accessibility: axe-core 4.13 at WCAG 2.0/2.1 A and AA on all seven
surfaces — catalog 27 passes, map + inspector 24, focus mode 24, browse list
24, mobile list + drawer 25, detail 25, workspace 29. Zero violations. The
run found one more real defect, now fixed: the rendered-document and eval
scrollers have no focusable child when a skill's body carries no links, which
left a keyboard user unable to scroll them.

Build:rm -rf .next then type-check, lint and build clean, 85 static
pages. React Flow remains absent from every bundle including the map route's
initial load.

Gaps that remain open

  1. Screenshots are not attached to this PR. Twelve fresh captures at 2x,
    desktop and mobile, light and dark, rendered from the production build to
    ~/Desktop/pr24-skills-canvas/ and ready to drop into this description.
    Nothing is committed as test media.
  2. The purpose lines are new copy. Thirty-four one-liners written for this
    PR and grounded in each skill's own description and heading. They deserve a
    read as copy, not as code.
  3. No human review yet, and specifically none by someone reading the page
    cold. That is the remaining merge gate, and the one the automated suite
    cannot stand in for.

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.
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.
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.
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.
The changelog entry and the roadmap item were written before the PR existed, so
both carried a guessed number. They name PR #24 now.
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.
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 <html> 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.
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.
The canvas panel was translucent, so the animated wash behind the shell
came through it and lifted the surface to something lighter than the
page itself. The dot grid, drawn at the same value as a panel border,
then had nothing to sit against and disappeared: the map read as a pale
rectangle with cards floating on it rather than as a space you look
into.
The map now has its own surface token, opaque and below the page
instead of above it — near black in dark, white in light — with a dot
colour picked to read against it, and it is a fraction larger. Focus
mode drops the wash entirely; there is nothing decorative left to see
past. The minimap takes the canvas colour too, since an elevated panel
in that corner was the brightest thing on a near-black screen, which is
backwards for an overview.
37 end-to-end checks still pass, axe still reports nothing on all seven
surfaces.
@harrymove-ctrl
harrymove-ctrl merged commit 125a113 into mainAug 29, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@harrymove-ctrl@hien-p