diff --git a/.gitignore b/.gitignore index 70a4f0a18..011d84f54 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ +.next/ +*.tsbuildinfo dist/ *.vsix .vscode-test/ @@ -13,6 +15,7 @@ dist-e2e/ .playwright-cli/ playwright-report/ test-results/ +/output/ blob-report/ packages/extension/tests/playwright-vscode/generated/ packages/extension/tests/playwright-vscode/generated-ir/ diff --git a/CONTEXT.md b/CONTEXT.md index f7fae366f..e7054655a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -472,6 +472,28 @@ _Avoid_: Required plugin The Plugin Package bundled with CodeGraphy, enabled by default for new CodeGraphy Workspaces, and still toggleable like any other Plugin. Core Plugin Activity State treats Markdown as enabled for a fresh workspace before settings are materialized, so CodeGraphy Interfaces can show its toggle as enabled without creating `.codegraphy/settings.json`. When workspace settings are created, such as during first Indexing or an explicit plugin toggle, Markdown is written as `enabled: true` unless the user disables it; disabling persists `enabled: false`. The Markdown Plugin exists because Tree-sitter Analysis does not provide CodeGraphy's markdown relationships; disabling it fully unloads the plugin runtime and leaves only static metadata for the enable toggle. _Avoid_: External markdown extension, special-case markdown runtime +### Commercial Layer + +**CodeGraphy Account**: +A signed-in website account that starts free and can later hold paid Pro package entitlements. +_Avoid_: Pro Account when the account has no paid package + +**Free Account**: +A CodeGraphy Account without an active paid Pro package entitlement. +_Avoid_: Free CodeGraphy use when the user has not signed up + +**CodeGraphy Pro**: +The paid commercial tier for CodeGraphy Accounts. A user becomes Pro by paying for at least one Pro Package. +_Avoid_: CodeGraphy Account, Free Account + +**Pro Package**: +A paid package attached to a CodeGraphy Account as a Pro entitlement. +_Avoid_: Workspace Package, Plugin Package + +**Organize**: +The first planned Pro Package for keeping, switching, arranging, and presenting useful CodeGraphy graph setups. +_Avoid_: CodeGraphy Pro when referring only to this package + ### Settings And Styling **Setting**: @@ -764,6 +786,10 @@ _Avoid_: Graph export - A **Plugin Package** is the packaging route for third-party plugins. - **Built-in Plugins** in this monorepo are examples and fast-development plugins, not required dependencies unless explicitly installed or bundled by the Core Package. - The **Markdown Plugin** is installed with `@codegraphy-dev/core` and enabled by default for new CodeGraphy Workspaces, but users can still toggle it off. +- Free CodeGraphy use starts from the **VS Code Extension** and does not require a **CodeGraphy Account**. +- A user can create a **Free Account** on the website before buying any **Pro Package**. +- A **CodeGraphy Account** becomes **CodeGraphy Pro** when it has at least one active paid **Pro Package** entitlement. +- **Organize** is a **Pro Package** inside **CodeGraphy Pro**, not a synonym for all signed-in account behavior. - A **Settings Control** changes a **Setting**; it is not a separate persisted concept. - **Settings** are saved workspace-locally under `.codegraphy/settings.json` so graph preferences survive between sessions. - **Graph Scope**, **Filter Setting**, **Display Setting**, **Verbose Diagnostics**, **Favorite**, and **Legend Entry Toggle** are settings because they are saved between sessions. @@ -857,6 +883,12 @@ _Avoid_: Graph export > > **Dev:** "If I turn off the Godot `*.gd` Legend Entry, do GDScript files disappear?" > **Domain expert:** "No. The **Legend Entry Toggle** only disables that styling, so matching nodes fall back to lower-priority styling." +> +> **Dev:** "Does installing CodeGraphy require a Pro account?" +> **Domain expert:** "No. Free CodeGraphy starts from the **VS Code Extension** and does not require a **CodeGraphy Account**. The website can still let someone create a **Free Account**." +> +> **Dev:** "Is Organize the same thing as Pro?" +> **Domain expert:** "No. **CodeGraphy Pro** is the paid tier. **Organize** is the first **Pro Package** inside that tier." ## Flagged ambiguities @@ -872,6 +904,7 @@ _Avoid_: Graph export - "collapse dependents" was ambiguous; resolved: **Collapse** absorbs downstream relationship nodes, not upstream nodes. - Shared downstream relationship targets stay visible when they are still related to by visible nodes outside the collapsed subgraph. - When a shared relationship target stays visible, the downstream path to it stays visible as a **Boundary Path**. +- "Bookmark" conflicts with **Favorite** if it only means marking a node; unresolved: website and Organize language still need a canonical term for reusable saved graph setups. - Collapse behavior is not renderer-owned; resolved: CodeGraphy owns **Collapse Projection**, it runs after the **Visible Graph** exists, and the force graph renderer displays the resulting graph. - Do not introduce "Collapsed Graph" as a separate pipeline term for now; resolved: the user still sees the **Visible Graph**, updated by **Collapse Projection**. - "filter" and "collapse" both reduce **Visible Graph** detail but are not synonyms; resolved: **Filter** means persistent include/exclude criteria, while **Collapse** means summarize relevant hidden detail. diff --git a/apps/web/app/_site/brand.tsx b/apps/web/app/_site/brand.tsx new file mode 100644 index 000000000..dda897274 --- /dev/null +++ b/apps/web/app/_site/brand.tsx @@ -0,0 +1,14 @@ +import Link from 'next/link'; + +export function Brand({ + href = '/', +}: { + href?: string; +}): React.ReactElement { + return ( + + + CodeGraphy + + ); +} diff --git a/apps/web/app/_site/footer.tsx b/apps/web/app/_site/footer.tsx new file mode 100644 index 000000000..70c4b5089 --- /dev/null +++ b/apps/web/app/_site/footer.tsx @@ -0,0 +1,10 @@ +export function SiteFooter(): React.ReactElement { + return ( + + ); +} diff --git a/apps/web/app/_site/header.tsx b/apps/web/app/_site/header.tsx new file mode 100644 index 000000000..8eb214de2 --- /dev/null +++ b/apps/web/app/_site/header.tsx @@ -0,0 +1,39 @@ +import Link from 'next/link'; +import { LogOut } from 'lucide-react'; +import { Button } from '../_ui/button'; +import { VsCodeIcon } from '../_ui/icons'; +import { Brand } from './brand'; + +export function SiteHeader({ + isSignedIn = false, +}: { + isSignedIn?: boolean; +}): React.ReactElement { + return ( +
+
+ +
+ {isSignedIn ? ( + + ) : ( + + )} + +
+
+
+ ); +} diff --git a/apps/web/app/_ui/button.tsx b/apps/web/app/_ui/button.tsx new file mode 100644 index 000000000..0e9b9f2c4 --- /dev/null +++ b/apps/web/app/_ui/button.tsx @@ -0,0 +1,47 @@ +import { Slot } from '@radix-ui/react-slot'; +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; +import { cn } from './cn'; + +const buttonVariants = cva( + 'inline-flex min-h-10 items-center justify-center gap-2 rounded-md text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + outline: 'border border-input bg-background text-foreground hover:bg-foreground/5', + ghost: 'hover:bg-accent hover:text-accent-foreground', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + }, + size: { + default: 'px-4 py-2', + sm: 'px-3 py-1.5', + lg: 'px-5 py-3 text-base', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +export function Button({ + asChild = false, + className, + size, + variant, + ...props +}: ButtonProps): React.ReactElement { + const Comp = asChild ? Slot : 'button'; + + return ; +} + +export { buttonVariants }; diff --git a/apps/web/app/_ui/card.tsx b/apps/web/app/_ui/card.tsx new file mode 100644 index 000000000..9a65aedd9 --- /dev/null +++ b/apps/web/app/_ui/card.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { cn } from './cn'; + +export function Card({ + className, + ...props +}: React.HTMLAttributes): React.ReactElement { + return ( +
+ ); +} diff --git a/apps/web/app/_ui/cn.ts b/apps/web/app/_ui/cn.ts new file mode 100644 index 000000000..a7c26636a --- /dev/null +++ b/apps/web/app/_ui/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/apps/web/app/_ui/icons.tsx b/apps/web/app/_ui/icons.tsx new file mode 100644 index 000000000..af4107535 --- /dev/null +++ b/apps/web/app/_ui/icons.tsx @@ -0,0 +1,56 @@ +export function GoogleIcon({ + className = 'h-4 w-4', +}: { + className?: string; +}): React.ReactElement { + return ( + + ); +} + +export function GitHubIcon({ + className = 'h-4 w-4', +}: { + className?: string; +}): React.ReactElement { + return ( + + ); +} + +export function VsCodeIcon({ + className = 'h-4 w-4', +}: { + className?: string; +}): React.ReactElement { + return ( + + ); +} diff --git a/apps/web/app/account/page.tsx b/apps/web/app/account/page.tsx new file mode 100644 index 000000000..78187c616 --- /dev/null +++ b/apps/web/app/account/page.tsx @@ -0,0 +1,5 @@ +import { AccountView } from './view'; + +export default function AccountPage(): React.ReactElement { + return ; +} diff --git a/apps/web/app/account/view.test.tsx b/apps/web/app/account/view.test.tsx new file mode 100644 index 000000000..b2952237e --- /dev/null +++ b/apps/web/app/account/view.test.tsx @@ -0,0 +1,21 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { AccountView } from './view'; + +describe('CodeGraphy website account page', () => { + it('shows the account email and active Pro package status', () => { + render(); + + expect(screen.getByText('maya@codegraphy.dev')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Private plugins' })).toBeInTheDocument(); + expect(screen.getByText('Organize')).toBeInTheDocument(); + expect( + screen.getByText('Sections, pinned nodes, saved setups, and advanced exports.'), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Customer portal coming soon' })).toBeDisabled(); + expect(screen.getByRole('link', { name: 'Sign out' })).toHaveAttribute('href', '/login'); + expect(screen.queryByRole('link', { name: 'Sign in' })).not.toBeInTheDocument(); + expect(screen.queryByText('Free account')).not.toBeInTheDocument(); + expect(screen.queryByText('Private plugins enabled')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/account/view.tsx b/apps/web/app/account/view.tsx new file mode 100644 index 000000000..119261c94 --- /dev/null +++ b/apps/web/app/account/view.tsx @@ -0,0 +1,89 @@ +import { CreditCard, PackageCheck, UserRound } from 'lucide-react'; +import { Button } from '../_ui/button'; +import { Card } from '../_ui/card'; +import { SiteHeader } from '../_site/header'; + +export function AccountView(): React.ReactElement { + return ( + <> + +
+
+

+ CodeGraphy account +

+

Account

+

+ Manage your account and private plugin subscriptions. +

+
+ +
+ +
+
+
+
+ +
+
+

Profile

+

Signed in through your account provider.

+
+
+
+
+ Email +
+
maya@codegraphy.dev
+
+ + + +
+ + + +
+

Subscription

+

Billing management will connect through Stripe.

+
+
+ +
+
+ + +
+
+ + + +
+

Private plugins

+

Optional paid plugins attached to this account.

+
+
+
+ +
+
+
+

Organize

+

+ Sections, pinned nodes, saved setups, and advanced exports. +

+
+ + Active + +
+
+
+
+ + ); +} diff --git a/apps/web/app/auth/graph.tsx b/apps/web/app/auth/graph.tsx new file mode 100644 index 000000000..d901817f4 --- /dev/null +++ b/apps/web/app/auth/graph.tsx @@ -0,0 +1,238 @@ +'use client'; + +import { + forceCenter, + forceCollide, + forceLink, + forceManyBody, + forceSimulation, + forceX, + forceY, + type Simulation, + type SimulationLinkDatum, + type SimulationNodeDatum, +} from 'd3-force'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +type AuthGraphNode = SimulationNodeDatum & { + clusterId: number; + fill: string; + id: number; + orbitSpeed: number; + radius: number; +}; + +type AuthGraphEdge = SimulationLinkDatum & { + id: string; + source: number; + target: number; +}; + +function randomBetween(min: number, max: number): number { + return min + Math.random() * (max - min); +} + +function blueFill(): string { + return `hsl(${Math.round(randomBetween(198, 214))} ${Math.round(randomBetween(56, 76))}% ${Math.round(randomBetween(76, 88))}%)`; +} + +function createAuthGraph(): { + edges: AuthGraphEdge[]; + nodes: AuthGraphNode[]; +} { + const clusterCount = 4; + const nodeCount = 34; + const center = 500; + const clusterCenters = Array.from({ length: clusterCount }, (_, index) => { + const angle = index * ((Math.PI * 2) / clusterCount) + randomBetween(-0.28, 0.28); + const distance = randomBetween(245, 360); + + return { + x: center + Math.cos(angle) * distance, + y: center + Math.sin(angle) * distance, + }; + }); + const nodes = Array.from({ length: nodeCount }, (_, index) => { + const clusterId = index % clusterCount; + const clusterCenter = clusterCenters[clusterId]; + const angle = randomBetween(0, Math.PI * 2); + const distance = randomBetween(56, 180); + + return { + clusterId, + fill: blueFill(), + id: index, + orbitSpeed: randomBetween(0.018, 0.052), + radius: randomBetween(30, 58), + x: clusterCenter.x + Math.cos(angle) * distance, + y: clusterCenter.y + Math.sin(angle) * distance, + }; + }); + const edges: AuthGraphEdge[] = []; + const addEdge = (source: number, target: number): void => { + const id = source < target ? `${source}-${target}` : `${target}-${source}`; + + if (source === target || edges.some(edge => edge.id === id)) { + return; + } + + edges.push({ id, source, target }); + }; + + const clusters = Array.from({ length: clusterCount }, (_, clusterId) => nodes.filter(node => node.clusterId === clusterId)); + + clusters.forEach(cluster => { + for (let index = 1; index < cluster.length; index += 1) { + addEdge(cluster[index - 1].id, cluster[index].id); + } + + if (cluster.length > 3) { + addEdge(cluster[0].id, cluster[Math.floor(cluster.length / 2)].id); + } + }); + + for (let clusterIndex = 1; clusterIndex < clusters.length; clusterIndex += 1) { + const previousCluster = clusters[clusterIndex - 1]; + const currentCluster = clusters[clusterIndex]; + + addEdge(previousCluster[0].id, currentCluster[0].id); + } + + return { edges, nodes }; +} + +export function AuthGraphField(): React.ReactElement | null { + const [isEnabled, setIsEnabled] = useState(false); + const [renderTick, setRenderTick] = useState(0); + const graph = useMemo(() => { + const nextGraph = createAuthGraph(); + + return { + edges: nextGraph.edges, + nodes: nextGraph.nodes, + }; + }, []); + const simulationRef = useRef | null>(null); + const frameRef = useRef(null); + + useEffect(() => { + if (typeof window.matchMedia !== 'function') { + return; + } + + const desktopQuery = window.matchMedia('(min-width: 768px)'); + const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + const updateEnabled = (): void => { + setIsEnabled(desktopQuery.matches && !reducedMotionQuery.matches); + }; + + updateEnabled(); + desktopQuery.addEventListener('change', updateEnabled); + reducedMotionQuery.addEventListener('change', updateEnabled); + + return () => { + desktopQuery.removeEventListener('change', updateEnabled); + reducedMotionQuery.removeEventListener('change', updateEnabled); + }; + }, []); + + useEffect(() => { + if (!isEnabled) { + simulationRef.current?.stop(); + simulationRef.current = null; + return; + } + + const simulation = forceSimulation(graph.nodes) + .alpha(0.82) + .alphaTarget(0.11) + .velocityDecay(0.34) + .force('center', forceCenter(500, 500).strength(0.032)) + .force('charge', forceManyBody().strength(-72)) + .force('collision', forceCollide().radius(node => node.radius + 14).iterations(4)) + .force( + 'link', + forceLink(graph.edges) + .id(node => node.id) + .distance(52) + .strength(0.44), + ) + .force('x', forceX(500).strength(0.01)) + .force('y', forceY(500).strength(0.01)) + .on('tick', () => { + graph.nodes.forEach(node => { + const deltaX = (node.x ?? 500) - 500; + const deltaY = (node.y ?? 500) - 500; + + node.vx = (node.vx ?? 0) - deltaY * node.orbitSpeed * 0.01; + node.vy = (node.vy ?? 0) + deltaX * node.orbitSpeed * 0.01; + }); + + if (frameRef.current !== null) { + return; + } + + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null; + setRenderTick(tick => tick + 1); + }); + }); + + simulationRef.current = simulation; + + return () => { + simulation.stop(); + simulationRef.current = null; + + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + }; + }, [graph.edges, graph.nodes, isEnabled]); + + if (!isEnabled) { + return null; + } + + void renderTick; + + return ( + + ); +} diff --git a/apps/web/app/auth/view.test.tsx b/apps/web/app/auth/view.test.tsx new file mode 100644 index 000000000..4ad34242a --- /dev/null +++ b/apps/web/app/auth/view.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { AuthView } from './view'; + +describe('CodeGraphy website auth pages', () => { + it('renders login with email, Google, GitHub, and signup navigation', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Sign in' })).toBeInTheDocument(); + expect(screen.getAllByText('CodeGraphy')).toHaveLength(1); + expect(screen.queryByText('CodeGraphy Account')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Email address')).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Continue with Google' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Continue with GitHub' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Sign up' })).toHaveAttribute('href', '/signup'); + }); + + it('renders signup without a confirm-password field and links back to login', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Create a free account' })).toBeInTheDocument(); + expect(screen.getAllByLabelText('Password')).toHaveLength(1); + expect(screen.queryByLabelText(/Confirm password/i)).not.toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Log in' })).toHaveAttribute('href', '/login'); + }); +}); diff --git a/apps/web/app/auth/view.tsx b/apps/web/app/auth/view.tsx new file mode 100644 index 000000000..7cf305f88 --- /dev/null +++ b/apps/web/app/auth/view.tsx @@ -0,0 +1,95 @@ +import Link from 'next/link'; +import { Button } from '../_ui/button'; +import { Card } from '../_ui/card'; +import { GitHubIcon, GoogleIcon } from '../_ui/icons'; +import { Brand } from '../_site/brand'; +import { AuthGraphField } from './graph'; + +export function AuthView({ + mode, +}: { + mode: 'login' | 'signup'; +}): React.ReactElement { + const isLogin = mode === 'login'; + const emailInputId = `${mode}-email`; + const passwordInputId = `${mode}-password`; + + return ( +
+ +
+ + +

+ {isLogin ? 'Sign in' : 'Create a free account'} +

+ +
+
+ + +
+
+
+ + {isLogin ? ( + + Forgot password? + + ) : null} +
+ +
+ +
+ +
+ + or + +
+ +
+ + +
+
+ +

+ {isLogin ? "Don't have an account?" : 'Already have an account?'}{' '} + + {isLogin ? 'Sign up' : 'Log in'} + +

+
+
+ ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 000000000..f51d033d5 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,517 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: 42 24% 92%; + --foreground: 214 29% 13%; + --card: 40 30% 96%; + --card-foreground: 214 29% 13%; + --primary: var(--brand-blue); + --primary-foreground: 0 0% 100%; + --secondary: 38 16% 86%; + --secondary-foreground: 214 29% 13%; + --muted: 38 16% 88%; + --muted-foreground: 212 13% 38%; + --accent: 210 54% 91%; + --accent-foreground: 211 72% 30%; + --brand-blue: 209 68% 41%; + --brand-orange: 25 70% 42%; + --border: 215 15% 80%; + --input: 215 15% 80%; + --ring: 209 68% 41%; + --radius: 0.5rem; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + background: + linear-gradient(hsl(var(--foreground) / 0.045) 1px, transparent 1px), + linear-gradient(90deg, hsl(var(--foreground) / 0.045) 1px, transparent 1px), + hsl(var(--background)); + background-size: 30px 30px; + color: hsl(var(--foreground)); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + letter-spacing: 0; +} + +a { + color: inherit; + text-decoration: none; +} + +button, +input { + font: inherit; +} + +img { + display: block; + max-width: 100%; +} + +.force-field-page, +main { + position: relative; +} + +.force-field-page { + isolation: isolate; +} + +main > section, +footer[data-force-field-section="true"] { + position: relative; +} + +.force-field-background { + z-index: 0; +} + +.force-field-content { + position: relative; + z-index: 2; +} + +.force-node-field { + position: fixed; + inset: 0; + z-index: 1; + width: 100vw; + height: 100vh; + pointer-events: none; + mix-blend-mode: multiply; +} + +.auth-graph-field { + position: absolute; + top: 50%; + left: 50%; + z-index: 0; + width: min(96rem, 106vw); + height: min(96rem, 106vw); + pointer-events: none; + transform: translate(-50%, -50%); + mix-blend-mode: multiply; + opacity: 0.84; + -webkit-mask-image: radial-gradient(circle, #000 0 58%, rgb(0 0 0 / 0.7) 76%, transparent 92%); + mask-image: radial-gradient(circle, #000 0 58%, rgb(0 0 0 / 0.7) 76%, transparent 92%); +} + +.auth-graph-rotor { + transform-origin: 500px 500px; + animation: auth-graph-rotate 58s linear infinite; +} + +.site-heading { + font-family: Georgia, Cambria, "Times New Roman", Times, serif; + font-weight: 600; + letter-spacing: 0; +} + +.home-hero-overlay { + background: linear-gradient( + 180deg, + hsl(var(--background) / 0.84) 0%, + hsl(var(--background) / 0.84) 68%, + hsl(var(--background) / 0.38) 88%, + hsl(var(--background) / 0) 100% + ); +} + +.hero-studio-panel { + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--card) / 0.9); + box-shadow: + 0 1px 0 hsl(var(--background) / 0.9) inset, + 0 12px 24px hsl(var(--foreground) / 0.07); +} + +.hero-tool-row { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.75rem; + align-items: start; + border: 1px solid hsl(var(--border) / 0.78); + border-radius: calc(var(--radius) - 2px); + background: hsl(var(--background) / 0.64); + padding: 0.85rem; +} + +.force-control-panel { + align-self: end; + background: hsl(var(--card) / 0.78); + box-shadow: + 0 1px 0 hsl(var(--background) / 0.85) inset, + 0 12px 24px hsl(var(--foreground) / 0.07); +} + +.force-control-header { + display: flex; + gap: 1rem; + align-items: flex-start; + justify-content: space-between; + border-bottom: 1px solid hsl(var(--border) / 0.7); + background: hsl(var(--background) / 0.48); + padding: 0.95rem 1rem 0.75rem; +} + +.force-control-reset { + min-width: 3.7rem; + border: 1px solid hsl(var(--border)); + border-radius: calc(var(--radius) - 2px); + background: hsl(var(--background) / 0.78); + color: hsl(var(--foreground)); + cursor: pointer; + font-size: 0.76rem; + font-weight: 800; + padding: 0.45rem 0.65rem; + transition: + border-color 160ms ease, + color 160ms ease, + transform 160ms ease; +} + +.force-control-reset:hover { + border-color: hsl(var(--brand-blue) / 0.7); + color: hsl(var(--brand-blue)); + transform: translateY(-1px); +} + +.force-control-list { + display: grid; + gap: 0.5rem; + padding: 0.8rem; +} + +.force-control-row { + display: grid; + grid-template-columns: auto minmax(5.2rem, 6.6rem) minmax(0, 1fr) auto; + gap: 0.65rem; + align-items: center; + border: 1px solid hsl(var(--border) / 0.66); + border-radius: calc(var(--radius) - 2px); + background: hsl(var(--background) / 0.62); + padding: 0.48rem 0.58rem; +} + +.force-control-label { + overflow: hidden; + color: hsl(var(--foreground)); + font-size: 0.92rem; + font-weight: 800; + text-overflow: ellipsis; + white-space: nowrap; +} + +.force-control-value { + min-width: 2rem; + border: 1px solid hsl(var(--brand-blue) / 0.18); + border-radius: 999px; + background: hsl(var(--brand-blue) / 0.08); + color: hsl(var(--brand-blue)); + font-size: 0.72rem; + font-weight: 900; + line-height: 1.4; + padding: 0.06rem 0.38rem; + text-align: center; +} + +.force-slider { + width: 100%; + accent-color: hsl(var(--brand-blue)); + cursor: pointer; +} + +.icon-badge { + display: inline-flex; + width: 2.25rem; + height: 2.25rem; + align-items: center; + justify-content: center; + border: 1px solid hsl(var(--brand-orange) / 0.28); + border-radius: 0.65rem; + background: hsl(var(--brand-orange) / 0.11); + color: hsl(var(--brand-orange)); +} + +.force-control-icon.icon-badge { + width: 1.95rem; + height: 1.95rem; + border-color: hsl(var(--brand-orange) / 0.28); + background: hsl(var(--brand-orange) / 0.1); + color: hsl(var(--brand-orange)); +} + +.graph-note { + border: 1px solid hsl(46 61% 78% / 0.8); + background: hsl(48 84% 88% / 0.72); + box-shadow: 0 8px 16px hsl(var(--foreground) / 0.05); +} + +.notebook-card { + background: hsl(var(--card) / 0.9); + box-shadow: 0 8px 18px hsl(var(--foreground) / 0.045); +} + +.workflow-studio { + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--card) / 0.72); + box-shadow: 0 8px 18px hsl(var(--foreground) / 0.04); +} + +.graph-preview-grid { + background-image: + linear-gradient(hsl(var(--foreground) / 0.06) 1px, transparent 1px), + linear-gradient(90deg, hsl(var(--foreground) / 0.06) 1px, transparent 1px); + background-size: 34px 34px; +} + +.section-plain { + background: hsl(var(--card) / 0.72); +} + +.section-kicker-blue { + color: hsl(var(--brand-blue)); + letter-spacing: 0; +} + +.section-kicker-orange { + color: hsl(var(--brand-orange)); + letter-spacing: 0; +} + +.feature-icon { + color: hsl(var(--brand-orange)); +} + +.section-cta { + background: + linear-gradient(hsl(var(--foreground) / 0.035) 1px, transparent 1px), + linear-gradient(90deg, hsl(var(--foreground) / 0.035) 1px, transparent 1px), + hsl(var(--background)); + background-size: 30px 30px; +} + +.section-cta-overlay { + opacity: 1; +} + +.section-cta-graph-window { + overflow: hidden; + min-height: 14rem; + border: 1px solid hsl(var(--border) / 0.58); + border-radius: var(--radius); + background: hsl(var(--card) / 0.42); + box-shadow: inset 0 0 0 1px hsl(var(--background) / 0.18); +} + +.feature-tour { + display: grid; + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: var(--radius); + background: hsl(var(--background) / 0.58); +} + +.feature-tour-stage { + position: relative; + min-height: 23rem; + overflow: hidden; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(214 19% 13%); +} + +.feature-tour-image-wrap { + height: 100%; + min-height: inherit; +} + +.feature-tour-image { + width: 100%; + height: 100%; + min-height: inherit; + object-fit: cover; + object-position: center; + animation: feature-tour-fade 360ms ease; +} + +.feature-tour-panel { + background: hsl(var(--card) / 0.78); + padding: 1rem; +} + +.feature-tour-option { + border: 1px solid hsl(var(--border) / 0.82); + border-radius: calc(var(--radius) - 1px); + background: hsl(var(--background) / 0.52); + cursor: pointer; + padding: 0.7rem; + transition: + background-color 160ms ease, + border-color 160ms ease, + box-shadow 160ms ease; +} + +.feature-tour-option:hover, +.feature-tour-option:focus-visible { + border-color: hsl(var(--brand-blue) / 0.58); + outline: none; +} + +.feature-tour-option-active { + border-color: hsl(var(--brand-blue) / 0.72); + background: hsl(var(--accent) / 0.62); + box-shadow: 0 8px 18px hsl(var(--brand-blue) / 0.08); +} + +.feature-tour-option-heading { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.7rem; + align-items: center; + color: hsl(var(--foreground)); +} + +.feature-tour-option-text { + margin-top: 0.45rem; + padding-left: 3rem; + color: hsl(var(--muted-foreground)); + font-size: 0.88rem; + line-height: 1.45; +} + +@keyframes feature-tour-fade { + from { + opacity: 0.42; + } + + to { + opacity: 1; + } +} + +.language-marquee { + overflow: hidden; + -webkit-mask-image: linear-gradient(90deg, transparent, #000 11%, #000 89%, transparent); + mask-image: linear-gradient(90deg, transparent, #000 11%, #000 89%, transparent); +} + +.language-marquee-track { + display: flex; + width: max-content; + animation: language-marquee-scroll 58s linear infinite; +} + +.language-marquee:hover .language-marquee-track, +.language-marquee:focus-within .language-marquee-track { + animation-play-state: paused; +} + +.language-marquee-group { + display: flex; + flex-shrink: 0; + gap: clamp(1.35rem, 3vw, 2.6rem); + align-items: center; + padding-right: clamp(1.35rem, 3vw, 2.6rem); +} + +.language-icon-link { + display: inline-flex; + width: 3.45rem; + height: 3.45rem; + align-items: center; + justify-content: center; + color: hsl(var(--foreground)); + transition: + color 160ms ease, + transform 160ms ease; +} + +.language-icon-link:hover, +.language-icon-link:focus-visible { + color: hsl(var(--brand-blue)); + transform: scale(1.16); +} + +.language-icon-link svg { + width: 1.9rem; + height: 1.9rem; +} + +@keyframes language-marquee-scroll { + from { + transform: translateX(0); + } + + to { + transform: translateX(-50%); + } +} + +@keyframes auth-graph-rotate { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .auth-graph-rotor { + animation: none; + } + + .feature-tour-image { + animation: none; + } + + .language-marquee-track { + animation: none; + transform: translateX(0); + } +} + +@media (min-width: 960px) { + .feature-tour { + grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.36fr); + } + + .feature-tour-stage { + border-right: 1px solid hsl(var(--border)); + border-bottom: 0; + } +} + +@media (max-width: 767px) { + .force-control-panel { + display: none; + } + + .force-node-field { + display: none; + } +} + +@media (max-width: 520px) { + .force-control-row { + grid-template-columns: auto minmax(0, 1fr) auto; + } + + .force-slider { + grid-column: 2 / -1; + } +} diff --git a/apps/web/app/home/content.ts b/apps/web/app/home/content.ts new file mode 100644 index 000000000..2af228160 --- /dev/null +++ b/apps/web/app/home/content.ts @@ -0,0 +1,570 @@ +import { + Bot, + Bookmark, + Braces, + Component, + Cuboid, + Database, + FileImage, + FolderOpen, + FolderTree, + Gamepad2, + MapPinned, + Monitor, + Network, + Pin, + Palette, + Plug, + Search, + SlidersHorizontal, +} from 'lucide-react'; +import type { SimpleIcon } from 'simple-icons'; +import { + siC, + siCplusplus, + siDart, + siDotnet, + siGo, + siGodotengine, + siHaskell, + siJavascript, + siKotlin, + siLua, + siMarkdown, + siOpenjdk, + siPhp, + siPython, + siRuby, + siRust, + siSwift, + siTypescript, +} from 'simple-icons'; + +export const installHref = 'https://marketplace.visualstudio.com/items?itemName=codegraphy.codegraphy'; +export const githubHref = 'https://github.com/joesobo/CodeGraphyV4'; +export const cliPackageHref = 'https://www.npmjs.com/package/@codegraphy-dev/cli'; +export const mcpPackageHref = 'https://www.npmjs.com/package/@codegraphy-dev/mcp'; +export const typescriptPluginHref = 'https://www.npmjs.com/package/@codegraphy-dev/plugin-typescript'; +export const pluginApiPackageHref = 'https://www.npmjs.com/package/@codegraphy-dev/plugin-api'; +export const pluginDocsHref = `${githubHref}/blob/main/docs/PLUGINS.md`; +export const pluginLifecycleHref = `${githubHref}/blob/main/docs/plugin-api/LIFECYCLE.md`; +export const pluginTypesHref = `${githubHref}/blob/main/docs/plugin-api/TYPES.md`; +export const cliDocsHref = `${githubHref}/blob/main/docs/COMMANDS.md`; +export const mcpDocsHref = `${githubHref}/blob/main/docs/MCP.md`; +export const coreReadmeHref = `${githubHref}/blob/main/packages/core/README.md`; +export const rootReadmeHref = `${githubHref}/blob/main/README.md`; +export const githubIssuesHref = `${githubHref}/issues`; +export const examplesHref = `${githubHref}/tree/main/examples`; + +function exampleHref(exampleName: string): string { + return `${examplesHref}/${exampleName}`; +} + +export type FaqTextPart = + | string + | { + href: string; + kind: 'link'; + text: string; + } + | { + kind: 'code'; + text: string; + }; + +export type FaqAnswerBlock = + | { + parts: FaqTextPart[]; + type: 'paragraph'; + } + | { + items: FaqTextPart[][]; + label?: string; + type: 'list'; + } + | { + code: string; + type: 'code'; + } + | { + alt: string; + caption: string; + src: string; + type: 'image'; + } + | { + label?: string; + links: Array<{ + href: string; + text: string; + }>; + type: 'links'; + }; + +export type FaqItem = { + answer: FaqAnswerBlock[]; + question: string; +}; + +export type SupportedLanguageItem = { + exampleHref: string; + icon: SimpleIcon; + label: string; +}; + +export const workflowSteps = [ + { + description: 'Start with a file, folder, symbol, or phrase and let the graph narrow around the work in front of you.', + icon: Search, + image: '/product-media/codegraphy-architecture.png', + title: 'Search', + }, + { + description: 'Hide noise, focus Graph Scope, and keep the visible map small enough to reason about.', + icon: SlidersHorizontal, + image: '/product-media/plugins-panel.png', + title: 'Filter', + }, + { + description: 'Use colors, shapes, and icons to customize the appearance of the graph.', + icon: Palette, + image: '/product-media/relationship-graph-2d.png', + title: 'Theme', + }, +]; + +export const socialProofItems = [ + { + href: 'https://github.com/godotengine/godot', + icon: Gamepad2, + image: '/product-media/symbol-nodes-graph.png', + title: 'Godot', + }, + { + href: 'https://github.com/shadcn-ui/ui', + icon: Component, + image: '/product-media/graph-sections.png', + title: 'shadcn/ui', + }, + { + href: githubHref, + icon: Network, + image: '/product-media/relationship-graph-2d.png', + title: 'CodeGraphy', + }, +]; + +export const galleryItems = [ + { + icon: FolderTree, + image: '/product-media/relationship-graph-2d.png', + text: 'Keep folder context visible while exploring the graph.', + title: 'Folder view', + }, + { + icon: Braces, + image: '/product-media/large-repo-graph.png', + text: 'Tree-sitter finds real code relationships to map.', + title: 'Analysis', + }, + { + icon: Palette, + image: '/product-media/symbol-nodes-graph.png', + text: 'Use icons, colors, and legends to read the map faster.', + title: 'Themes', + }, + { + icon: SlidersHorizontal, + image: '/product-media/search-filter-panel.png', + text: 'Narrow the graph to the files and symbols that matter.', + title: 'Search and filters', + }, + { + icon: Plug, + image: '/product-media/plugins-panel.png', + text: 'Turn on plugins to add language and workflow support.', + title: 'Plugin system', + }, + { + icon: Cuboid, + image: '/product-media/relationship-graph-3d.png', + text: 'Switch between flat maps and depth views.', + title: '3D view', + }, + { + icon: FolderOpen, + href: examplesHref, + image: '/product-media/codegraphy-architecture.png', + linkLabel: 'Open examples on GitHub', + text: 'Browse small example projects and language fixtures.', + title: 'Examples', + }, +]; + +export const supportedLanguages: SupportedLanguageItem[] = [ + { + exampleHref: exampleHref('example-c'), + icon: siC, + label: 'C', + }, + { + exampleHref: exampleHref('example-cpp'), + icon: siCplusplus, + label: 'C++', + }, + { + exampleHref: exampleHref('example-csharp'), + icon: siDotnet, + label: 'C#', + }, + { + exampleHref: exampleHref('example-dart'), + icon: siDart, + label: 'Dart', + }, + { + exampleHref: exampleHref('example-go'), + icon: siGo, + label: 'Go', + }, + { + exampleHref: exampleHref('example-godot'), + icon: siGodotengine, + label: 'Godot', + }, + { + exampleHref: exampleHref('example-haskell'), + icon: siHaskell, + label: 'Haskell', + }, + { + exampleHref: exampleHref('example-java'), + icon: siOpenjdk, + label: 'Java', + }, + { + exampleHref: exampleHref('example-typescript'), + icon: siJavascript, + label: 'JavaScript', + }, + { + exampleHref: exampleHref('example-kotlin'), + icon: siKotlin, + label: 'Kotlin', + }, + { + exampleHref: exampleHref('example-lua'), + icon: siLua, + label: 'Lua', + }, + { + exampleHref: exampleHref('example-markdown'), + icon: siMarkdown, + label: 'Markdown', + }, + { + exampleHref: exampleHref('example-php'), + icon: siPhp, + label: 'PHP', + }, + { + exampleHref: exampleHref('example-python'), + icon: siPython, + label: 'Python', + }, + { + exampleHref: exampleHref('example-ruby'), + icon: siRuby, + label: 'Ruby', + }, + { + exampleHref: exampleHref('example-rust'), + icon: siRust, + label: 'Rust', + }, + { + exampleHref: exampleHref('example-swift'), + icon: siSwift, + label: 'Swift', + }, + { + exampleHref: exampleHref('example-typescript'), + icon: siTypescript, + label: 'TypeScript', + }, +]; + +export const coreFeatures = [ + { + icon: Network, + text: 'Relationship Graph with files, folders, packages, symbols, and plugin nodes', + }, + { + icon: Database, + text: 'Local Graph Cache in the workspace', + }, + { + icon: Search, + text: 'Search, filters, and Graph Scope', + }, + { + icon: Palette, + text: 'Integrated VS Code theming and Legend styling', + }, + { + icon: Monitor, + text: '2D or 3D graph views', + }, + { + icon: Bot, + text: 'CLI and local MCP access for agents', + }, + { + icon: Plug, + text: 'Plugin API for language and framework enrichment', + }, +]; + +export const optionalPackages = [ + { + description: 'Pins, sections, bookmarks, and polished export tools.', + features: [ + { + icon: MapPinned, + text: 'Sections for shaping clusters into named areas', + }, + { + icon: Pin, + text: 'Pin nodes so you remember where they are', + }, + { + icon: Bookmark, + text: 'Bookmark graph settings and organization patterns', + }, + { + icon: FileImage, + text: 'Advanced exports for polished graph internals and shareable map artifacts', + }, + ], + href: null, + name: 'Organize', + screenshots: [ + { + image: '/product-media/graph-sections.png', + title: 'Sections', + }, + { + image: '/product-media/search-filter-panel.png', + title: 'Bookmarks', + }, + { + image: '/product-media/relationship-graph-2d.png', + title: 'Pins', + }, + ], + }, +]; + +export const faqItems: FaqItem[] = [ + { + answer: [ + { + parts: [ + 'CodeGraphy has three surfaces that can read the same local map: the ', + { href: installHref, kind: 'link', text: 'VS Code extension' }, + ', the ', + { href: cliPackageHref, kind: 'link', text: 'CodeGraphy CLI' }, + ', and the ', + { href: mcpPackageHref, kind: 'link', text: 'CodeGraphy MCP package' }, + '.', + ], + type: 'paragraph', + }, + { + items: [ + [ + 'Install the VS Code extension.', + ], + [ + 'Index the repo from the CodeGraphy view or with the CLI.', + ], + [ + 'Install ', + { kind: 'code', text: '@codegraphy-dev/plugin-typescript' }, + ' for TypeScript and JavaScript relationships.', + ], + [ + 'Turn the TypeScript plugin on from the extension UI or with the CLI, then re-index.', + ], + ], + label: 'Starting path', + type: 'list', + }, + { + code: 'npm install -g @codegraphy-dev/plugin-typescript\ncodegraphy index\ncodegraphy plugins enable @codegraphy-dev/plugin-typescript\ncodegraphy index', + type: 'code', + }, + { + alt: 'CodeGraphy architecture diagram showing core, plugins, the extension, CLI, and MCP reading the same graph data', + caption: 'Core indexes the workspace, plugins enrich the graph, then the extension, CLI, and MCP can read the same relationships.', + src: '/product-media/codegraphy-architecture.png', + type: 'image', + }, + { + label: 'Setup links', + links: [ + { href: installHref, text: 'VS Code extension' }, + { href: cliPackageHref, text: 'CLI package' }, + { href: cliDocsHref, text: 'CLI docs' }, + { href: typescriptPluginHref, text: 'TypeScript plugin' }, + { href: mcpPackageHref, text: 'MCP package' }, + { href: mcpDocsHref, text: 'MCP setup docs' }, + ], + type: 'links', + }, + ], + question: 'Quickstart', + }, + { + answer: [ + { + parts: [ + 'Yes. The core, extension, CLI, MCP, Plugin API, and several example plugin packages are open source in the CodeGraphy monorepo.', + ], + type: 'paragraph', + }, + { + items: [ + ['Inspect the source, docs, package readmes, and plugin examples on GitHub.'], + ['Private paid plugins are separate plugins integrated on top of the core that require login.'], + ], + type: 'list', + }, + { + label: 'Open-source links', + links: [ + { href: githubHref, text: 'GitHub' }, + { href: coreReadmeHref, text: 'Core package' }, + { href: cliPackageHref, text: 'CLI package' }, + { href: mcpPackageHref, text: 'MCP package' }, + { href: pluginApiPackageHref, text: 'Plugin API' }, + ], + type: 'links', + }, + ], + question: 'Is CodeGraphy open source?', + }, + { + answer: [ + { + parts: [ + 'Connections are made during local indexing. CodeGraphy discovers files, reads folder and package structure, runs Tree-sitter analysis, applies enabled plugins, then writes the result into a workspace local Graph Cache.', + ], + type: 'paragraph', + }, + { + label: 'Read the local graph docs', + links: [ + { href: coreReadmeHref, text: 'Core package README' }, + ], + type: 'links', + }, + ], + question: 'How are connections made?', + }, + { + answer: [ + { + parts: [ + 'Plugins extend CodeGraphy to support a language, framework, file format, or private codebase convention.', + ], + type: 'paragraph', + }, + { + items: [ + ['Add new kinds of nodes or relationships'], + ['Set ecosystem defaults such as filters, icons, and color themes'], + ['Represent private architecture rules or internal framework conventions'], + ], + label: 'Plugins can be used to', + type: 'list', + }, + { + label: 'Explore plugin docs', + links: [ + { href: pluginDocsHref, text: 'Plugin Guide' }, + { href: pluginApiPackageHref, text: 'Plugin API package' }, + { href: pluginTypesHref, text: 'Plugin API types' }, + ], + type: 'links', + }, + ], + question: 'What are plugins?', + }, + { + answer: [ + { + parts: ['Install the plugin, refresh CodeGraphy\'s plugin cache, enable it for the current workspace, then re-index.'], + type: 'paragraph', + }, + { + code: 'npm install -g @codegraphy-dev/plugin-python\ncodegraphy plugins refresh\ncodegraphy plugins enable @codegraphy-dev/plugin-python\ncodegraphy index', + type: 'code', + }, + { + label: 'Install a plugin', + links: [ + { href: 'https://www.npmjs.com/package/@codegraphy-dev/plugin-typescript', text: 'TypeScript/JavaScript' }, + { href: 'https://www.npmjs.com/package/@codegraphy-dev/plugin-python', text: 'Python' }, + { href: 'https://www.npmjs.com/package/@codegraphy-dev/plugin-csharp', text: 'C#' }, + { href: 'https://www.npmjs.com/package/@codegraphy-dev/plugin-godot', text: 'Godot' }, + { href: 'https://www.npmjs.com/package/@codegraphy-dev/plugin-markdown', text: 'Markdown' }, + ], + type: 'links', + }, + ], + question: 'How do I install plugins?', + }, + { + answer: [ + { + parts: [ + 'Start with ', + { href: pluginApiPackageHref, kind: 'link', text: '@codegraphy-dev/plugin-api' }, + ' for the typed contracts, then follow the Plugin Guide for package metadata, lifecycle hooks, analysis results, and local testing.', + ], + type: 'paragraph', + }, + { + label: 'Build your own plugin', + links: [ + { href: pluginApiPackageHref, text: 'Plugin API npm package' }, + { href: `${githubHref}/blob/main/packages/plugin-api/README.md`, text: 'Plugin API README' }, + { href: pluginDocsHref, text: 'Plugin Guide' }, + { href: pluginLifecycleHref, text: 'Lifecycle docs' }, + ], + type: 'links', + }, + ], + question: 'How do I make plugins?', + }, + { + answer: [ + { + parts: [ + 'Open a GitHub issue for bugs, feature requests, or ideas you want discussed. Contributions are always welcome.', + ], + type: 'paragraph', + }, + { + label: 'Contribute', + links: [ + { href: githubHref, text: 'GitHub' }, + { href: githubIssuesHref, text: 'Issues' }, + ], + type: 'links', + }, + ], + question: 'Where do I report bugs or contribute?', + }, +]; diff --git a/apps/web/app/home/featureTour/view.tsx b/apps/web/app/home/featureTour/view.tsx new file mode 100644 index 000000000..de068de20 --- /dev/null +++ b/apps/web/app/home/featureTour/view.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { cn } from '../../_ui/cn'; +import { galleryItems } from '../content'; + +const AUTO_ADVANCE_MS = 5000; +const INTERACTION_COOLDOWN_MS = 2400; + +export function FeatureTour(): React.ReactElement { + const [activeIndex, setActiveIndex] = useState(0); + const [isCoolingDown, setIsCoolingDown] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const [isInView, setIsInView] = useState(false); + const activeItem = galleryItems[activeIndex] ?? galleryItems[0]; + const activeImage = useMemo(() => activeItem.image, [activeItem.image]); + const cooldownTimeoutRef = useRef(null); + const sectionRef = useRef(null); + const isAutoPaused = !isInView || isHovered || isFocused || isCoolingDown; + + const pauseBriefly = useCallback(() => { + if (cooldownTimeoutRef.current !== null) { + window.clearTimeout(cooldownTimeoutRef.current); + } + + setIsCoolingDown(true); + cooldownTimeoutRef.current = window.setTimeout(() => { + cooldownTimeoutRef.current = null; + setIsCoolingDown(false); + }, INTERACTION_COOLDOWN_MS); + }, []); + + const selectFeature = useCallback((index: number) => { + setActiveIndex(index); + pauseBriefly(); + }, [pauseBriefly]); + + useEffect(() => { + const section = sectionRef.current; + + if (section === null) { + return; + } + + if (typeof IntersectionObserver === 'undefined') { + setIsInView(true); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + setIsInView(Boolean(entry?.isIntersecting)); + }, + { + root: null, + threshold: 0.35, + }, + ); + + observer.observe(section); + + return () => { + observer.disconnect(); + }; + }, []); + + useEffect(() => { + if (isAutoPaused) { + return; + } + + const interval = window.setInterval(() => { + setActiveIndex(currentIndex => (currentIndex + 1) % galleryItems.length); + }, AUTO_ADVANCE_MS); + + return () => { + window.clearInterval(interval); + }; + }, [isAutoPaused]); + + useEffect(() => () => { + if (cooldownTimeoutRef.current !== null) { + window.clearTimeout(cooldownTimeoutRef.current); + } + }, []); + + return ( +
{ + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setIsFocused(false); + pauseBriefly(); + } + }} + onFocus={() => { + setIsFocused(true); + }} + onMouseEnter={() => { + setIsHovered(true); + }} + onMouseLeave={() => { + setIsHovered(false); + pauseBriefly(); + }} + ref={sectionRef} + > +
+
+ {`${activeItem.title} +
+
+ +
+

Select a view

+
+ {galleryItems.map((item, index) => { + const Icon = item.icon; + const isActive = index === activeIndex; + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/apps/web/app/home/forceNodeField/controls.test.tsx b/apps/web/app/home/forceNodeField/controls.test.tsx new file mode 100644 index 000000000..9b0fb7875 --- /dev/null +++ b/apps/web/app/home/forceNodeField/controls.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { ForceNodeControls } from './controls'; +import { ForceNodeSettingsProvider } from './settings'; + +function renderControls(): void { + render( + + + , + ); +} + +describe('ForceNodeControls', () => { + it('lets visitors adjust and reset hero graph forces', () => { + renderControls(); + + const sizeSlider = screen.getByRole('slider', { name: 'Node size' }); + + expect(sizeSlider).toHaveValue('50'); + + fireEvent.change(sizeSlider, { target: { value: '88' } }); + + expect(sizeSlider).toHaveValue('88'); + + fireEvent.click(screen.getByRole('button', { name: 'Reset' })); + + expect(sizeSlider).toHaveValue('50'); + }); +}); diff --git a/apps/web/app/home/forceNodeField/controls.tsx b/apps/web/app/home/forceNodeField/controls.tsx new file mode 100644 index 000000000..a4be9e58f --- /dev/null +++ b/apps/web/app/home/forceNodeField/controls.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { Crosshair, Link2, MoveHorizontal, Orbit, Radius, type LucideIcon } from 'lucide-react'; +import { useId } from 'react'; +import { type ForceSettingKey, useForceNodeSettings } from './settings'; + +type ForceControlItem = { + icon: LucideIcon; + key: ForceSettingKey; + label: string; +}; + +const forceControls: ForceControlItem[] = [ + { + icon: Crosshair, + key: 'center', + label: 'Center', + }, + { + icon: Orbit, + key: 'repel', + label: 'Repel', + }, + { + icon: MoveHorizontal, + key: 'distance', + label: 'Distance', + }, + { + icon: Link2, + key: 'link', + label: 'Link force', + }, + { + icon: Radius, + key: 'size', + label: 'Node size', + }, +]; + +export function ForceNodeControls(): React.ReactElement { + const idPrefix = useId().replace(/:/g, ''); + const { resetSettings, settings, updateSetting } = useForceNodeSettings(); + + return ( + + ); +} diff --git a/apps/web/app/home/forceNodeField/model.test.ts b/apps/web/app/home/forceNodeField/model.test.ts new file mode 100644 index 000000000..42f60f5e4 --- /dev/null +++ b/apps/web/app/home/forceNodeField/model.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest'; +import { + createForceEdges, + createForceNodes, + createVisibleEdgeSegment, + forceNodeCollisionRadius, + getForceLinkDistance, + getSafeLinkDistance, + limitForceNodeVelocity, + readForceSectionRects, + separateOverlappingForceNodes, + type ForceEdge, + type ForceNode, +} from './model'; +import { defaultForceNodeSettings, normalizeForceSettings } from './settings'; + +function seededRandom(seed: number): () => number { + let value = seed; + + return () => { + value = (value * 16807) % 2147483647; + + return (value - 1) / 2147483646; + }; +} + +function endpointId(endpoint: ForceEdge['source']): number { + return typeof endpoint === 'object' ? endpoint.id : Number(endpoint); +} + +function connectedNodeIds(nodes: ForceNode[], edges: ForceEdge[]): Set { + const adjacency = new Map(); + + nodes.forEach(node => { + adjacency.set(node.id, []); + }); + + edges.forEach(edge => { + const source = endpointId(edge.source); + const target = endpointId(edge.target); + + adjacency.get(source)?.push(target); + adjacency.get(target)?.push(source); + }); + + const visited = new Set(); + const pending = [nodes[0]?.id].filter(id => id !== undefined); + + while (pending.length > 0) { + const id = pending.pop(); + + if (id === undefined || visited.has(id)) { + continue; + } + + visited.add(id); + pending.push(...(adjacency.get(id) ?? [])); + } + + return visited; +} + +function stubRect(element: Element, rect: Partial): void { + Object.defineProperty(element, 'getBoundingClientRect', { + configurable: true, + value: () => ({ + bottom: 0, + height: 0, + left: 0, + right: 0, + toJSON: () => ({}), + top: 0, + width: 0, + x: 0, + y: 0, + ...rect, + }), + }); +} + +function forceNode(overrides: Partial): ForceNode { + return { + clusterId: 0, + fill: 'hsl(207 83% 48%)', + id: 0, + opacity: 0.3, + pull: 0.06, + radius: 8, + stroke: 'hsl(210 88% 88% / 0.62)', + x: 0, + y: 0, + ...overrides, + }; +} + +describe('force node field model', () => { + it('creates a varied set of blue force nodes', () => { + const nodes = createForceNodes({ height: 800, width: 1200 }, seededRandom(10)); + + expect(nodes.length).toBeGreaterThanOrEqual(26); + expect(nodes.length).toBeLessThanOrEqual(34); + expect(Math.min(...nodes.map(node => node.radius))).toBeGreaterThan(5); + expect(new Set(nodes.map(node => node.radius)).size).toBeGreaterThan(6); + expect(new Set(nodes.map(node => node.fill)).size).toBeGreaterThan(4); + expect(nodes.every(node => /^hsl\((19|20|21)/.test(node.fill))).toBe(true); + }); + + it('randomizes nodes across refreshes while keeping the same blue theme', () => { + const firstNodes = createForceNodes({ height: 800, width: 1200 }, seededRandom(11)); + const secondNodes = createForceNodes({ height: 800, width: 1200 }, seededRandom(12)); + + expect(firstNodes.map(node => [node.radius, node.fill, node.clusterId])).not.toEqual( + secondNodes.map(node => [node.radius, node.fill, node.clusterId]), + ); + }); + + it('creates one connected clustered graph with link distance and force settings', () => { + const nodes = createForceNodes({ height: 800, width: 1200 }, seededRandom(20)); + const edges = createForceEdges(nodes, seededRandom(21)); + + expect(connectedNodeIds(nodes, edges).size).toBe(nodes.length); + expect(edges.length).toBeGreaterThan(nodes.length); + expect(edges.some(edge => edge.distance < 60 && edge.strength > 0.16)).toBe(true); + expect(edges.some(edge => edge.distance > 60 && edge.strength < 0.1)).toBe(true); + expect(edges.every(edge => edge.distance > 0 && edge.strength > 0)).toBe(true); + }); + + it('keeps link distance outside the visible node rim for oversized nodes', () => { + const source = forceNode({ id: 1, radius: 11 }); + const target = forceNode({ id: 2, radius: 10 }); + const collisionSettings = { + collisionPadding: 27.2, + sizeMultiplier: 3.9, + }; + + expect(getSafeLinkDistance(38, source, target, collisionSettings)).toBeGreaterThanOrEqual( + source.radius * collisionSettings.sizeMultiplier + target.radius * collisionSettings.sizeMultiplier + 4, + ); + }); + + it('preserves visible distance-slider movement when oversized nodes need collision safety', () => { + const source = forceNode({ id: 1, radius: 8 }); + const target = forceNode({ id: 2, radius: 8 }); + const compactDistance = getForceLinkDistance(40, source, target, normalizeForceSettings({ + ...defaultForceNodeSettings, + distance: 0, + size: 100, + })); + const defaultDistance = getForceLinkDistance(40, source, target, normalizeForceSettings({ + ...defaultForceNodeSettings, + distance: 50, + size: 100, + })); + + expect(compactDistance).toBeLessThan(defaultDistance - 12); + }); + + it('separates oversized nodes that get shoved into each other', () => { + const firstNode = forceNode({ id: 1, radius: 11, vx: 14, vy: 9, x: 100, y: 100 }); + const secondNode = forceNode({ id: 2, radius: 10, vx: -12, vy: -8, x: 106, y: 101 }); + const collisionSettings = { + collisionPadding: 27.2, + sizeMultiplier: 3.9, + }; + const nodes = [firstNode, secondNode]; + + expect(separateOverlappingForceNodes(nodes, collisionSettings)).toBe(true); + + const distance = Math.hypot((secondNode.x ?? 0) - (firstNode.x ?? 0), (secondNode.y ?? 0) - (firstNode.y ?? 0)); + const requiredDistance = forceNodeCollisionRadius(firstNode, collisionSettings) + + forceNodeCollisionRadius(secondNode, collisionSettings); + + expect(distance).toBeGreaterThanOrEqual(requiredDistance); + expect(Math.hypot(firstNode.vx ?? 0, firstNode.vy ?? 0)).toBeLessThan(14); + expect(Math.hypot(secondNode.vx ?? 0, secondNode.vy ?? 0)).toBeLessThan(12); + }); + + it('clamps fast mouse-shove velocity before it can spin the cluster', () => { + const node = forceNode({ vx: 18, vy: 24 }); + + limitForceNodeVelocity(node, 0.6); + + expect(Math.hypot(node.vx ?? 0, node.vy ?? 0)).toBeCloseTo(0.6); + }); + + it('trims visible edge segments to the node rim', () => { + expect( + createVisibleEdgeSegment( + forceNode({ id: 1, radius: 10, x: 0, y: 0 }), + forceNode({ id: 2, radius: 20, x: 100, y: 0 }), + { x: 0, y: 0 }, + ), + ).toEqual({ + x1: 12, + x2: 78, + y1: 0, + y2: 0, + }); + }); + + it('uses the interactive node size when trimming edge segments', () => { + expect( + createVisibleEdgeSegment( + forceNode({ id: 1, radius: 10, x: 0, y: 0 }), + forceNode({ id: 2, radius: 10, x: 80, y: 0 }), + { x: 0, y: 0 }, + 2, + 1.5, + ), + ).toEqual({ + x1: 17, + x2: 63, + y1: 0, + y2: 0, + }); + }); + + it('skips edges when nodes are too close to reveal a clean segment', () => { + expect( + createVisibleEdgeSegment( + forceNode({ id: 1, radius: 12, x: 0, y: 0 }), + forceNode({ id: 2, radius: 12, x: 20, y: 0 }), + { x: 0, y: 0 }, + ), + ).toBeNull(); + }); + + it('uses only visible graph background sections for the mask', () => { + document.body.innerHTML = ` +
+
+
+ `; + + const visible = document.querySelector('#visible'); + const below = document.querySelector('#below'); + const solid = document.querySelector('#solid'); + + if (visible === null || below === null || solid === null) { + throw new Error('test sections were not created'); + } + + stubRect(visible, { bottom: 340, height: 320, left: 0, top: 20, width: 1200 }); + stubRect(below, { bottom: 980, height: 280, left: 0, top: 700, width: 1200 }); + stubRect(solid, { bottom: 420, height: 320, left: 0, top: 100, width: 1200 }); + + expect(readForceSectionRects({ height: 600, width: 1200 })).toEqual([ + { + height: 320, + width: 1200, + x: 0, + y: 20, + }, + ]); + }); +}); diff --git a/apps/web/app/home/forceNodeField/model.ts b/apps/web/app/home/forceNodeField/model.ts new file mode 100644 index 000000000..1e906a8b8 --- /dev/null +++ b/apps/web/app/home/forceNodeField/model.ts @@ -0,0 +1,335 @@ +import type { SimulationLinkDatum, SimulationNodeDatum } from 'd3-force'; + +export const EDGE_FEATHER = 28; +export const EDGE_NODE_GAP = 2; +export const FIELD_SELECTOR = '[data-force-field-section="true"]'; + +const MAX_NODE_COUNT = 34; +const MIN_NODE_COUNT = 26; +const MAX_CLUSTER_COUNT = 5; +const MIN_CLUSTER_COUNT = 3; + +export type Viewport = { + height: number; + width: number; +}; + +export type MaskRect = { + height: number; + width: number; + x: number; + y: number; +}; + +export type ForceNode = SimulationNodeDatum & { + clusterId: number; + fill: string; + id: number; + opacity: number; + pull: number; + radius: number; + stroke: string; +}; + +export type ForceEdge = SimulationLinkDatum & { + distance: number; + id: string; + opacity: number; + strength: number; + width: number; +}; + +export type EdgeSegment = { + x1: number; + x2: number; + y1: number; + y2: number; +}; + +export type RandomSource = () => number; + +export type ForceCollisionSettings = { + collisionPadding: number; + sizeMultiplier: number; +}; + +export type ForceLinkDistanceSettings = ForceCollisionSettings & { + distanceMultiplier: number; + linkDistancePadding?: number; + sizeDistanceMultiplier: number; +}; + +function randomBetween(random: RandomSource, min: number, max: number): number { + return min + random() * (max - min); +} + +function randomInt(random: RandomSource, min: number, max: number): number { + return Math.floor(randomBetween(random, min, max + 1)); +} + +function blueColor(random: RandomSource): string { + return `hsl(${randomInt(random, 196, 216)} ${randomInt(random, 68, 92)}% ${randomInt(random, 45, 68)}%)`; +} + +export function createForceNodes(viewport: Viewport, random: RandomSource = Math.random): ForceNode[] { + const nodeCount = randomInt(random, MIN_NODE_COUNT, MAX_NODE_COUNT); + const clusterCount = randomInt(random, MIN_CLUSTER_COUNT, MAX_CLUSTER_COUNT); + const startX = viewport.width * 0.72; + const startY = viewport.height * 0.28; + const clusterCenters = Array.from({ length: clusterCount }, (_, clusterIndex) => { + const angle = clusterIndex * ((Math.PI * 2) / clusterCount) + randomBetween(random, -0.3, 0.3); + const distance = randomBetween(random, 24, 72); + + return { + x: startX + Math.cos(angle) * distance, + y: startY + Math.sin(angle) * distance, + }; + }); + + return Array.from({ length: nodeCount }, (_, index) => { + const clusterId = index % clusterCount; + const center = clusterCenters[clusterId]; + const angle = randomBetween(random, 0, Math.PI * 2); + const distance = randomBetween(random, 12, 56); + const radius = randomBetween(random, 5.4, 11.8); + + return { + clusterId, + fill: blueColor(random), + id: index, + opacity: randomBetween(random, 0.24, 0.42), + pull: randomBetween(random, 0.048, 0.076), + radius, + stroke: 'hsl(210 88% 88% / 0.62)', + x: center.x + Math.cos(angle) * distance, + y: center.y + Math.sin(angle) * distance, + }; + }); +} + +export function createForceEdges(nodes: ForceNode[], random: RandomSource = Math.random): ForceEdge[] { + const edges: ForceEdge[] = []; + const edgeIds = new Set(); + const addEdge = ( + source: number, + target: number, + options: { + distance: number; + opacity: number; + strength: number; + width: number; + }, + ): void => { + if (source === target) { + return; + } + + const id = source < target ? `${source}-${target}` : `${target}-${source}`; + + if (edgeIds.has(id)) { + return; + } + + edgeIds.add(id); + edges.push({ + ...options, + id, + source, + target, + }); + }; + const clusterMap = new Map(); + + nodes.forEach(node => { + clusterMap.set(node.clusterId, [...(clusterMap.get(node.clusterId) ?? []), node.id]); + }); + + const clusters = Array.from(clusterMap.values()).filter(cluster => cluster.length > 0); + + clusters.forEach(cluster => { + for (let index = 1; index < cluster.length; index += 1) { + addEdge(cluster[index - 1], cluster[index], { + distance: randomBetween(random, 26, 42), + opacity: randomBetween(random, 0.14, 0.23), + strength: randomBetween(random, 0.34, 0.54), + width: randomBetween(random, 0.9, 1.35), + }); + } + + cluster.forEach(source => { + const extraEdgeCount = random() > 0.55 ? 2 : 1; + + for (let extraIndex = 0; extraIndex < extraEdgeCount; extraIndex += 1) { + const target = cluster[randomInt(random, 0, cluster.length - 1)]; + + addEdge(source, target, { + distance: randomBetween(random, 34, 58), + opacity: randomBetween(random, 0.08, 0.16), + strength: randomBetween(random, 0.16, 0.3), + width: randomBetween(random, 0.7, 1.05), + }); + } + }); + }); + + for (let clusterIndex = 1; clusterIndex < clusters.length; clusterIndex += 1) { + const previousCluster = clusters[clusterIndex - 1]; + const currentCluster = clusters[clusterIndex]; + + addEdge(previousCluster[randomInt(random, 0, previousCluster.length - 1)], currentCluster[randomInt(random, 0, currentCluster.length - 1)], { + distance: randomBetween(random, 68, 96), + opacity: randomBetween(random, 0.06, 0.12), + strength: randomBetween(random, 0.045, 0.08), + width: randomBetween(random, 0.65, 0.95), + }); + } + + return edges; +} + +export function forceNodeCollisionRadius(node: ForceNode, settings: ForceCollisionSettings): number { + return node.radius * settings.sizeMultiplier + settings.collisionPadding; +} + +export function forceNodeVisibleRadius(node: ForceNode, settings: Pick): number { + return node.radius * settings.sizeMultiplier; +} + +export function getSafeLinkDistance( + preferredDistance: number, + source: ForceNode, + target: ForceNode, + settings: ForceCollisionSettings & { linkDistancePadding?: number }, +): number { + return Math.max( + preferredDistance, + forceNodeVisibleRadius(source, settings) + forceNodeVisibleRadius(target, settings) + (settings.linkDistancePadding ?? EDGE_NODE_GAP * 2), + ); +} + +export function getForceLinkDistance( + baseDistance: number, + source: ForceNode, + target: ForceNode, + settings: ForceLinkDistanceSettings, +): number { + return getSafeLinkDistance(baseDistance * settings.distanceMultiplier * settings.sizeDistanceMultiplier, source, target, settings); +} + +export function limitForceNodeVelocity(node: ForceNode, maxVelocity: number): void { + const velocityX = node.vx ?? 0; + const velocityY = node.vy ?? 0; + const speed = Math.hypot(velocityX, velocityY); + + if (speed <= maxVelocity || speed === 0) { + return; + } + + const scale = maxVelocity / speed; + + node.vx = velocityX * scale; + node.vy = velocityY * scale; +} + +function fallbackSeparationAngle(sourceId: number, targetId: number): number { + return ((sourceId + 1) * 1.618 + (targetId + 1) * 2.414) % (Math.PI * 2); +} + +function dampForceNodeVelocity(node: ForceNode): void { + node.vx = (node.vx ?? 0) * 0.25; + node.vy = (node.vy ?? 0) * 0.25; +} + +export function separateOverlappingForceNodes( + nodes: ForceNode[], + settings: ForceCollisionSettings, + iterations = 2, +): boolean { + let separated = false; + + for (let iteration = 0; iteration < iterations; iteration += 1) { + for (let sourceIndex = 0; sourceIndex < nodes.length; sourceIndex += 1) { + for (let targetIndex = sourceIndex + 1; targetIndex < nodes.length; targetIndex += 1) { + const source = nodes[sourceIndex]; + const target = nodes[targetIndex]; + const sourceX = source.x ?? 0; + const sourceY = source.y ?? 0; + const targetX = target.x ?? sourceX; + const targetY = target.y ?? sourceY; + const deltaX = targetX - sourceX; + const deltaY = targetY - sourceY; + const distance = Math.hypot(deltaX, deltaY); + const requiredDistance = forceNodeCollisionRadius(source, settings) + forceNodeCollisionRadius(target, settings); + + if (distance >= requiredDistance) { + continue; + } + + const angle = distance === 0 ? fallbackSeparationAngle(source.id, target.id) : Math.atan2(deltaY, deltaX); + const unitX = Math.cos(angle); + const unitY = Math.sin(angle); + const separation = (requiredDistance - distance) / 2 + 0.001; + + source.x = sourceX - unitX * separation; + source.y = sourceY - unitY * separation; + target.x = targetX + unitX * separation; + target.y = targetY + unitY * separation; + dampForceNodeVelocity(source); + dampForceNodeVelocity(target); + separated = true; + } + } + } + + return separated; +} + +export function createVisibleEdgeSegment( + source: ForceNode, + target: ForceNode, + fallback: { x: number; y: number }, + gap = EDGE_NODE_GAP, + radiusMultiplier = 1, +): EdgeSegment | null { + const sourceX = source.x ?? fallback.x; + const sourceY = source.y ?? fallback.y; + const targetX = target.x ?? fallback.x; + const targetY = target.y ?? fallback.y; + const deltaX = targetX - sourceX; + const deltaY = targetY - sourceY; + const length = Math.hypot(deltaX, deltaY); + const sourceRadius = source.radius * radiusMultiplier; + const targetRadius = target.radius * radiusMultiplier; + + if (length <= sourceRadius + targetRadius + gap * 2 + 1) { + return null; + } + + const unitX = deltaX / length; + const unitY = deltaY / length; + const sourceOffset = sourceRadius + gap; + const targetOffset = targetRadius + gap; + + return { + x1: sourceX + unitX * sourceOffset, + x2: targetX - unitX * targetOffset, + y1: sourceY + unitY * sourceOffset, + y2: targetY - unitY * targetOffset, + }; +} + +export function readForceSectionRects(viewport: Viewport): MaskRect[] { + if (typeof document === 'undefined') { + return []; + } + + return Array.from(document.querySelectorAll(FIELD_SELECTOR)) + .map(element => element.getBoundingClientRect()) + .filter(rect => rect.bottom >= -EDGE_FEATHER && rect.top <= viewport.height + EDGE_FEATHER) + .map(rect => ({ + height: Math.max(0, rect.height), + width: Math.max(0, rect.width), + x: rect.left, + y: rect.top, + })); +} diff --git a/apps/web/app/home/forceNodeField/settings.test.ts b/apps/web/app/home/forceNodeField/settings.test.ts new file mode 100644 index 000000000..d930238eb --- /dev/null +++ b/apps/web/app/home/forceNodeField/settings.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { defaultForceNodeSettings, normalizeForceSettings } from './settings'; + +describe('force node settings', () => { + it('keeps the default sliders close to the original hero graph physics', () => { + const normalized = normalizeForceSettings(defaultForceNodeSettings); + + expect(normalized.centerPullMultiplier).toBeCloseTo(1.045, 3); + expect(normalized.centerStrength).toBeCloseTo(0.025, 3); + expect(normalized.collisionIterations).toBe(2); + expect(normalized.collisionPadding).toBe(4); + expect(normalized.distanceMultiplier).toBe(1); + expect(normalized.linkDistancePadding).toBe(4); + expect(normalized.linkStrengthMultiplier).toBe(1); + expect(normalized.repelStrength).toBe(-18); + expect(normalized.sizeDistanceMultiplier).toBe(1); + expect(normalized.sizeMultiplier).toBe(1); + expect(normalized.sizeRepelMultiplier).toBe(1); + }); + + it('turns slider values into bounded graph force ranges', () => { + const low = normalizeForceSettings({ + center: -20, + distance: -20, + link: -20, + repel: -20, + size: -20, + }); + const high = normalizeForceSettings({ + center: 120, + distance: 120, + link: 120, + repel: 120, + size: 120, + }); + + expect(low.centerStrength).toBe(0.008); + expect(high.centerStrength).toBe(0.046); + expect(low.collisionIterations).toBe(2); + expect(high.collisionIterations).toBe(9); + expect(low.collisionPadding).toBe(4); + expect(high.collisionPadding).toBe(33); + expect(low.distanceMultiplier).toBe(0.35); + expect(high.distanceMultiplier).toBe(2); + expect(low.linkDistancePadding).toBe(4); + expect(high.linkDistancePadding).toBe(11.25); + expect(low.linkStrengthMultiplier).toBe(0); + expect(high.linkStrengthMultiplier).toBe(6.7); + expect(low.repelStrength).toBe(-6); + expect(high.repelStrength).toBe(-90); + expect(low.sizeDistanceMultiplier).toBe(1); + expect(high.sizeDistanceMultiplier).toBeCloseTo(2.704); + expect(low.sizeMultiplier).toBe(0.75); + expect(high.sizeMultiplier).toBe(3.9); + expect(low.sizeRepelMultiplier).toBe(1); + expect(high.sizeRepelMultiplier).toBeCloseTo(5.386); + }); + + it('makes the public slider extremes visibly stronger while preserving readable defaults', () => { + const loose = normalizeForceSettings({ + ...defaultForceNodeSettings, + distance: 0, + link: 0, + repel: 0, + size: 0, + }); + const dramatic = normalizeForceSettings({ + ...defaultForceNodeSettings, + distance: 100, + link: 100, + repel: 100, + size: 100, + }); + + expect(dramatic.distanceMultiplier).toBeGreaterThanOrEqual(loose.distanceMultiplier * 5); + expect(dramatic.linkStrengthMultiplier).toBeGreaterThanOrEqual(6); + expect(Math.abs(dramatic.repelStrength)).toBeGreaterThanOrEqual(Math.abs(loose.repelStrength) * 15); + expect(dramatic.sizeDistanceMultiplier).toBeGreaterThanOrEqual(2); + expect(dramatic.sizeMultiplier).toBeGreaterThanOrEqual(loose.sizeMultiplier * 5); + expect(dramatic.sizeRepelMultiplier).toBeGreaterThanOrEqual(4); + }); +}); diff --git a/apps/web/app/home/forceNodeField/settings.tsx b/apps/web/app/home/forceNodeField/settings.tsx new file mode 100644 index 000000000..e36b948e4 --- /dev/null +++ b/apps/web/app/home/forceNodeField/settings.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'; + +export type ForceSettingKey = 'center' | 'distance' | 'link' | 'repel' | 'size'; + +export type ForceNodeSettings = Record; + +export type NormalizedForceSettings = { + centerPullMultiplier: number; + centerStrength: number; + collisionIterations: number; + collisionPadding: number; + distanceMultiplier: number; + linkDistancePadding: number; + linkStrengthMultiplier: number; + repelStrength: number; + sizeDistanceMultiplier: number; + sizeMultiplier: number; + sizeRepelMultiplier: number; +}; + +export const defaultForceNodeSettings: ForceNodeSettings = { + center: 50, + distance: 50, + link: 50, + repel: 50, + size: 50, +}; + +type ForceNodeSettingsContextValue = { + normalizedSettings: NormalizedForceSettings; + resetSettings: () => void; + settings: ForceNodeSettings; + updateSetting: (key: ForceSettingKey, value: number) => void; +}; + +const ForceNodeSettingsContext = createContext(null); + +function clampSliderValue(value: number): number { + return Math.max(0, Math.min(100, Math.round(value))); +} + +function scaleAroundDefault( + value: number, + { + defaultOutput, + defaultValue, + max, + min, + }: { + defaultOutput: number; + defaultValue: number; + max: number; + min: number; + }, +): number { + const clampedValue = clampSliderValue(value); + + if (clampedValue <= defaultValue) { + return min + (clampedValue / defaultValue) * (defaultOutput - min); + } + + return defaultOutput + ((clampedValue - defaultValue) / (100 - defaultValue)) * (max - defaultOutput); +} + +export function normalizeForceSettings(settings: ForceNodeSettings): NormalizedForceSettings { + const distanceSpacingMultiplier = scaleAroundDefault(settings.distance, { + defaultOutput: 1, + defaultValue: defaultForceNodeSettings.distance, + max: 1.25, + min: 0.25, + }); + const sizeMultiplier = scaleAroundDefault(settings.size, { + defaultOutput: 1, + defaultValue: defaultForceNodeSettings.size, + max: 3.9, + min: 0.75, + }); + const sizeGrowth = Math.max(0, sizeMultiplier - 1); + + return { + centerPullMultiplier: scaleAroundDefault(settings.center, { + defaultOutput: 1.045, + defaultValue: defaultForceNodeSettings.center, + max: 1.65, + min: 0.55, + }), + centerStrength: scaleAroundDefault(settings.center, { + defaultOutput: 0.025, + defaultValue: defaultForceNodeSettings.center, + max: 0.046, + min: 0.008, + }), + collisionIterations: Math.round(2 + sizeGrowth * 2.4), + collisionPadding: 4 + sizeGrowth * 8 * distanceSpacingMultiplier, + distanceMultiplier: scaleAroundDefault(settings.distance, { + defaultOutput: 1, + defaultValue: defaultForceNodeSettings.distance, + max: 2, + min: 0.35, + }), + linkDistancePadding: 4 + sizeGrowth * 2 * distanceSpacingMultiplier, + linkStrengthMultiplier: scaleAroundDefault(settings.link, { + defaultOutput: 1, + defaultValue: defaultForceNodeSettings.link, + max: 6.7, + min: 0, + }), + repelStrength: -scaleAroundDefault(settings.repel, { + defaultOutput: 18, + defaultValue: defaultForceNodeSettings.repel, + max: 90, + min: 6, + }), + sizeDistanceMultiplier: 1 + sizeGrowth * (0.15 + distanceSpacingMultiplier * 0.35), + sizeMultiplier, + sizeRepelMultiplier: 1 + sizeGrowth * (0.7 + distanceSpacingMultiplier * 0.65), + }; +} + +export function ForceNodeSettingsProvider({ children }: { children: ReactNode }): React.ReactElement { + const [settings, setSettings] = useState(defaultForceNodeSettings); + const normalizedSettings = useMemo(() => normalizeForceSettings(settings), [settings]); + const value = useMemo( + () => ({ + normalizedSettings, + resetSettings: () => { + setSettings(defaultForceNodeSettings); + }, + settings, + updateSetting: (key, nextValue) => { + setSettings(current => ({ + ...current, + [key]: clampSliderValue(nextValue), + })); + }, + }), + [normalizedSettings, settings], + ); + + return ( + + {children} + + ); +} + +export function useForceNodeSettings(): ForceNodeSettingsContextValue { + const context = useContext(ForceNodeSettingsContext); + + if (context === null) { + throw new Error('useForceNodeSettings must be used inside ForceNodeSettingsProvider'); + } + + return context; +} diff --git a/apps/web/app/home/forceNodeField/view.test.tsx b/apps/web/app/home/forceNodeField/view.test.tsx new file mode 100644 index 000000000..366592526 --- /dev/null +++ b/apps/web/app/home/forceNodeField/view.test.tsx @@ -0,0 +1,125 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ForceNodeControls } from './controls'; +import { ForceNodeSettingsProvider } from './settings'; +import { ForceNodeField } from './view'; + +function installDesktopMatchMedia(): () => void { + const originalMatchMedia = window.matchMedia; + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: (query: string) => ({ + addEventListener, + addListener: addEventListener, + dispatchEvent: vi.fn(), + matches: query.includes('min-width') || query.includes('pointer: fine'), + media: query, + onchange: null, + removeEventListener, + removeListener: removeEventListener, + }), + }); + + return () => { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: originalMatchMedia, + }); + }; +} + +function installWideCoarsePointerMatchMedia(): () => void { + const originalMatchMedia = window.matchMedia; + const addEventListener = vi.fn(); + const removeEventListener = vi.fn(); + + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: (query: string) => ({ + addEventListener, + addListener: addEventListener, + dispatchEvent: vi.fn(), + matches: query.includes('min-width') && !query.includes('pointer: fine'), + media: query, + onchange: null, + removeEventListener, + removeListener: removeEventListener, + }), + }); + + return () => { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: originalMatchMedia, + }); + }; +} + +function firstRenderedNodeRadius(): number { + const circle = document.querySelector('[data-testid="force-node-field"] > g > g > circle'); + + if (circle === null) { + throw new Error('expected a rendered force node'); + } + + return Number(circle.getAttribute('r')); +} + +describe('ForceNodeField', () => { + it('stays disabled when desktop pointer media is unavailable', () => { + render( + + + , + ); + + expect(screen.queryByTestId('force-node-field')).not.toBeInTheDocument(); + }); + + it('renders on wide screens even when the browser does not report a fine pointer', async () => { + const restoreMatchMedia = installWideCoarsePointerMatchMedia(); + + try { + render( + +
+ +
+
, + ); + + expect(await screen.findByTestId('force-node-field')).toBeInTheDocument(); + } finally { + restoreMatchMedia(); + } + }); + + it('updates rendered node size from the shared hero controls', async () => { + const restoreMatchMedia = installDesktopMatchMedia(); + + try { + render( + +
+ + +
+
, + ); + + await screen.findByTestId('force-node-field'); + const initialRadius = firstRenderedNodeRadius(); + + fireEvent.change(screen.getByRole('slider', { name: 'Node size' }), { target: { value: '100' } }); + + await waitFor(() => { + expect(firstRenderedNodeRadius()).toBeGreaterThan(initialRadius); + }); + } finally { + restoreMatchMedia(); + } + }); +}); diff --git a/apps/web/app/home/forceNodeField/view.tsx b/apps/web/app/home/forceNodeField/view.tsx new file mode 100644 index 000000000..359f325cf --- /dev/null +++ b/apps/web/app/home/forceNodeField/view.tsx @@ -0,0 +1,571 @@ +'use client'; + +import { + forceCenter, + forceCollide, + forceLink, + forceManyBody, + forceSimulation, + forceX, + forceY, + type ForceCenter, + type ForceCollide, + type ForceLink, + type ForceManyBody, + type ForceX, + type ForceY, + type Simulation, +} from 'd3-force'; +import { useEffect, useId, useRef, useState } from 'react'; +import { + EDGE_FEATHER, + EDGE_NODE_GAP, + createForceEdges, + createForceNodes, + createVisibleEdgeSegment, + forceNodeCollisionRadius, + getForceLinkDistance, + limitForceNodeVelocity, + readForceSectionRects, + separateOverlappingForceNodes, + type ForceEdge, + type ForceNode, + type MaskRect, + type Viewport, +} from './model'; +import { useForceNodeSettings } from './settings'; + +function supportsDesktopMotion(): boolean { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return false; + } + + return window.matchMedia('(min-width: 768px)').matches + && !window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +function getViewport(): Viewport { + return { + height: window.innerHeight, + width: window.innerWidth, + }; +} + +function sameViewport(left: Viewport | null, right: Viewport): boolean { + return left?.height === right.height && left.width === right.width; +} + +function resolveEdgeEndpoint(endpoint: ForceEdge['source'], nodes: ForceNode[]): ForceNode | undefined { + if (typeof endpoint === 'object') { + return endpoint; + } + + return nodes.find(node => node.id === Number(endpoint)); +} + +function scaledNodeRadius(node: ForceNode, sizeMultiplier: number): number { + return node.radius * sizeMultiplier; +} + +function getVelocityLimit(sizeMultiplier: number): number { + return Math.max(0.58, 2.1 / Math.sqrt(sizeMultiplier)); +} + +export function ForceNodeField(): React.ReactElement | null { + const maskId = useId().replace(/:/g, ''); + const { normalizedSettings } = useForceNodeSettings(); + const [edges, setEdges] = useState([]); + const [isEnabled, setIsEnabled] = useState(false); + const [maskRects, setMaskRects] = useState([]); + const [nodes, setNodes] = useState([]); + const [renderSizeMultiplier, setRenderSizeMultiplier] = useState(normalizedSettings.sizeMultiplier); + const [viewport, setViewport] = useState(null); + const [, setFrame] = useState(0); + const centerForceRef = useRef | null>(null); + const chargeForceRef = useRef | null>(null); + const collisionForceRef = useRef | null>(null); + const linkForceRef = useRef | null>(null); + const simulationRef = useRef | null>(null); + const xForceRef = useRef | null>(null); + const yForceRef = useRef | null>(null); + const pointerTargetRef = useRef({ x: 0, y: 0 }); + const tickFrameRef = useRef(null); + const calmTimeoutRef = useRef(null); + const normalizedSettingsRef = useRef(normalizedSettings); + + normalizedSettingsRef.current = normalizedSettings; + + useEffect(() => { + if (typeof window.matchMedia !== 'function') { + return; + } + + const desktopQuery = window.matchMedia('(min-width: 768px)'); + const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); + const updateEnabled = (): void => { + setIsEnabled(desktopQuery.matches && !reducedMotionQuery.matches); + }; + + updateEnabled(); + desktopQuery.addEventListener('change', updateEnabled); + reducedMotionQuery.addEventListener('change', updateEnabled); + + return () => { + desktopQuery.removeEventListener('change', updateEnabled); + reducedMotionQuery.removeEventListener('change', updateEnabled); + }; + }, []); + + useEffect(() => { + if (!isEnabled) { + setMaskRects([]); + setViewport(null); + return; + } + + let pendingFrame: number | null = null; + + const updateGeometry = (): void => { + pendingFrame = null; + const nextViewport = getViewport(); + + setViewport(current => (sameViewport(current, nextViewport) ? current : nextViewport)); + setMaskRects(readForceSectionRects(nextViewport)); + }; + + const scheduleGeometryUpdate = (): void => { + if (pendingFrame !== null) { + return; + } + + pendingFrame = window.requestAnimationFrame(updateGeometry); + }; + + updateGeometry(); + window.addEventListener('resize', scheduleGeometryUpdate); + window.addEventListener('scroll', scheduleGeometryUpdate, { passive: true }); + + return () => { + if (pendingFrame !== null) { + window.cancelAnimationFrame(pendingFrame); + } + + window.removeEventListener('resize', scheduleGeometryUpdate); + window.removeEventListener('scroll', scheduleGeometryUpdate); + }; + }, [isEnabled]); + + useEffect(() => { + if (!isEnabled || viewport === null) { + simulationRef.current?.stop(); + simulationRef.current = null; + setEdges([]); + setNodes([]); + return; + } + + const forceNodes = createForceNodes(viewport); + const forceEdges = createForceEdges(forceNodes); + pointerTargetRef.current = { + x: viewport.width * 0.68, + y: viewport.height * 0.28, + }; + setEdges(forceEdges); + setNodes(forceNodes); + + const centerForce = forceCenter(pointerTargetRef.current.x, pointerTargetRef.current.y).strength(0.025); + const linkForce = forceLink(forceEdges) + .id(node => node.id) + .distance(edge => edge.distance) + .strength(edge => edge.strength); + const chargeForce = forceManyBody().strength(-18); + const collisionForce = forceCollide().radius(node => node.radius + 4).iterations(2); + const xForce = forceX(pointerTargetRef.current.x).strength(node => node.pull); + const yForce = forceY(pointerTargetRef.current.y).strength(node => node.pull); + centerForceRef.current = centerForce; + chargeForceRef.current = chargeForce; + collisionForceRef.current = collisionForce; + linkForceRef.current = linkForce; + xForceRef.current = xForce; + yForceRef.current = yForce; + + const simulation = forceSimulation(forceNodes) + .alpha(0.72) + .alphaTarget(0.035) + .velocityDecay(0.31) + .force('center', centerForce) + .force('charge', chargeForce) + .force('collision', collisionForce) + .force('link', linkForce) + .force('x', xForce) + .force('y', yForce) + .on('tick', () => { + const currentSettings = normalizedSettingsRef.current; + const currentNodes = simulation.nodes(); + + separateOverlappingForceNodes( + currentNodes, + { + collisionPadding: currentSettings.collisionPadding, + sizeMultiplier: currentSettings.sizeMultiplier, + }, + currentSettings.collisionIterations, + ); + currentNodes.forEach(node => { + limitForceNodeVelocity(node, getVelocityLimit(currentSettings.sizeMultiplier)); + }); + + if (tickFrameRef.current !== null) { + return; + } + + tickFrameRef.current = window.requestAnimationFrame(() => { + tickFrameRef.current = null; + setFrame(frame => frame + 1); + }); + }); + + simulationRef.current = simulation; + + return () => { + simulation.stop(); + simulationRef.current = null; + centerForceRef.current = null; + chargeForceRef.current = null; + collisionForceRef.current = null; + linkForceRef.current = null; + xForceRef.current = null; + yForceRef.current = null; + + if (tickFrameRef.current !== null) { + window.cancelAnimationFrame(tickFrameRef.current); + tickFrameRef.current = null; + } + }; + }, [isEnabled, viewport]); + + useEffect(() => { + if (!isEnabled || viewport === null) { + return; + } + + const simulation = simulationRef.current; + + if (simulation === null) { + return; + } + + centerForceRef.current?.strength(normalizedSettings.centerStrength); + chargeForceRef.current?.strength(normalizedSettings.repelStrength * normalizedSettings.sizeRepelMultiplier); + collisionForceRef.current + ?.radius(node => forceNodeCollisionRadius(node, normalizedSettings)) + .iterations(normalizedSettings.collisionIterations); + linkForceRef.current + ?.distance(edge => { + const source = resolveEdgeEndpoint(edge.source, nodes); + const target = resolveEdgeEndpoint(edge.target, nodes); + if (source === undefined || target === undefined) { + return edge.distance * normalizedSettings.distanceMultiplier * normalizedSettings.sizeDistanceMultiplier; + } + + return getForceLinkDistance(edge.distance, source, target, normalizedSettings); + }) + .strength(edge => edge.strength * normalizedSettings.linkStrengthMultiplier); + xForceRef.current?.strength(node => node.pull * normalizedSettings.centerPullMultiplier); + yForceRef.current?.strength(node => node.pull * normalizedSettings.centerPullMultiplier); + separateOverlappingForceNodes( + simulation.nodes(), + { + collisionPadding: normalizedSettings.collisionPadding, + sizeMultiplier: normalizedSettings.sizeMultiplier, + }, + normalizedSettings.collisionIterations, + ); + + simulation.alpha(Math.max(simulation.alpha(), 0.48)).alphaTarget(0.18).restart(); + + const calmTimeout = window.setTimeout(() => { + if (simulationRef.current === simulation) { + simulation.alphaTarget(0.035); + } + }, 520); + + return () => { + window.clearTimeout(calmTimeout); + }; + }, [ + isEnabled, + normalizedSettings.centerPullMultiplier, + normalizedSettings.centerStrength, + normalizedSettings.collisionIterations, + normalizedSettings.collisionPadding, + normalizedSettings.distanceMultiplier, + normalizedSettings.linkStrengthMultiplier, + normalizedSettings.repelStrength, + normalizedSettings.sizeDistanceMultiplier, + normalizedSettings.sizeMultiplier, + normalizedSettings.sizeRepelMultiplier, + nodes, + viewport, + ]); + + useEffect(() => { + if (!isEnabled) { + setRenderSizeMultiplier(normalizedSettings.sizeMultiplier); + return; + } + + let frame: number | null = null; + const targetSize = normalizedSettings.sizeMultiplier; + + const stepTowardTarget = (): void => { + setRenderSizeMultiplier(currentSize => { + if (targetSize <= currentSize) { + return targetSize; + } + + const nextSize = Math.min(targetSize, currentSize + Math.max(0.035, (targetSize - currentSize) * 0.04)); + + if (nextSize < targetSize) { + frame = window.requestAnimationFrame(stepTowardTarget); + } + + return nextSize; + }); + }; + + frame = window.requestAnimationFrame(stepTowardTarget); + + return () => { + if (frame !== null) { + window.cancelAnimationFrame(frame); + } + }; + }, [isEnabled, normalizedSettings.sizeMultiplier]); + + useEffect(() => { + if (!isEnabled) { + return; + } + + const handleMouseMove = (event: MouseEvent): void => { + pointerTargetRef.current = { + x: event.clientX, + y: event.clientY, + }; + centerForceRef.current?.x(event.clientX).y(event.clientY); + xForceRef.current?.x(event.clientX); + yForceRef.current?.y(event.clientY); + + const simulation = simulationRef.current; + + if (simulation === null) { + return; + } + + simulation.nodes().forEach((node, index) => { + const currentSettings = normalizedSettingsRef.current; + const mouseImpulse = 0.22 / Math.sqrt(currentSettings.sizeMultiplier); + + node.vx = (node.vx ?? 0) + Math.sin(event.clientY * 0.018 + index) * mouseImpulse; + node.vy = (node.vy ?? 0) + Math.cos(event.clientX * 0.018 + index) * mouseImpulse; + limitForceNodeVelocity(node, getVelocityLimit(currentSettings.sizeMultiplier)); + }); + separateOverlappingForceNodes( + simulation.nodes(), + { + collisionPadding: normalizedSettingsRef.current.collisionPadding, + sizeMultiplier: normalizedSettingsRef.current.sizeMultiplier, + }, + normalizedSettingsRef.current.collisionIterations, + ); + + simulation.alphaTarget(0.2).restart(); + + if (calmTimeoutRef.current !== null) { + window.clearTimeout(calmTimeoutRef.current); + } + + calmTimeoutRef.current = window.setTimeout(() => { + simulation.alphaTarget(0.035); + }, 360); + }; + + window.addEventListener('mousemove', handleMouseMove, { passive: true }); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + + if (calmTimeoutRef.current !== null) { + window.clearTimeout(calmTimeoutRef.current); + calmTimeoutRef.current = null; + } + }; + }, [isEnabled]); + + if (!isEnabled || viewport === null || !supportsDesktopMotion()) { + return null; + } + + const maskElementId = `${maskId}-force-mask`; + const blurElementId = `${maskId}-force-mask-blur`; + const edgeCutoutMaskId = `${maskId}-edge-cutout-mask`; + const viewportFadeMaskId = `${maskId}-viewport-fade-mask`; + const topFadeId = `${maskId}-top-edge-fade`; + const bottomFadeId = `${maskId}-bottom-edge-fade`; + const leftFadeId = `${maskId}-left-edge-fade`; + const rightFadeId = `${maskId}-right-edge-fade`; + const viewportEdgeFeather = Math.min(120, viewport.width * 0.1, viewport.height * 0.18); + + return ( + + ); +} diff --git a/apps/web/app/home/view.test.tsx b/apps/web/app/home/view.test.tsx new file mode 100644 index 000000000..aadad2bf7 --- /dev/null +++ b/apps/web/app/home/view.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { supportedLanguages } from './content'; +import { HomeView } from './view'; + +describe('CodeGraphy website home page', () => { + it('starts from the selected light website direction without design-lab chrome', () => { + render(); + + expect(screen.getByRole('heading', { level: 1, name: 'See your code connect.' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Play with the graph.' })).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: 'Center' })).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: 'Repel' })).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: 'Distance' })).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: 'Link force' })).toBeInTheDocument(); + expect(screen.getByRole('slider', { name: 'Node size' })).toBeInTheDocument(); + expect(screen.getAllByRole('link', { name: /Install CodeGraphy/i })).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + href: 'https://marketplace.visualstudio.com/items?itemName=codegraphy.codegraphy', + }), + ]), + ); + expect(screen.getAllByRole('link', { name: /GitHub/i })[0]).toHaveAttribute( + 'href', + 'https://github.com/joesobo/CodeGraphyV4', + ); + expect(screen.getByText('Example graphs')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Features.' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Open examples on GitHub' })).toHaveAttribute( + 'href', + 'https://github.com/joesobo/CodeGraphyV4/tree/main/examples', + ); + expect(screen.queryByText('Language examples')).not.toBeInTheDocument(); + expect(screen.queryByText('Click a language to open its example workspace on GitHub.')).not.toBeInTheDocument(); + expect(screen.getByText('Supported languages')).toBeInTheDocument(); + supportedLanguages.forEach(language => { + const links = screen.getAllByRole('link', { name: `${language.label} example` }); + + expect(links).toHaveLength(2); + links.forEach(link => { + expect(link).toHaveAttribute('href', language.exampleHref); + }); + }); + expect(screen.getByText('Quickstart')).toBeInTheDocument(); + expect(screen.getByText('Starting path')).toBeInTheDocument(); + expect(screen.getByText('@codegraphy-dev/plugin-typescript')).toBeInTheDocument(); + expect(screen.getByText('Is CodeGraphy open source?')).toBeInTheDocument(); + expect(screen.getByText('Where do I report bugs or contribute?')).toBeInTheDocument(); + expect(screen.queryByText('GitHub repo')).not.toBeInTheDocument(); + expect(screen.queryByText('Nearby nodes naturally become the places you are already working.')).not.toBeInTheDocument(); + expect(screen.queryByText('Scan your codebase to create a local map of relationships.')).not.toBeInTheDocument(); + expect(screen.queryByText('$5/mo')).not.toBeInTheDocument(); + expect(screen.queryByText('Do I need an account?')).not.toBeInTheDocument(); + expect(screen.queryByText('How do I use CodeGraphy?')).not.toBeInTheDocument(); + expect(screen.queryByText('PROTOTYPE')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/home/view.tsx b/apps/web/app/home/view.tsx new file mode 100644 index 000000000..44a665a6b --- /dev/null +++ b/apps/web/app/home/view.tsx @@ -0,0 +1,431 @@ +import { Button } from '../_ui/button'; +import { Card } from '../_ui/card'; +import { GitHubIcon, VsCodeIcon } from '../_ui/icons'; +import { SiteFooter } from '../_site/footer'; +import { SiteHeader } from '../_site/header'; +import { + faqItems, + githubHref, + installHref, + optionalPackages, + socialProofItems, + supportedLanguages, + workflowSteps, +} from './content'; +import { FeatureTour } from './featureTour/view'; +import { ForceNodeControls } from './forceNodeField/controls'; +import { ForceNodeSettingsProvider } from './forceNodeField/settings'; +import { ForceNodeField } from './forceNodeField/view'; +import type { FaqAnswerBlock, FaqTextPart } from './content'; + +function renderFaqText(parts: FaqTextPart[], keyPrefix: string): React.ReactNode { + return parts.map((part, index) => { + if (typeof part === 'string') { + return part; + } + + if (part.kind === 'code') { + return ( + + {part.text} + + ); + } + + return ( + + {part.text} + + ); + }); +} + +function FaqAnswer({ blocks }: { blocks: FaqAnswerBlock[] }): React.ReactElement { + return ( +
+ {blocks.map((block, blockIndex) => { + if (block.type === 'paragraph') { + return ( +

+ {renderFaqText(block.parts, `paragraph-${blockIndex}`)} +

+ ); + } + + if (block.type === 'list') { + return ( +
+ {block.label ?

{block.label}

: null} +
    + {block.items.map((item, itemIndex) => ( +
  • + {renderFaqText(item, `list-${blockIndex}-${itemIndex}`)} +
  • + ))} +
+
+ ); + } + + if (block.type === 'code') { + return ( +
+              {block.code}
+            
+ ); + } + + if (block.type === 'image') { + return ( +
+ {block.alt} +
{block.caption}
+
+ ); + } + + return ( +
+ {block.label ?

{block.label}

: null} +
+ {block.links.map(link => ( + + {link.text} + + ))} +
+
+ ); + })} +
+ ); +} + +function SupportedLanguageIcon({ + iconPath, +}: { + iconPath: string; +}): React.ReactElement { + return ( + + ); +} + +function SupportedLanguageMarquee(): React.ReactElement { + const languageRows = [supportedLanguages, supportedLanguages]; + + return ( +
+

Supported languages

+
+
+ {languageRows.map((languages, rowIndex) => ( +
+ {languages.map(language => ( + + + {language.label} + + ))} +
+ ))} +
+
+
+ ); +} + +export function HomeView(): React.ReactElement { + return ( + <> + +
+ + +
+
+
+
+
+

+ See your code connect. +

+

+ CodeGraphy turns a codebase into a visual graph, allowing you to navigate, understand, and change your code by its actual shape instead of hidden folder structures. +

+ + + + Open source on GitHub + +
+ +
+
+ +
+
+
+

Problem

+

+ Codebases drift faster than folders can explain them. +

+
+
+

+ Renaming, splitting, and reorganizing code gets risky when the real dependencies are buried across imports, symbols, packages, and plugin conventions. +

+

+ CodeGraphy shows the shape that already exists, so you can change structure with more confidence. +

+
+
+
+ + + +
+
+
+
+

Example graphs

+

+ Open repos from above. +

+
+

+ Explore any repo with CodeGraphy to see the real shape of the code. Below are graphs of popular open source projects created by the community. +

+
+
+ {socialProofItems.map(item => { + const Icon = item.icon; + + return ( + + +
+
+ + + +

{item.title}

+
+ + + View repo + +
+
+ ); + })} +
+
+
+ +
+
+
+
+

How it works

+

+ Code wants to form its own connections. +

+
+

+ Instead of arbitrary categories, CodeGraphy leans into spatial awareness. Nearby nodes are naturally more relevant to what you are working on, and distant groups show where systems are pulling apart internally. +

+
+
+
+ +
+

Local relationship map

+

+ CodeGraphy can show you only the code relevant to your current work, and the relationships between them, without needing to understand the whole codebase at once. +

+
+
+
+ {workflowSteps.map(step => { + const Icon = step.icon; + + return ( +
+ + + +
+

{step.title}

+

{step.description}

+
+
+ ); + })} +
+
+
+
+ +
+
+

Private plugins

+

+ Paid plugins for focused workflows. +

+

+ Private plugins are optional monthly plugins layered on top of the open core. +

+
+ {optionalPackages.map(plan => ( + +
+

{plan.name}

+

{plan.description}

+ +
+
+
    + {plan.features.map(feature => { + const Icon = feature.icon; + + return ( +
  • + + {feature.text} +
  • + ); + })} +
+
+ {plan.screenshots.map(screenshot => ( +
+ +
+ {screenshot.title} +
+
+ ))} +
+
+
+ ))} +
+
+
+ +
+
+

FAQ

+

+ Questions worth answering. +

+
+ {faqItems.map(item => ( +
+ + {item.question} + + + + + + +
+ ))} +
+
+
+ +
+
+
+
+

Start graphing

+

+ Map the repo in front of you. +

+

+ Install the extension, open a project, and see the connections already inside the code. +

+ +
+
+ +
+
+
+
+ +
+
+ + ); +} diff --git a/apps/web/app/icon.svg b/apps/web/app/icon.svg new file mode 100644 index 000000000..4603a8379 --- /dev/null +++ b/apps/web/app/icon.svg @@ -0,0 +1,6 @@ + + + diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 000000000..9f4ca64e3 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'CodeGraphy', + description: 'CodeGraphy turns VS Code workspaces into local Relationship Graphs.', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>): React.ReactElement { + return ( + + {children} + + ); +} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx new file mode 100644 index 000000000..b3b15b6f6 --- /dev/null +++ b/apps/web/app/login/page.tsx @@ -0,0 +1,5 @@ +import { AuthView } from '../auth/view'; + +export default function LoginPage(): React.ReactElement { + return ; +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 000000000..fb93d60c0 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,5 @@ +import { HomeView } from './home/view'; + +export default function Home(): React.ReactElement { + return ; +} diff --git a/apps/web/app/signup/page.tsx b/apps/web/app/signup/page.tsx new file mode 100644 index 000000000..0e73b92d0 --- /dev/null +++ b/apps/web/app/signup/page.tsx @@ -0,0 +1,5 @@ +import { AuthView } from '../auth/view'; + +export default function SignupPage(): React.ReactElement { + return ; +} diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 000000000..2222e754f --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.cjs", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/app", + "utils": "@/app/_ui/cn", + "ui": "@/app/_ui" + } +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 000000000..9edff1c7c --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 000000000..d87dd8d15 --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,13 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appDir = path.dirname(fileURLToPath(import.meta.url)); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + turbopack: { + root: path.resolve(appDir, '../..'), + }, +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 3f225876a..a81bc195b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,24 +1,44 @@ { "name": "@codegraphy/web", "version": "0.1.0", - "description": "CodeGraphy account, subscription, billing, and access web app", + "description": "CodeGraphy marketing, account, subscription, billing, and access web app", "private": true, "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - } - }, "engines": { "node": ">=20" }, "scripts": { - "build": "tsc -p tsconfig.build.json", + "dev": "next dev", + "build": "next build", + "start": "next start", "test": "vitest run", - "lint": "eslint \"src/**/*.ts\" \"tests/**/*.ts\"", - "typecheck": "tsc --noEmit -p tsconfig.json" + "lint": "eslint \"app/**/*.{ts,tsx}\" \"src/**/*.ts\" \"tests/**/*.ts\" \"vitest.config.ts\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.2.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "d3-force": "^3.0.0", + "lucide-react": "1.16.0", + "next": "16.2.6", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "simple-icons": "^16.21.0", + "tailwind-merge": "^3.4.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.2.0", + "@testing-library/react": "^14.1.2", + "@types/d3-force": "^3.0.10", + "@types/node": "^20.11.0", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "autoprefixer": "^10.4.17", + "jsdom": "^25.0.0", + "postcss": "^8.4.33", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vitest": "^3.0.0" } } diff --git a/apps/web/postcss.config.cjs b/apps/web/postcss.config.cjs new file mode 100644 index 000000000..12a703d90 --- /dev/null +++ b/apps/web/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/public/codegraphy-icon.svg b/apps/web/public/codegraphy-icon.svg new file mode 100644 index 000000000..4603a8379 --- /dev/null +++ b/apps/web/public/codegraphy-icon.svg @@ -0,0 +1,6 @@ + + + diff --git a/apps/web/public/product-media/codegraphy-architecture.png b/apps/web/public/product-media/codegraphy-architecture.png new file mode 100644 index 000000000..417175071 Binary files /dev/null and b/apps/web/public/product-media/codegraphy-architecture.png differ diff --git a/apps/web/public/product-media/graph-sections.png b/apps/web/public/product-media/graph-sections.png new file mode 100644 index 000000000..47fa7098f Binary files /dev/null and b/apps/web/public/product-media/graph-sections.png differ diff --git a/apps/web/public/product-media/hero-relationship-graph.png b/apps/web/public/product-media/hero-relationship-graph.png new file mode 100644 index 000000000..978ba2784 Binary files /dev/null and b/apps/web/public/product-media/hero-relationship-graph.png differ diff --git a/apps/web/public/product-media/large-repo-graph.png b/apps/web/public/product-media/large-repo-graph.png new file mode 100644 index 000000000..95548d556 Binary files /dev/null and b/apps/web/public/product-media/large-repo-graph.png differ diff --git a/apps/web/public/product-media/plugins-panel.png b/apps/web/public/product-media/plugins-panel.png new file mode 100644 index 000000000..ec63253b5 Binary files /dev/null and b/apps/web/public/product-media/plugins-panel.png differ diff --git a/apps/web/public/product-media/relationship-graph-2d.png b/apps/web/public/product-media/relationship-graph-2d.png new file mode 100644 index 000000000..e0c8f8f78 Binary files /dev/null and b/apps/web/public/product-media/relationship-graph-2d.png differ diff --git a/apps/web/public/product-media/relationship-graph-3d.png b/apps/web/public/product-media/relationship-graph-3d.png new file mode 100644 index 000000000..b79244526 Binary files /dev/null and b/apps/web/public/product-media/relationship-graph-3d.png differ diff --git a/apps/web/public/product-media/search-filter-panel.png b/apps/web/public/product-media/search-filter-panel.png new file mode 100644 index 000000000..3cb300163 Binary files /dev/null and b/apps/web/public/product-media/search-filter-panel.png differ diff --git a/apps/web/public/product-media/symbol-nodes-graph.png b/apps/web/public/product-media/symbol-nodes-graph.png new file mode 100644 index 000000000..b95a5e69a Binary files /dev/null and b/apps/web/public/product-media/symbol-nodes-graph.png differ diff --git a/apps/web/tailwind.config.cjs b/apps/web/tailwind.config.cjs new file mode 100644 index 000000000..5070fe674 --- /dev/null +++ b/apps/web/tailwind.config.cjs @@ -0,0 +1,41 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./app/**/*.{ts,tsx}'], + theme: { + extend: { + colors: { + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'hsl(var(--primary))', + foreground: 'hsl(var(--primary-foreground))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + }, + borderRadius: { + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + }, + }, + plugins: [], +}; diff --git a/apps/web/tests/setup.ts b/apps/web/tests/setup.ts new file mode 100644 index 000000000..0d74b7352 --- /dev/null +++ b/apps/web/tests/setup.ts @@ -0,0 +1,7 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => { + cleanup(); +}); diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 35af928bb..abb476930 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,5 +1,42 @@ { - "extends": "../../tsconfig.plugins.json", - "include": ["src/**/*.ts", "tests/**/*.ts"], - "exclude": ["node_modules", "dist"] + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@/*": [ + "./*" + ] + }, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 000000000..fa7d995a8 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + setupFiles: ['./tests/setup.ts'], + }, +}); diff --git a/docs/media/website/saas-landing-page-structure-reference.png b/docs/media/website/saas-landing-page-structure-reference.png new file mode 100644 index 000000000..0a4c217bc Binary files /dev/null and b/docs/media/website/saas-landing-page-structure-reference.png differ diff --git a/docs/plans/2026-05-26-codegraphy-website.md b/docs/plans/2026-05-26-codegraphy-website.md new file mode 100644 index 000000000..caaee5d7b --- /dev/null +++ b/docs/plans/2026-05-26-codegraphy-website.md @@ -0,0 +1,437 @@ +# CodeGraphy Website Plan + +## Source + +- Trello: [CodeGraphy Website](https://trello.com/c/xnumixTi/142-codegraphy-website) +- Trello card id: #142 +- Related Trello card: [Extract Pro](https://trello.com/c/x2WvUEPs/141-extract-pro) +- Card labels at time of synthesis: Website, Pro +- Trello last activity at time of synthesis: 2026-05-26T19:07:02.989Z +- Domain language source: root `CONTEXT.md` +- Structure reference: [SaaS landing page structure image](../media/website/saas-landing-page-structure-reference.png), copied from the Trello card attachment. + +This doc consolidates the website card into repo-native planning language. It is the working source for the website package, landing-page direction, launch scope, and follow-up grill questions. + +## Goal + +Create the CodeGraphy website as its own app in the monorepo. The website should explain CodeGraphy clearly, support free account signup and CodeGraphy Pro package flows, and give the VS Code Extension a place to send users for login, pricing, checkout, account management, and entitlement status. + +The first public launch should not be marketing-only. The website can be built incrementally, but public launch requires the landing page, pricing, FAQ, account, billing, and VS Code handoff path to be coherent enough to install, sign in, start a trial, manage a subscription, and report entitlement state back to the extension. + +## Product Language + +Use existing CodeGraphy terms: + +- **Relationship Graph**, not dependency graph, for the main product concept. +- **CodeGraphy Workspace** or workspace when the product scope includes code, Markdown, docs, and plugin-defined concepts. +- **Graph Cache** for `.codegraphy/graph.lbug`. +- **Graph Query** for agent/query access, but do not make Graph Query/MCP a main launch-gallery demo. +- **CodeGraphy Account** for the signed-in website account. +- **Free Account** for a CodeGraphy Account without an active paid Pro package. +- **CodeGraphy Pro** for the paid tier. +- **Pro Package** for paid entitlements inside CodeGraphy Pro. +- **Organize** for the first planned Pro Package. + +Avoid leading website copy with generic "visualize your codebase" language by itself. Prefer language that combines workspace maps, structure, relationships, and useful graph understanding. + +Starting copy direction: + +- Hero headline: `See how everything connects.` +- Primary hero CTA: `Install CodeGraphy Free` +- Launch pricing/final CTA after checkout exists: `Start 7-day Pro trial` +- Starter Organize pricing CTA before checkout exists: `Coming soon` +- Core story: CodeGraphy turns a local workspace into a useful Relationship Graph so users can see how files and concepts connect before they edit, explain, or ask an agent to work. + +Use `workspace` instead of `codebase` when the broader product scope matters. CodeGraphy is often used for code, but Markdown support and plugin-defined graph concepts mean the product should not be over-narrowed to source code only. + +## App Shape + +Resolved stack direction: + +- Next.js +- TypeScript +- shadcn +- Vercel-compatible hosting + +Resolved package/app path direction: + +- App path: `apps/web` +- Keep shadcn components local to `apps/web` for launch. +- Do not create a shared `packages/ui` package yet. +- Add `apps/*` to workspace configuration when implementation starts. + +Open package-name grill question: + +- The Trello card recommends `@codegraphy/web`. +- The current monorepo package convention is `@codegraphy-dev/*`. +- Recommended answer: keep `apps/web`, but use `@codegraphy-dev/web` unless there is a concrete publishing or branding reason to introduce a new npm scope. + +Do not create a separate backend package for launch. The website app owns account, billing, and entitlement backend routes until that backend grows enough to deserve its own package. In this sentence, "backend package" means a monorepo code package, not a Pro Package. + +## Ownership Boundaries + +The website card owns: + +- `apps/web` package setup. +- Landing page structure, copy, and design. +- shadcn-local website UI. +- Gallery layout and asset slots. +- Integration points to account, billing, and entitlements. +- Overall public-launch completeness. + +The related Pro/account/billing card owns: + +- Supabase Auth details. +- Stripe checkout and customer portal details. +- Trial, refund, cancellation, and offline entitlement policy. +- Entitlement API details. +- Account/status data model. + +The website still needs to expose these flows at launch; it should not duplicate the lower-level billing design if that belongs to the linked Pro work. + +## Landing Page Flow + +The Trello attachment's SaaS landing page structure is a loose guide for the starter website, not a required checklist. Use it to keep the page's conversion rhythm clear, but include only the sections that fit the agreed CodeGraphy story. + +The reference frames a typical page as: + +- Hook: Hero. +- Trust: Social proof. +- Educate: Problem/pain, how it works, and features as benefits. +- Convert: Pricing, FAQ, and final CTA. + +Use this first-launch page structure: + +1. Hero: `See how everything connects.` plus one beautiful real CodeGraphy graph visual. +2. Problem / pain: file trees hide relationships and make workspace structure hard to hold in your head. +3. How it works: index, tune, explore. +4. Gallery: curated workspace maps across CodeGraphy, Next.js, Godot Open RPG, ripgrep, and qmd. +5. Pro Organize: shown through the CodeGraphy repo itself, using launch-ready organization features. +6. Founder note: why spatial workspace maps matter. +7. Pricing: Free vs Organize, $5/month or $50/year, 7-day trial. +8. FAQ: privacy, account, billing, support, cancellation, refund, offline use, and source-code upload concerns. +9. Final CTA: repeat the main action without adding new concepts. + +Adapt the reference image's trust sections honestly. Skip dedicated Social Proof and Testimonials sections for the starter website and first launch unless real proof exists. Do not fake testimonials. A roughly 9k install count can appear as a small trust detail later if it feels natural, but it should not carry a full standalone social-proof band. + +## Launch Scope + +First public launch requires: + +- Landing page with final-enough copy and temporary or approved CodeGraphy visuals. +- Pricing section for Free and Organize. +- FAQ covering privacy, account, billing, support, cancellation, refund, offline use, and source-code upload concerns. +- Supabase auth with email/password, Google OAuth, and GitHub OAuth. +- Stripe checkout for monthly and annual Organize. +- 7-day paid trial flow. +- Stripe customer portal or equivalent subscription-management path. +- Account/status page showing signed-in user, Pro Package status, current plan, trial/subscription period, and renewal/cancel state. +- Entitlement API for Organize verification from the extension. +- VS Code login/account handoff path. + +Implementation can happen in milestones, but do not ship the public website as a pure marketing page. + +## Pricing And Account Policy + +Free: + +- Users can install the VS Code Extension and explore the Relationship Graph without an account. +- Free use should not require signing in. +- Users can also navigate to the website and create a Free Account. +- A Free Account is useful for account setup and later upgrade, but it should not be required for basic extension exploration. + +Organize: + +- $5/month per user or $50/year per user. +- 7-day trial. +- Launch copy should describe only launch-ready Organize features. +- Current likely Organize features: Bookmarks or saved graph setups once named, pinned nodes, Graph Sections, and selected polished graph exports if launch-ready. + +Account language: + +- CodeGraphy Account is the signed-in website account. +- Free Account is a CodeGraphy Account without an active paid Pro package. +- CodeGraphy Pro is the paid tier. +- Organize is the first Pro Package inside CodeGraphy Pro. +- A user becomes Pro by paying for one or more Pro Packages. + +VS Code account entry: + +- Preferred initial placement: under `Open in Editor` in the bottom-right Graph Stage Corner Controls. +- The VS Code Extension should show a subtle account icon when the user is not logged in. +- Use a small door/login-style icon rather than a prominent banner or blocking prompt. +- The unauthenticated tooltip text is `Sign in or create a free account`. +- Clicking the unauthenticated icon navigates to the website login page by default. +- After login, replace the door/login icon with a small circular profile picture or profile fallback. +- Prefer the auth provider avatar when available. +- Keep the profile picture small in the graph UI. +- For users without a provider avatar, use a deterministic generated SVG avatar instead of an initial-only colored circle. +- Recommended fallback avatar package: `boring-avatars`, using an organic variant such as `marble` or `sunset` with a restrained two-color CodeGraphy palette seeded by the account email. +- Generated fallback avatars should be deterministic from the account email for the first implementation. +- Do not add stored custom avatar state until users need profile-avatar editing or cross-provider avatar control. +- Backup package if we want a raw SVG string instead of a React component: `gradient-avatar`. +- Avoid a broad avatar system such as DiceBear for the first graph-corner implementation unless the website/account app needs the extra avatar styles too. +- The authenticated tooltip should identify the signed-in account and summarize Pro Package status. +- Authenticated tooltip format: `Account: {email}`, then active or trial Pro Packages such as `Organize: Active` or `Organize: Trial`, or `No Pro packages` when no package entitlement exists. +- Clicking the authenticated profile entry navigates to the website account page. +- The account page is where users manage their profile, account settings, and Pro Package subscriptions. +- The account entry is an affordance for account and Pro Package flows, not a requirement for using the base Relationship Graph. +- Keep it visually quieter than a callout and separated from zoom/fit/open controls with the same bar-separator treatment used between Settings and Plugins in the main graph toolbar. +- The login page should include normal login buttons plus a `Don't have an account? Sign up` action that goes to the signup page. +- The signup page should include normal signup buttons plus an `Already have an account? Log in` action that goes back to the login page. +- Login and signup pages should offer three account methods: email, Google, and GitHub. +- Email auth uses email + password for the first implementation. +- Signup should ask for email and password only; do not include confirm password in the first implementation. +- Login page should include a forgot-password link. +- Use a GitHub-style auth layout: email form first, an `or` divider, then social buttons. +- Social button order: `Continue with Google`, then `Continue with GitHub`. +- Do not include Apple in the first auth implementation. +- First VS Code account-entry pass should fake account/package state locally instead of wiring the real backend. +- Initial VS Code account-entry states: logged out, logged in with `No Pro packages`, logged in with `Organize: Trial`, and logged in with `Organize: Active`. +- Use the fake states to evaluate placement, tooltip wording, avatar fallback, separator treatment, and website navigation before adding Supabase, Stripe, or entitlement API integration. + +Entitlement states should stay simple for the first version: + +- trialing +- active +- canceled-until-period-end +- expired/unpaid + +Existing local Organize data should not be deleted when access expires. + +Open policy question: + +- Money-back/refund policy is not resolved in the card. It needs one clear FAQ answer before launch. + +## Privacy And Trust + +Make local-first privacy a central trust point, not only a technical FAQ. + +Website and FAQ copy should say: + +- CodeGraphy runs locally. +- Relationship Graph indexing runs locally. +- Graph Cache stays folder-local. +- Free use, Free Accounts, and Organize do not upload source code or Graph Cache data. +- Bookmarks or saved graph setups stay local unless the user explicitly uses a future sync, share, or export service. +- The account backend stores account, billing, and entitlement data, not source code. + +Bookmark sync is a future team sync idea, not part of launch. Later team sync may become a separate Pro Package because it requires shared accounts, permissions, conflict handling, and server-backed value. + +## Design Direction + +Use a dark, high-contrast, graph-native website theme that stays calm and legible. + +Design guidance: + +- The page should feel like a gallery wall for beautiful workspace maps, not a neon developer dashboard. +- Use dark canvas/background, crisp off-white text, restrained panels, and vivid graph colors as accents. +- Pull color energy from tuned CodeGraphy Legend themes and graph screenshots/GIFs. +- Include pastel accents where appropriate so the page feels approachable. +- Avoid a single dominant purple/blue developer-SaaS palette. +- Use varied accent families: teal, amber, coral, green, violet, blue, white, graphite. +- Keep UI chrome simple so graph visuals carry most of the richness. +- Explore subtle background texture such as paper grain or a light dot grid only if it improves physicality without reducing readability. + +Color should support clarity and structure. It should make clusters, important nodes, and workspace maps easier to read, not merely decorative. + +## Product Visuals And Gallery + +Use real CodeGraphy screenshots/GIFs from small or well-tuned public repos/workspaces. Do not use abstract decoration for primary product visuals. + +Build the website first with temporary CodeGraphy images/placeholders, then replace them with approved gallery screenshots/GIFs as each workspace audition finishes. Temporary visuals should make layout and copy feel real, but remain obviously replaceable. + +Launch gallery direction: + +- CodeGraphy itself: dogfooding, public monorepo shape, extension/core split, plugin API, built-in plugins, CLI/MCP packages, and how the product is assembled. Do not make this an MCP demo. +- vercel/next.js: Graph Scope, filtering, search, and making a huge workspace readable. +- gdquest-demos/godot-open-rpg: plugin-powered relationships and non-web-code workspace structure. +- BurntSushi/ripgrep: compact Rust architecture and relationship evidence; well-structured projects should look structured. +- tobi/qmd: local-first docs/code hybrid, Markdown/docs support, and Legend clarity. + +Backups or later candidates: + +- earendil-works/pi / pi.dev for terminal coding-agent/package architecture. +- openclaw/openclaw as a future stress test, probably too sprawling for the first landing-page gallery. +- vuejs/core can be styled visually, but do not claim deep `.vue` Single File Component relationship extraction until CodeGraphy adds parser/plugin support. + +Gallery audition loop: + +1. Clone or update candidate workspace locally. +2. Index the workspace with CodeGraphy. +3. Inspect the first raw Relationship Graph and note visual problems. +4. Filter junk/noise and tune Graph Scope/search/toggles. +5. Create custom Legend/theme groups for important folders, file types, and concepts. +6. Adjust icons, shapes, colors, and node sizing until useful patterns emerge. +7. Create 2-3 candidate views for the workspace. +8. Capture screenshot/GIF candidates. +9. Review whether the graph is both beautiful and useful. +10. Iterate until the workspace earns a gallery slot or gets rejected. + +### Website Media Shot List + +Status: wait until the Pro extraction work lands before capturing final website screenshots/GIFs. + +Example map targets: + +- **CodeGraphy**: dogfood map for the CodeGraphy repo itself. Use this to show the product understands its own core, extension, CLI/MCP, Plugin API, built-in plugins, docs, and examples. +- **Godot**: public Godot map for an engine-scale codebase with a very different shape than a TypeScript monorepo. +- **shadcn/ui**: exact third example map target: `shadcn-ui/ui` (`https://github.com/shadcn-ui/ui`). Use this instead of the earlier OpenHands and Vite DevTools ideas because it is highly recognizable, TypeScript-heavy, and close to CodeGraphy's developer-tool audience. The map should show useful neighborhoods across components, docs, registry code, templates, apps, styling, and package boundaries. + +Gallery media: + +- **Folder view**: still image showing folder/package context visible in the graph without making folders the only organizing principle. +- **3D view**: still image showing the graph in depth, framed clearly enough to read clusters. +- **Search and filters**: still image showing search/filter controls narrowing the graph to a useful subset. +- **Plugin system**: GIF of turning on multiple plugins and watching the graph/legend become richer. +- **Examples and natural clusters**: combine the old "Natural clusters" and "Examples folder" slots into one image. Use the examples folder because small multi-language examples naturally show CodeGraphy physics forming readable groups. + +Pricing and Organize media: + +- **Sections**: image with many section nodes or sectioned graph areas, making the grouping behavior obvious at a glance. +- **Pinned nodes**: image with several nodes pinned in an intentionally obvious layout so the viewer can see that important code can stay in place. +- **Bookmarks**: image standing in for saved graph setups/bookmarks. Until Bookmarks exist, use a filters/settings screenshot that implies a reusable saved view. + +## Feature Claims + +Lead with outcomes and avoid overclaiming implementation. + +Use launch-ready benefit cards only: + +- See how files connect before you edit. +- Jump from the graph to code. +- Tune graph scope and filters so large workspaces become readable. +- Use Legend/theming to make workspace structure visually meaningful. +- Organize important areas with launch-ready Pro Package features. +- Export or present a graph only if that export path is launch-ready. + +Do not market Saved Views as available until implemented. + +Graph Query/MCP is important, but it should live later in docs, blog, or advanced sections. The launch landing page should stay visual and immediately understandable. + +## Founder Note + +Include a short founder-style section lower on the landing page after the main product/gallery sections. + +Guidelines: + +- Keep it roughly 3-5 sentences. +- Do not make it a long personal essay. +- Explain that CodeGraphy came from thinking about workspaces spatially: files and concepts as points, relationships as closeness, hubs and clusters as structure. +- Connect the philosophy back to the product: CodeGraphy makes that mental map visible so users can understand, navigate, organize, and explain a workspace. +- Avoid making the copy feel overly sentimental or medicalized. + +Possible section titles: + +- `Why CodeGraphy Exists` +- `A Map For How Workspaces Feel` +- `The Mental Map, Made Visible` +- `Built For Spatial Understanding` + +## Domain Direction + +Current likely primary domain: + +- `codegraphy.dev` + +Reasoning: + +- Clearly signals a developer/workspace tool. +- Fits CodeGraphy's VS Code, CLI, plugin, and local-first developer ecosystem. +- Memorable and direct. +- Avoids positioning the product as AI-first. + +Optional secondary domains: + +- `codegraphy.co` as a company/product-home redirect or backup. +- `getcodegraphy.com` as a fallback. + +The card has earlier RDAP notes that `codegraphy.com` was registered and several alternatives returned no RDAP record on 2026-05-15. Treat those as research notes only. Verify availability and pricing at registrar checkout before finalizing or announcing a domain. + +## Implementation Slices + +Recommended initial slices: + +1. Update workspace/package configuration for `apps/web`. +2. Scaffold the Next.js + TypeScript app under `apps/web`. +3. Add shadcn to `apps/web` with local components. +4. Draft landing page copy in CodeGraphy product language before polishing layout. +5. Build the launch landing structure with temporary visuals. +6. Add pricing and FAQ content, including privacy, support, cancellation, refund, offline use, and source-code upload concerns. +7. Add login/account routes. +8. Add Supabase Auth. +9. Add Stripe checkout and customer portal handoff. +10. Add API route for Organize entitlement status. +11. Connect VS Code login/account handoff. +12. Replace temporary visuals with approved gallery captures as auditions finish. + +Do not let gallery perfection block package setup, copy, pricing, auth, billing, or account flow work. Do not let temporary visuals become permanent without review. + +## Starter Website Scope + +First website pages: + +- `/` landing page with pricing and FAQ sections. +- `/login` page for signing into an existing CodeGraphy Account. +- `/signup` page for creating a Free Account. +- `/account` page for account email, Pro Package status, and Pro Package subscription management. + +Landing page starter rule: + +- Use the Trello SaaS landing page structure reference as a loose guide for section order and conversion rhythm. +- Include whichever reference sections support the agreed CodeGraphy story; do not include a section just because the reference image has one. +- Start from the selected light website direction, not the discarded multi-variant prototype lab. +- Use the real CodeGraphy icon in website branding. +- Skip social proof/trust as a dedicated starter section. +- Organize pricing CTA should say `Coming soon` until checkout exists. +- Do not link Organize pricing to checkout or signup until the checkout path exists. + +First account page sections: + +- Account: `Account: {email}` with avatar preview. +- Packages: `No Pro packages` or current Organize status. +- Manage subscription: placeholder-only until Stripe/customer portal work starts. +- Sign out action. + +Do not build a checkout route yet. Keep checkout as later Stripe integration work. + +First VS Code account-entry scope: + +- Add the graph-corner account entry with local fake states only. +- Clicking the logged-out state should navigate to `/login`. +- Clicking the logged-in state should navigate to `/account`. +- Do not wire Supabase, Stripe, or entitlement APIs in the first account-entry pass. + +## Open Grill Questions + +### 1. Package Namespace + +Question: Should the website package be named `@codegraphy/web` from the Trello card, or `@codegraphy-dev/web` to match the current repo package convention? + +Recommended answer: use `@codegraphy-dev/web` for consistency unless a concrete publishing or brand reason requires `@codegraphy/web`. + +### 2. Saved Setup Term + +Question: What is the canonical term for reusable saved graph setups: Bookmark, Saved View, or something else? + +Existing glossary conflict: **Favorite** already means a marked node, and `CONTEXT.md` says to avoid Bookmark if it only means graph presentation. + +Recommended answer: use `Saved View` for the reusable setup until the product explicitly wants a more casual UI noun. Keep **Favorite** for marked nodes. + +### 3. Refund Policy + +Question: What should the launch FAQ say about refunds or a money-back policy? + +Recommended answer: offer a simple short-window refund policy for first launch, but confirm operational comfort before promising it. + +### 4. Launch-Ready Organize Features + +Question: Which Organize features are guaranteed to exist when the website launches? + +Recommended answer: only market features that are implemented or actively landing before launch. Keep future features in roadmap language, not pricing-card bullets. + +### 5. Domain Purchase + +Question: Is `codegraphy.dev` available and acceptable at checkout pricing? + +Recommended answer: verify live at registrar checkout before treating it as final. Buy likely redirect domains only if pricing is reasonable. diff --git a/package.json b/package.json index 39ef569ab..7f3c9b78b 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ ], "scripts": { "build": "turbo run build", + "web:dev": "pnpm --filter @codegraphy/web dev", "build:devhost": "node scripts/build-devhost.mjs", "watch": "pnpm --filter @codegraphy-dev/extension run watch", "dev": "pnpm --filter @codegraphy-dev/extension run dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1344be234..41abaf7ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,7 +93,75 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@20.19.37)(jiti@1.21.7)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.3) - apps/web: {} + apps/web: + dependencies: + '@radix-ui/react-slot': + specifier: ^1.2.4 + version: 1.2.4(@types/react@18.3.28)(react@18.3.1) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + d3-force: + specifier: ^3.0.0 + version: 3.0.0 + lucide-react: + specifier: 1.16.0 + version: 1.16.0(react@18.3.1) + next: + specifier: 16.2.6 + version: 16.2.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react: + specifier: ^18.2.0 + version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) + simple-icons: + specifier: ^16.21.0 + version: 16.24.0 + tailwind-merge: + specifier: ^3.4.0 + version: 3.5.0 + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.2.0 + version: 6.9.1 + '@testing-library/react': + specifier: ^14.1.2 + version: 14.3.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 + '@types/node': + specifier: ^20.11.0 + version: 20.19.37 + '@types/react': + specifier: ^18.2.48 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.2.18 + version: 18.3.7(@types/react@18.3.28) + autoprefixer: + specifier: ^10.4.17 + version: 10.4.27(postcss@8.5.15) + jsdom: + specifier: ^25.0.0 + version: 25.0.1 + postcss: + specifier: 8.5.15 + version: 8.5.15 + tailwindcss: + specifier: ^3.4.1 + version: 3.4.19(tsx@4.21.0)(yaml@2.8.3) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@20.19.37)(jiti@1.21.7)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.3) examples/example-javascript: {} @@ -860,6 +928,9 @@ packages: tree-sitter: optional: true + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -1264,6 +1335,159 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@inquirer/ansi@2.0.5': resolution: {integrity: sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw==} engines: {node: '>=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0'} @@ -1490,6 +1714,61 @@ packages: '@cfworker/json-schema': optional: true + '@next/env@16.2.6': + resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} + + '@next/swc-darwin-arm64@16.2.6': + resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.6': + resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.6': + resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-arm64-musl@16.2.6': + resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@next/swc-linux-x64-gnu@16.2.6': + resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@next/swc-linux-x64-musl@16.2.6': + resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@next/swc-win32-arm64-msvc@16.2.6': + resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.6': + resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2189,6 +2468,9 @@ packages: svelte: ^5.0.0 vite: ^6.3.0 || ^7.0.0 + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@testing-library/dom@9.3.4': resolution: {integrity: sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==} engines: {node: '>=14'} @@ -2855,6 +3137,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -4312,6 +4597,11 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + lucide-react@1.16.0: + resolution: {integrity: sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -4482,6 +4772,27 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + next@16.2.6: + resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + ngraph.events@1.4.0: resolution: {integrity: sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==} @@ -5195,6 +5506,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5236,6 +5551,10 @@ packages: simple-get@4.0.1: resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-icons@16.24.0: + resolution: {integrity: sha512-lAPW1rqgwPQ4tdIY15TtcKSgSelvJexz8q/B+a7Igg1dJoXR0LPjScLkLMI8UbLSkS41/fLVZWIhsH7HPUwAgQ==} + engines: {node: '>=0.12.18'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -5379,6 +5698,19 @@ packages: structured-source@4.0.0: resolution: {integrity: sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA==} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -6756,6 +7088,11 @@ snapshots: optionalDependencies: tree-sitter: 0.25.0(patch_hash=581e1c376edeabe3d25b64ea7bac59e14cc731627b17b97acccf0cce1dd051cb) + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -7005,6 +7342,103 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@inquirer/ansi@2.0.5': {} '@inquirer/checkbox@5.1.5(@types/node@20.19.37)': @@ -7241,6 +7675,32 @@ snapshots: transitivePeerDependencies: - supports-color + '@next/env@16.2.6': {} + + '@next/swc-darwin-arm64@16.2.6': + optional: true + + '@next/swc-darwin-x64@16.2.6': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.6': + optional: true + + '@next/swc-linux-arm64-musl@16.2.6': + optional: true + + '@next/swc-linux-x64-gnu@16.2.6': + optional: true + + '@next/swc-linux-x64-musl@16.2.6': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.6': + optional: true + + '@next/swc-win32-x64-msvc@16.2.6': + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -7935,6 +8395,10 @@ snapshots: vite: 7.3.5(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3) vitefu: 1.1.3(vite@7.3.5(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3)) + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + '@testing-library/dom@9.3.4': dependencies: '@babel/code-frame': 7.29.0 @@ -8781,6 +9245,8 @@ snapshots: cli-width@4.1.0: {} + client-only@0.0.1: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -10457,6 +10923,10 @@ snapshots: dependencies: yallist: 4.0.0 + lucide-react@1.16.0(react@18.3.1): + dependencies: + react: 18.3.1 + lz-string@1.5.0: {} magic-string@0.30.21: @@ -10619,6 +11089,31 @@ snapshots: negotiator@1.0.0: {} + next@16.2.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@next/env': 16.2.6 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001777 + postcss: 8.5.15 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@18.3.1) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.6 + '@next/swc-darwin-x64': 16.2.6 + '@next/swc-linux-arm64-gnu': 16.2.6 + '@next/swc-linux-arm64-musl': 16.2.6 + '@next/swc-linux-x64-gnu': 16.2.6 + '@next/swc-linux-x64-musl': 16.2.6 + '@next/swc-win32-arm64-msvc': 16.2.6 + '@next/swc-win32-x64-msvc': 16.2.6 + '@playwright/test': 1.58.2 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + ngraph.events@1.4.0: {} ngraph.forcelayout@3.3.1: @@ -11387,6 +11882,38 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -11437,6 +11964,8 @@ snapshots: simple-concat: 1.0.1 optional: true + simple-icons@16.24.0: {} + slash@3.0.0: {} slash@5.1.0: {} @@ -11595,6 +12124,13 @@ snapshots: dependencies: boundary: 2.0.0 + styled-jsx@5.1.6(@babel/core@7.29.0)(react@18.3.1): + dependencies: + client-only: 0.0.1 + react: 18.3.1 + optionalDependencies: + '@babel/core': 7.29.0 + sucrase@3.35.1: dependencies: '@jridgewell/gen-mapping': 0.3.13 @@ -12136,7 +12672,7 @@ snapshots: debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.5(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3) + vite: 6.4.1(@types/node@20.19.37)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti