Skip to content

feat(landing): add map teaser section linking to /map#5649

Merged
MarkusNeusinger merged 10 commits into
mainfrom
claude/add-map-teaser-homepage-vEbUo
May 2, 2026
Merged

feat(landing): add map teaser section linking to /map#5649
MarkusNeusinger merged 10 commits into
mainfrom
claude/add-map-teaser-homepage-vEbUo

Conversation

@MarkusNeusinger
Copy link
Copy Markdown
Owner

Summary

Adds a new section to the landing page that teases the recently introduced /map page and links to it. Sits between SpecsSection and LibrariesSection — the natural follow-up to the curated featured grid is "and here's the visual way to browse all of them".

  • Two-column layout that mirrors SpecsSection / PaletteSection: short description on the left, decorative preview on the right.
  • Description copy is dynamic — uses the live spec count when available (all 327 specs on a single canvas — clustered by tag similarity, coloured by plot type, searchable.).
  • Preview is a hand-positioned SVG of three loose clusters connected by hairlines, in the same Okabe-Ito palette as MapPage's CLUSTER_COLORS. Purely static — no data fetch, no force simulation — so the landing page stays cheap.
  • Whole right column is a RouterLink to /map with the same hover treatment as the featured thumbs (lift + green accent bar). A secondary map.open() MethodLink lives under the description for keyboard users.
  • Analytics: nav_click events with source: 'map_teaser_link' (text link) and source: 'map_teaser_preview' (visual). The SectionHeader link already tracks itself.

Design proposal

I weighed three options for the right column:

  1. Live mini-graph — re-render ForceGraph2D at small size. Rejected: ~70 KB gzip on the landing page, force simulation cost, layout flicker.
  2. Real screenshot — would need a curated PNG asset that goes stale every time the catalog grows.
  3. Static SVG cluster (picked) — light, on-brand, ages gracefully, communicates the clustering aesthetic without pretending to be the real thing.

Test plan

  • yarn type-check — clean
  • yarn test src/pages/LandingPage.test.tsx — 5/5 pass (existing tests still hit the right elements)
  • yarn build — landing page bundle 24.51 kB → 7.23 kB gzip
  • yarn lint src/pages/LandingPage.tsx — clean (pre-existing warnings in other files unaffected)
  • Manual smoke: open /, scroll to map section, verify hover lift + click navigates to /map
  • Manual smoke: light + dark theme — palette, surfaces, rule borders all read correctly

https://claude.ai/code/session_011dPWC8Z43EGZdj6rrE9gav


Generated by Claude Code

Two-column section between SpecsSection and LibrariesSection: short
description on the left, decorative SVG cluster preview on the right.
The preview is purely static (no data fetch, no force simulation) and
mirrors the real map's three-cluster look using the same Okabe-Ito
palette, so it tells visitors what /map is without paying for it on
the landing page.

https://claude.ai/code/session_011dPWC8Z43EGZdj6rrE9gav
Copilot AI review requested due to automatic review settings May 2, 2026 17:31
@codecov
Copy link
Copy Markdown

codecov Bot commented May 2, 2026

Codecov Report

❌ Patch coverage is 98.59155% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
app/src/pages/MapPage.tsx 98.11% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new “Map” teaser section to the landing page to drive navigation to the interactive /map experience, positioned between the existing specs showcase and the libraries section.

Changes:

  • Inserts a new MapSection into LandingPage between SpecsSection and LibrariesSection.
  • Implements a static SVG “cluster preview” card linking to /map, with nav_click analytics tracking.

Comment thread app/src/pages/LandingPage.tsx Outdated
Comment on lines +110 to +115
{specCount ? `all ${specCount} specs` : 'every spec'} on a single canvas —{' '}
<Box component="span" sx={{ color: 'var(--ink)' }}>
clustered by tag similarity, coloured by plot type, searchable.
</Box>{' '}
zoom in for thumbnails, hover for details, click to open the spec.
</Box>
Comment thread app/src/pages/LandingPage.tsx Outdated
Comment on lines +216 to +217
role="img"
aria-label="Three clusters of circles connected by hairlines, mirroring the map's force-directed layout"
Comment thread app/src/pages/LandingPage.tsx Outdated
Comment on lines +117 to +126
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 3 }}>
<MethodLink to="/map" subject="map" verb="open" source="map_teaser_link" />
</Box>
</Box>

<Box
component={RouterLink}
to="/map"
onClick={() => trackEvent('nav_click', { source: 'map_teaser_preview', target: '/map' })}
sx={{
- specCount: use null-check instead of truthiness so a future 0-spec
  catalog still renders "all 0 specs" rather than the loading copy
- decorative SVG: drop role="img" + aria-label on the cluster preview
  and add aria-hidden, since the wrapping link already carries an
  aria-label and the duplicate announcement is noisy
- tests: assert nav_click fires for map_teaser_preview and
  map_teaser_link with the expected source

https://claude.ai/code/session_011dPWC8Z43EGZdj6rrE9gav
Copilot AI review requested due to automatic review settings May 2, 2026 20:20
The map teaser had `map.explore()` in the section header and `map.open()`
as a MethodLink in the body — both pointing to /map. Drop the MethodLink
to match the palette/specs section pattern (header-only entry link), and
flip the grid ratio to 1.1fr / 1fr so the SVG preview is narrower and
proportionally less tall, sitting closer to the text height.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UX
- Add a "settling" overlay that swallows pointer input from canvas mount
  through engine cool-down. Previously the canvas accepted clicks during
  the simulation and the post-settle zoom-fit, which felt like clicks were
  resetting because the camera animation overrode them.
- Drop the post-settle two-step zoomToFit (zoom out, wait 700ms, zoom back
  in to MIN_ZOOM). The simulation's centering force already lands the
  graph in the viewport, so the corrective zoom changed almost nothing
  visually but added 1.7s of camera movement that could clobber input.
- Belt-and-braces: onNodeClick / search flyTo early-return until settled.

Layout quality
- Seed initial node positions per colorBucket on a circle around the
  origin (CLUSTER_SEED_RADIUS=600, hash-stable jitter ±150). With a warm
  start the simulation produces visibly cleaner cluster separation in
  the same number of cooldown ticks.
- Warm-restart from previous x/y/vx/vy when the cache hits, so filter or
  weight tweaks refine the existing layout instead of re-converging.

Defaults
- Add a 0.1 weight floor on every previously-zero category (techniques,
  patterns, dataprep, dependencies, domain, styling) so subtle cross-type
  cohesion contributes instead of being ignored.
- Bump domain 0.1 → 0.3, techniques 0.1 → 0.2, data_type 0.5 → 0.6:
  domain in particular is a strong semantic axis worth surfacing.
- KNN_K 5 → 8: denser within-cluster connectivity tightens the cluster
  shapes without creating cross-cluster bridges.
- COOLDOWN_TICKS 400 → 450, d3AlphaDecay 0.0228 → 0.018: a touch more
  effective settling time, hidden behind the overlay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 7 comments.

Comment thread app/src/pages/MapPage.helpers.ts Outdated
Comment on lines +133 to +137
* The defaults privilege plot_type (2.0) with light contributions from
* features and data_type (0.5 each). That gives a plot_type-dominant map
* with subtle cross-type cohesion. Users can slide secondary categories up
* via the weights panel to mix in techniques/patterns/etc. for richer
* clustering.
* features and data_type (0.5 each), plus a small 0.1 floor on every
* remaining category so subtle cross-type cohesion (techniques, patterns,
* dataprep, etc.) still nudges the layout instead of being ignored
* entirely. Users can slide any of these up via the weights panel.
Comment on lines +77 to +114
/**
* Map section — teases the interactive force-directed map at /map. Mirrors
* SpecsSection / PaletteSection two-column layout: short description on the
* left, decorative SVG cluster preview on the right. The preview is purely
* static (no data fetch, no force simulation) so it stays cheap on the
* landing page; it only hints at the real map's clustering aesthetic using
* the same Okabe-Ito palette.
*/
function MapSection({ specCount }: { specCount?: number }) {
const { trackEvent } = useAnalytics();
return (
<Box sx={{ py: { xs: 2, md: 3 } }}>
<SectionHeader prompt="❯" title={<em>map</em>} linkText="map.explore()" linkTo="/map" />

<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: 'minmax(0, 1fr)', md: 'minmax(0, 1.1fr) minmax(0, 1fr)' },
gap: { xs: 4, md: 8, lg: 12 },
alignItems: 'center',
}}
>
<Box
sx={{
fontFamily: typography.serif,
fontSize: { xs: '1rem', md: '1.25rem' },
lineHeight: 1.55,
color: 'var(--ink-soft)',
fontWeight: 300,
maxWidth: '52ch',
}}
>
{specCount != null ? `all ${specCount} specs` : 'every spec'} on a single canvas —{' '}
<Box component="span" sx={{ color: 'var(--ink)' }}>
clustered by tag similarity, coloured by plot type, searchable.
</Box>{' '}
zoom in for thumbnails, hover for details, click to open the spec.
</Box>
Comment thread app/src/pages/MapPage.tsx Outdated
Comment on lines +1312 to +1315
hasn't finished cooling and the camera hasn't completed its initial
fit-to-view. Sits on top of the canvas, swallows pointer events,
and shows a small spinner in the corner so the user sees that the
layout is still resolving. Drops as soon as `settled` flips. */}
Comment thread app/src/pages/MapPage.tsx Outdated
Comment on lines +531 to +534
// Belt-and-braces guard: the overlay already swallows pointer events
// while the camera is animating, but ForceGraph2D may have buffered a
// tap that landed just before the gate appeared.
if (!settled) return;
Comment thread app/src/pages/MapPage.tsx Outdated
Comment on lines 555 to 556
if (!settled) return;
const fg = fgRef.current;
Comment thread app/src/pages/MapPage.tsx Outdated
Comment on lines +1266 to +1271
// The simulation's centering force already lands the graph near
// the viewport, so an explicit zoom-to-fit changes very little
// and just adds a wait. Drop the gate as soon as the engine
// stops — the user keeps whatever camera state the simulation
// produced and can pan/zoom freely.
onEngineStop={() => setSettled(true)}
Comment thread app/src/pages/MapPage.tsx Outdated
Comment on lines +168 to +171
// settled = true once the post-mount camera animation has finished. Until
// then, the canvas is overlaid by a subtle gate that swallows pointer
// input so a click during simulation/zoom can't be clobbered when the
// camera then animates away from where the user tapped.
- Update DEFAULT_CATEGORY_WEIGHT docstring to reflect actual values
  (data_type 0.6 / features 0.5 / domain 0.3 / techniques 0.2 / 0.1
  floor on the rest) instead of the stale "0.1 floor everywhere" line.
- Drop the `if (!settled) return` guards in onNodeClick and flyTo. The
  settling overlay (pointerEvents: auto on a full-canvas Box) is enough
  to prevent canvas pointer input during cooling; the early-returns
  also blocked the search dropdown's flyTo even though seeded nodes
  have valid coordinates from t=0.
- Restore a conditional fit-to-view at engine stop: only fires when
  the settled bbox would actually overflow the viewport (typical case
  on mobile), uses a single centerAt + zoom animation covered by the
  overlay, and clamps via MIN_ZOOM_FIT so far-flung outliers don't
  shrink the dense cluster into pixels.
- Refresh stale comments around the `settled` state and the gate
  overlay so they match the actual cooling-only behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 2, 2026 20:33
Restoring even a guarded zoomToFit ended up firing on typical desktop
viewports (graph extent slightly exceeds visible area), animating the
camera with no perceptible visual change. User feedback was the same
as on the original two-step fit: the motion feels unmotivated.

Trust the simulation's centering force to land the graph reasonably,
let users pan/zoom on small screens where it overflows. Drop the
MIN_ZOOM_FIT constant since nothing references it anymore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

Comment thread app/src/pages/MapPage.tsx
Comment on lines +1261 to +1267
// Frame the dense cluster to ~80% of the viewport — instantly
// (0 ms), so the camera move happens behind the still-active
// gate overlay and the user just sees the final framing when
// `settled` flips. The trick is bounding the *5th–95th
// percentile* of node coordinates instead of the full bbox:
// a couple of far-flung outliers (typically null-bucket specs
// with no strong KNN edges) would otherwise dominate the bbox
Comment thread app/src/pages/MapPage.tsx
Comment on lines 1312 to 1321
// tighter clusters for highly related specs, looser otherwise.
// The d3-force-3d ambient types are minimal; cast for the chained calls.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const linkForce = fg.d3Force('link') as any;
if (linkForce) {
linkForce.distance((l: MapLink) => {
const w = l.weight ?? 0.3;
return LINK_DISTANCE_MIN + (1 - Math.min(1, w)) * (LINK_DISTANCE_MAX - LINK_DISTANCE_MIN);
});
linkForce.strength((l: MapLink) =>
Comment on lines +109 to +114
{specCount != null ? `all ${specCount} specs` : 'every spec'} on a single canvas —{' '}
<Box component="span" sx={{ color: 'var(--ink)' }}>
clustered by tag similarity, coloured by plot type, searchable.
</Box>{' '}
zoom in for thumbnails, hover for details, click to open the spec.
</Box>
MarkusNeusinger and others added 2 commits May 2, 2026 22:37
Trim the bbox to its 5th–95th percentile in each axis before fitting,
so a couple of far-flung outliers can't shrink the readable center.
The fit fires instantly (0 ms) at engine stop and is hidden behind the
gate overlay, so the user never sees an animation — just lands on a
cleanly framed graph regardless of viewport size.

On desktop this is a small inward trim. On mobile the full structure
becomes visible at the cost of smaller thumbnails — exactly the
"overview" the user described. Outliers stay reachable via pan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the corner preview panel switched from compact (160 px,
no tags) to full (280 px, tags) at the `sm` breakpoint (≥600 px), so
tablets in portrait already got the desktop-sized panel that covers
the focused node and its KNN edges after fly-to.

Move every breakpoint one step up (`sm` → `md`): tablets now share
the phone layout (160 px, no tags), full 280 px panel kicks in at
`md` (≥900 px) where there's actually room beside the dense cluster.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 2, 2026 20:56
MarkusNeusinger and others added 2 commits May 2, 2026 22:57
Two issues from the latest Copilot review:

1. The settling overlay sat at z-index 5 with pointerEvents:auto, and
   the search pill / legend / weights panel / corner panel sit at
   z-indices 2–4. So during simulation cooling the gate blocked all
   UI, not just the canvas. Drop the gate to z-index 1 — above the
   canvas (implicit 0), below every interactive control. Pointer
   input on the controls now reaches them; only the canvas itself
   is gated.
2. `settled` was a one-way latch (false → true on engineStop, never
   reset). Filter / weight / category changes re-derive `graphData`,
   FG2D reheats the simulation, but the gate stayed gone. Add a
   useEffect that resets `settled = false` whenever `graphData`
   changes, so the gate also covers subsequent re-layouts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Extend the ForceGraph2D mock with forwardRef + a shared mock instance
  so callbacks that read `fgRef.current` (centerAt / zoom / d3Force, …)
  can be exercised in jsdom.
- New test: cluster-seeded x/y/vx/vy on every node before FG2D ticks,
  and same-bucket nodes seeded closer than cross-bucket ones.
- New test: settling overlay is visible while the engine is cooling
  and disappears the moment onEngineStop fires.
- New test: onEngineStop calls centerAt + zoom (instant duration 0,
  hidden behind the gate) — covers the percentile-trimmed fit path.
- New test: touch-device flow (`useMediaQuery` returns false) — first
  tap pins + emits `map_node_pin`, second tap navigates + emits
  `map_node_click`. Each tap wrapped in act() so React flushes the
  pinnedId state between calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Comment thread app/src/pages/MapPage.tsx
Comment on lines +1297 to +1298
const lo = Math.floor(xs.length * trim);
const hi = Math.floor(xs.length * (1 - trim));
Comment on lines +109 to +114
{specCount != null ? `all ${specCount} specs` : 'every spec'} on a single canvas —{' '}
<Box component="span" sx={{ color: 'var(--ink)' }}>
clustered by tag similarity, coloured by plot type, searchable.
</Box>{' '}
zoom in for thumbnails, hover for details, click to open the spec.
</Box>
>
{specCount != null ? `all ${specCount} specs` : 'every spec'} on a single canvas —{' '}
<Box component="span" sx={{ color: 'var(--ink)' }}>
clustered by tag similarity, coloured by plot type, searchable.
Comment thread app/src/pages/MapPage.tsx
Comment on lines +1273 to 1314
// Frame the dense cluster to ~80% of the viewport — instantly
// (0 ms), so the camera move happens behind the still-active
// gate overlay and the user just sees the final framing when
// `settled` flips. The trick is bounding the *5th–95th
// percentile* of node coordinates instead of the full bbox:
// a couple of far-flung outliers (typically null-bucket specs
// with no strong KNN edges) would otherwise dominate the bbox
// and shrink the readable center to half its size. Outliers
// remain reachable via pan.
onEngineStop={() => {
const fg = fgRef.current;
if (!fg) return;
fg.zoomToFit?.(600, 80);
setTimeout(() => {
if (typeof fg.zoom === 'function' && fg.zoom() < MIN_ZOOM) {
fg.zoom(MIN_ZOOM, 400);
if (fg) {
const xs: number[] = [];
const ys: number[] = [];
for (const n of graphData.nodes as Array<MapNode & { x?: number; y?: number }>) {
if (n.x != null && n.y != null) {
xs.push(n.x);
ys.push(n.y);
}
}
if (xs.length > 0) {
xs.sort((a, b) => a - b);
ys.sort((a, b) => a - b);
const trim = 0.05;
const lo = Math.floor(xs.length * trim);
const hi = Math.floor(xs.length * (1 - trim));
const minX = xs[lo], maxX = xs[hi], minY = ys[lo], maxY = ys[hi];
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const bboxW = Math.max(1, maxX - minX);
const bboxH = Math.max(1, maxY - minY);
const padding = Math.round(Math.min(size.w, size.h) * 0.1);
const fitZoom = Math.min(
(size.w - 2 * padding) / bboxW,
(size.h - 2 * padding) / bboxH,
);
fg.centerAt?.(cx, cy, 0);
fg.zoom?.(fitZoom, 0);
}
}, 700);
}
setSettled(true);
}}
@MarkusNeusinger MarkusNeusinger merged commit 47c210f into main May 2, 2026
11 checks passed
@MarkusNeusinger MarkusNeusinger deleted the claude/add-map-teaser-homepage-vEbUo branch May 2, 2026 21:04
Sign up for free to 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.

3 participants