From 916ac0f5e37f66b67def2d906ce509b8cd5345f8 Mon Sep 17 00:00:00 2001 From: Stanley Date: Tue, 21 Apr 2026 17:19:17 -0400 Subject: [PATCH 1/2] feat(toc): add constellation variant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fourth TableOfContents variant that renders headings as a star map: dots laid out deterministically per-post (seeded PRNG), connected by faint amber edges in reading order. Active node glows; active edge brightens. - lib/toc-starmap.ts: seeded mulberry32 + repulsion layout so each post gets stable positions across reloads. - toc.css: scoped .toc-variant-constellation block (dot, label, edge, active states); extends shared positioning with the other variants. - toc.tsx: ConstellationToc sub-component. Auto-falls back to staffline when headings.length > 12 or prefers-reduced-motion. No post opts in yet — ship behind explicit variant="constellation". --- app/styles/toc.css | 105 ++++++++++++++++++++++++++++++++-- components/ui/toc.tsx | 128 ++++++++++++++++++++++++++++++++++++++++-- lib/toc-starmap.ts | 46 +++++++++++++++ 3 files changed, 270 insertions(+), 9 deletions(-) create mode 100644 lib/toc-starmap.ts diff --git a/app/styles/toc.css b/app/styles/toc.css index 438635f..b910776 100644 --- a/app/styles/toc.css +++ b/app/styles/toc.css @@ -297,7 +297,8 @@ Shared sidebar positioning for new variants (staffline + vim) ────────────────────────────────────────────────────────────── */ .toc-variant-staffline, -.toc-variant-vim { +.toc-variant-vim, +.toc-variant-constellation { position: fixed; left: 20px; top: 120px; @@ -309,13 +310,15 @@ } .toc-variant-staffline.mounted, -.toc-variant-vim.mounted { +.toc-variant-vim.mounted, +.toc-variant-constellation.mounted { animation: tocFadeIn 0.7s cubic-bezier(0.25, 0.1, 0.25, 1) forwards; } @media (min-width: 1024px) { .toc-variant-staffline, - .toc-variant-vim { + .toc-variant-vim, + .toc-variant-constellation { display: flex; flex-direction: column; width: 17ch; @@ -324,13 +327,15 @@ @media (min-width: 1280px) { .toc-variant-staffline, - .toc-variant-vim { + .toc-variant-vim, + .toc-variant-constellation { width: 25ch; } } .toc-variant-staffline .toc-title, -.toc-variant-vim .toc-title { +.toc-variant-vim .toc-title, +.toc-variant-constellation .toc-title { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.16em; @@ -544,3 +549,93 @@ color: var(--amber); font-weight: 400; } + +/* ────────────────────────────────────────────────────────────── + Constellation variant — star map nav + ────────────────────────────────────────────────────────────── */ +.toc-variant-constellation { + font-size: 11.5px; + position: fixed; +} + +.toc-variant-constellation .map { + position: relative; + height: 240px; + width: 100%; +} + +.toc-variant-constellation svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} + +.toc-variant-constellation ul { + position: absolute; + inset: 0; + list-style: none; + margin: 0; + padding: 0; +} + +.toc-variant-constellation .node { + position: absolute; + width: 0; + height: 0; +} + +.toc-variant-constellation .node > a { + position: absolute; + top: 0; + left: 0; + cursor: pointer; + text-decoration: none; + border: none; + color: inherit; +} + +.toc-variant-constellation .node .dot { + position: absolute; + top: 0; + left: 0; + width: 6px; + height: 6px; + border-radius: 50%; + transform: translate(-50%, -50%); + background: var(--fg-light); + opacity: 0.5; + transition: all 0.3s; +} + +.toc-variant-constellation .node .lbl { + position: absolute; + top: 6px; + left: 6px; + font-family: var(--font-serif); + font-size: 12px; + color: var(--fg-light); + opacity: 0.55; + white-space: nowrap; + transition: opacity 0.2s, color 0.2s; +} + +.toc-variant-constellation .node > a:hover .dot, +.toc-variant-constellation .node > a:focus-visible .dot, +.toc-variant-constellation .node.active .dot { + opacity: 1; + background: var(--amber); + box-shadow: 0 0 12px 2px rgba(186, 149, 94, 0.25); +} + +.toc-variant-constellation .node > a:hover .lbl, +.toc-variant-constellation .node > a:focus-visible .lbl, +.toc-variant-constellation .node.active .lbl { + opacity: 1; + color: var(--fg); +} + +.toc-variant-constellation .node.active .lbl { + color: var(--amber); +} diff --git a/components/ui/toc.tsx b/components/ui/toc.tsx index 71de50d..84b19c7 100644 --- a/components/ui/toc.tsx +++ b/components/ui/toc.tsx @@ -1,10 +1,11 @@ 'use client' import GithubSlugger from 'github-slugger' import { usePathname } from 'next/navigation' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { BLOG_POSTS } from '@/app/data' import '@/app/styles/toc.css' +import { layoutStars } from '@/lib/toc-starmap' interface TocItem { level: number @@ -12,7 +13,9 @@ interface TocItem { slug: string } -export type TocVariant = 'classic' | 'staffline' | 'vim' +export type TocVariant = 'classic' | 'staffline' | 'vim' | 'constellation' + +const CONSTELLATION_MAX_NODES = 12 const variantByTag: Record = { jazz: 'staffline', @@ -47,11 +50,20 @@ export function TableOfContents({ const [minLevel, setMinLevel] = useState(1) const [activeId, setActiveId] = useState('') const [scrollProgress, setScrollProgress] = useState(0) + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false) const post = BLOG_POSTS.find((p) => p.link === pathname) - const resolvedVariant: TocVariant = + const requestedVariant: TocVariant = variant ?? resolveVariantFromTags(post?.tags ?? []) + const shouldFallbackFromConstellation = + requestedVariant === 'constellation' && + (headings.length > CONSTELLATION_MAX_NODES || prefersReducedMotion) + + const resolvedVariant: TocVariant = shouldFallbackFromConstellation + ? 'staffline' + : requestedVariant + const totalMinutes = post?.readingTime ? parseInt(post.readingTime, 10) || 0 : 0 @@ -119,6 +131,16 @@ export function TableOfContents({ return () => observer.disconnect() }, [headings]) + // Detect prefers-reduced-motion so constellation can fall back + useEffect(() => { + if (typeof window === 'undefined' || !window.matchMedia) return + const mq = window.matchMedia('(prefers-reduced-motion: reduce)') + const update = () => setPrefersReducedMotion(mq.matches) + update() + mq.addEventListener('change', update) + return () => mq.removeEventListener('change', update) + }, []) + // Track scroll progress for the vim status bar useEffect(() => { if (resolvedVariant !== 'vim') return @@ -195,7 +217,22 @@ export function TableOfContents({ ? ':TOC' : resolvedVariant === 'staffline' ? '§ contents' - : title + : resolvedVariant === 'constellation' + ? 'index' + : title + + if (resolvedVariant === 'constellation') { + return ( + + ) + } return (