diff --git a/docs/index.md b/docs/index.md index 16730113..e7bea79d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,13 +3,27 @@ id: docs title: Welcome to recode hive sidebar_label: Welcome to recode hive sidebar_position: 1 +hide_title: true +description: "The story behind recode hive: why we started, how the community grew from one repo to 100+ contributors, and how to make your first contribution." --- -# Welcome to recode hive - -**Collaboration 1st, code 2nd.** - -recode hive is an open-source educational platform built to help developers master essential technologies through interactive tutorials, practical guides, and community-driven learning. Whether you are a beginner taking your first steps in programming or an experienced developer sharpening your skills, recode hive provides the resources you need to grow. +import { + StoryHero, + NameCards, + JourneyTimeline, + ImpactCounters, + FounderNote, + ContributorWall, + CtaBand, +} from '@site/src/components/docsStory'; + + + + + + + + --- diff --git a/src/components/docsStory/ContributorWall.tsx b/src/components/docsStory/ContributorWall.tsx new file mode 100644 index 00000000..dbae0178 --- /dev/null +++ b/src/components/docsStory/ContributorWall.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { WALL_HANDLES } from './storyData'; +import { useInView, useRunning } from './hooks'; +import styles from './docsStory.module.css'; + +function Pill({ handle, hidden }: { handle: string; hidden?: boolean }) { + return ( + + + @{handle} + {!hidden && (opens in a new tab)} + + ); +} + +function Lane({ handles, reverse }: { handles: string[]; reverse?: boolean }) { + return ( +
+
+ {handles.map((h) => )} + {/* Duplicate set makes the loop seamless; hidden from assistive tech and the tab order */} + {handles.map((h) =>
+
+ ); +} + +export default function ContributorWall() { + const [ref, inView] = useInView(0.1); + const running = useRunning(inView); + if (WALL_HANDLES.length === 0) return null; + const mid = Math.ceil(WALL_HANDLES.length / 2); + return ( +
+

{'✦'} THE HIVE

+

Built by these people, and you next

+
+ + +
+
+ ); +} diff --git a/src/components/docsStory/CtaBand.tsx b/src/components/docsStory/CtaBand.tsx new file mode 100644 index 00000000..15a61f85 --- /dev/null +++ b/src/components/docsStory/CtaBand.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { GOOD_FIRST_ISSUES, SETUP_ANCHOR } from './storyData'; +import styles from './docsStory.module.css'; + +export default function CtaBand() { + return ( +
+
+

{'✦'} YOUR TURN

+

Every hexagon in the hive started with one PR.

+

Set up the repo in 10 minutes, then pick a good first issue.

+
+ +
+ ); +} diff --git a/src/components/docsStory/FounderNote.tsx b/src/components/docsStory/FounderNote.tsx new file mode 100644 index 00000000..fcc187f0 --- /dev/null +++ b/src/components/docsStory/FounderNote.tsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import { FOUNDER } from './storyData'; +import styles from './docsStory.module.css'; + +export default function FounderNote() { + const photo = useBaseUrl(FOUNDER.photo); + const [failed, setFailed] = useState(false); + // No quote written yet: hide the whole section rather than show a placeholder. + if (!FOUNDER.quote.trim()) return null; + const initials = FOUNDER.name.split(' ').map((w) => w[0]).join('').slice(0, 2); + return ( +
+

A note from the founder

+
+ + {failed ? ( + + ) : ( + {FOUNDER.name} setFailed(true)} + /> + )} +
+
{FOUNDER.quote}
+
+ {FOUNDER.name} + {FOUNDER.role} +
+
+
+
+ ); +} diff --git a/src/components/docsStory/HiveBee.tsx b/src/components/docsStory/HiveBee.tsx new file mode 100644 index 00000000..e1979c6c --- /dev/null +++ b/src/components/docsStory/HiveBee.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import styles from './docsStory.module.css'; + +/** The recode hive bee. The witch hat is added only when `hat` is true (Halloween window). */ +export default function HiveBee({ hat }: { hat: boolean }) { + return ( + + ); +} diff --git a/src/components/docsStory/ImpactCounters.tsx b/src/components/docsStory/ImpactCounters.tsx new file mode 100644 index 00000000..516628b4 --- /dev/null +++ b/src/components/docsStory/ImpactCounters.tsx @@ -0,0 +1,50 @@ +import React, { useEffect, useState } from 'react'; +import { IMPACT } from './storyData'; +import { useInView, useReducedMotion } from './hooks'; +import styles from './docsStory.module.css'; + +const fmt = (n: number, k: boolean) => { + if (k && n >= 1000) return `${(n / 1000).toFixed(2).replace(/0$/, '')}K`; + return Math.round(n).toLocaleString('en-US'); +}; + +function Counter({ value, suffix, k, play }: { value: number; suffix: string; k: boolean; play: boolean }) { + const [shown, setShown] = useState(value); // final value for server render and reduced motion + useEffect(() => { + if (!play) return; + let raf = 0; + const start = performance.now(); + const dur = 1200; + const tick = (now: number) => { + const t = Math.min(1, (now - start) / dur); + setShown(value * (1 - Math.pow(1 - t, 3))); + if (t < 1) raf = requestAnimationFrame(tick); + }; + setShown(0); + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [play, value]); + return <>{fmt(shown, k)}{suffix}; +} + +export default function ImpactCounters() { + const [ref, seen] = useInView(0.4, true); + const reduced = useReducedMotion(); + if (IMPACT.length === 0) return null; + return ( +
+

{'✦'} IMPACT SO FAR

+

Small contributions, added up

+
+ {IMPACT.map((t) => ( +
+ + + + {t.label} +
+ ))} +
+
+ ); +} diff --git a/src/components/docsStory/JourneyTimeline.tsx b/src/components/docsStory/JourneyTimeline.tsx new file mode 100644 index 00000000..5ee0d628 --- /dev/null +++ b/src/components/docsStory/JourneyTimeline.tsx @@ -0,0 +1,179 @@ +import React, { useEffect, useRef, useState } from 'react'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import { AnimatePresence, motion } from 'framer-motion'; +import { MILESTONES, type Milestone } from './storyData'; +import { useInView, useReducedMotion, useRunning } from './hooks'; +import styles from './docsStory.module.css'; + +const ERA_COLOR: Record = { + idea: '#94a3b8', + early: '#8b5cf6', + apr: '#2563eb', + oct: '#2e8555', + next: '#f59e0b', +}; +const INTERVAL_MS = 5000; + +function Shot({ milestone }: { milestone: Milestone }) { + const src = useBaseUrl(milestone.screenshot || ''); + const ref = useRef(null); + const [state, setState] = useState<'loading' | 'ok' | 'failed'>(milestone.screenshot ? 'loading' : 'failed'); + + useEffect(() => { + const img = ref.current; + if (!img) return; + // The image may have finished (or failed) before hydration attached the handlers. + if (img.complete) setState(img.naturalWidth > 0 ? 'ok' : 'failed'); + }, []); + + return ( +
+ {state !== 'ok' && ( + + )} + {milestone.screenshot && state !== 'failed' && ( + {`recode setState('ok')} + onError={() => setState('failed')} + /> + )} +
+ ); +} + +function NextFrame() { + return ( +
+ + Your PR goes here + The next screenshot on this timeline is the one you ship. +
+ ); +} + +export default function JourneyTimeline() { + const [ref, inView] = useInView(0.25); + const running = useRunning(inView); + const reduced = useReducedMotion(); + const [active, setActive] = useState(0); + const [nonce, setNonce] = useState(0); // bumps restart the progress bar + const tabs = useRef<(HTMLButtonElement | null)[]>([]); + + if (MILESTONES.length === 0) return null; + const current = MILESTONES[Math.min(active, MILESTONES.length - 1)]; + + const activate = (i: number, focus = false) => { + setActive(i); + setNonce((n) => n + 1); + if (focus) tabs.current[i]?.focus(); + }; + const onKey = (e: React.KeyboardEvent, i: number) => { + const last = MILESTONES.length - 1; + const map: Record = { + ArrowDown: i === last ? 0 : i + 1, + ArrowRight: i === last ? 0 : i + 1, + ArrowUp: i === 0 ? last : i - 1, + ArrowLeft: i === 0 ? last : i - 1, + Home: 0, + End: last, + }; + if (e.key in map) { + e.preventDefault(); + activate(map[e.key], true); + } + }; + + return ( +
+

{'✦'} HOW WE GOT HERE

+

From one repo to a hive

+
+
+ {MILESTONES.map((m, i) => { + const on = i === active; + return ( + + ); + })} +
+ +
+
+
+
+ ); +} diff --git a/src/components/docsStory/NameCards.tsx b/src/components/docsStory/NameCards.tsx new file mode 100644 index 00000000..4e44ac47 --- /dev/null +++ b/src/components/docsStory/NameCards.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useInView, useRunning } from './hooks'; +import styles from './docsStory.module.css'; + +export default function NameCards() { + const [ref, inView] = useInView(0.15); + const running = useRunning(inView); + return ( +
+

{'✦'} THE NAME

+

Why “recode hive”?

+
+
+

+ {'recode'.split('').map((ch, i) => ( + + ))} +

+

+ Code again, differently. AI agents now write a lot of the code. The skill that matters is knowing what to build, how to review it and how to ship it with other people. We learn that by doing. +

+
+
+ +

hive

+

+ A hive works because every bee does a small part. One fixes a typo, one writes a tutorial, one reviews a PR. Together that becomes a platform thousands of learners use. +

+
+
+
+ ); +} diff --git a/src/components/docsStory/StoryHero.tsx b/src/components/docsStory/StoryHero.tsx new file mode 100644 index 00000000..4504ab89 --- /dev/null +++ b/src/components/docsStory/StoryHero.tsx @@ -0,0 +1,122 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { useHalloween } from '../Halloween/useHalloween'; +import HiveBee from './HiveBee'; +import { useInView, useRunning } from './hooks'; +import styles from './docsStory.module.css'; + +const COLS = 10; +const ROWS = 7; +const R = 21; +const W = Math.sqrt(3) * R; +const YOU = 34; // the permanently gold cell +const COMMAND = '$ git commit -m "recode, together"'; + +function hexPoints(cx: number, cy: number): string { + const pts: string[] = []; + for (let k = 0; k < 6; k++) { + const a = (Math.PI / 180) * (60 * k + 30); + pts.push(`${(cx + (R - 1.5) * Math.cos(a)).toFixed(2)},${(cy + (R - 1.5) * Math.sin(a)).toFixed(2)}`); + } + return pts.join(' '); +} + +// Seeded shuffle so server and client always agree. +function mulberry32(seed: number) { + return () => { + seed |= 0; seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const CELLS = Array.from({ length: COLS * ROWS }, (_, i) => { + const row = Math.floor(i / COLS); + const col = i % COLS; + const cx = R + col * W + (row % 2 ? W / 2 : 0); + const cy = R + row * R * 1.5; + return { i, points: hexPoints(cx, cy) }; +}); + +const ORDER = (() => { + const rand = mulberry32(2026); + const idx = CELLS.map((c) => c.i).filter((i) => i !== YOU); + for (let i = idx.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)); + [idx[i], idx[j]] = [idx[j], idx[i]]; + } + return idx; +})(); +const RANK = new Map(ORDER.map((cell, rank) => [cell, rank])); + +export default function StoryHero() { + const hat = useHalloween(); + const [ref, inView] = useInView(0.15); + const running = useRunning(inView); + const [lit, setLit] = useState(ORDER.length); // final state until the loop starts + + useEffect(() => { + if (!running) { + // Off screen, hidden tab or reduced motion: hold the fully lit final state. + setLit(ORDER.length); + return; + } + setLit(0); + let n = 0; + let hold = 0; + const id = window.setInterval(() => { + if (n < ORDER.length) { + n += 1; + setLit(n); + } else if (hold < 8) { + hold += 1; // pause on the full hive for about 1.2s + } else { + n = 0; + hold = 0; + setLit(0); + } + }, 150); + return () => window.clearInterval(id); + }, [running]); + + const cells = useMemo( + () => + CELLS.map((c) => { + if (c.i === YOU) return ; + const on = (RANK.get(c.i) ?? 0) < lit; + return ; + }), + [lit], + ); + + return ( +
+
+

{'✦'} OUR STORY

+

+ Welcome to
recode hive +

+

+ An open source learning community where developers learn by shipping real work, together. Collaboration first, code second. +

+

+ +

+
+
+ + {cells} + + +
+
+ ); +} diff --git a/src/components/docsStory/docsStory.module.css b/src/components/docsStory/docsStory.module.css new file mode 100644 index 00000000..655839f0 --- /dev/null +++ b/src/components/docsStory/docsStory.module.css @@ -0,0 +1,194 @@ +/* Our story intro for the docs landing page. Light tokens on bare selectors, dark overrides below. */ +.section { + --accent: #2e8555; --accent-tint: #e8f5ee; + --text: #0f172a; --text-2: #44403c; --muted: #78716c; --border: #e7e5e4; + --card: #ffffff; --card-2: #f5f5f4; --shadow: 0 10px 30px rgba(15, 23, 42, 0.08); + margin: 56px 0 0; + color: var(--text); + font-family: "Inter", sans-serif; +} +:global(html[data-theme='dark']) .section { + --accent: #60a5fa; --accent-tint: rgba(96, 165, 250, 0.14); + --text: #f1f5f9; --text-2: #cbd5e1; --muted: #94a3b8; --border: #1e293b; + --card: #111827; --card-2: #0b1120; --shadow: 0 10px 30px rgba(0, 0, 0, 0.4); +} +.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } + +.eyebrow, .eyebrowLight, .eyebrowCta { margin: 0 0 10px !important; font: 700 12px/1 "JetBrains Mono", ui-monospace, monospace; letter-spacing: 0.12em; text-transform: uppercase; } +.eyebrow { color: var(--accent); } +.eyebrowLight { color: #6fd39a; } +.eyebrowCta { color: #a7e3c0; } +.section .h2 { margin: 0 0 20px; padding: 0; font-family: "Space Grotesk", "Inter", sans-serif; font-size: 30px; line-height: 1.15; font-weight: 700; letter-spacing: -0.02em; color: var(--text); } +.section a:focus-visible, .section button:focus-visible, .cta a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } + +/* Hero */ +.hero { + position: relative; display: grid; grid-template-columns: 1fr 1.15fr; gap: 24px; align-items: center; overflow: hidden; + margin: 0 0 8px; padding: 36px; border-radius: 1.25rem; color: #cbd5e1; background-color: #0d1117; + background-image: radial-gradient(ellipse 55% 70% at 85% 50%, rgba(46, 133, 85, 0.28), transparent 70%); + font-family: "Inter", sans-serif; +} +.heroText { min-width: 0; } +.hero .heroTitle { margin: 0 0 16px; padding: 0; font-family: "Space Grotesk", "Inter", sans-serif; font-size: 44px; line-height: 1.05; font-weight: 700; letter-spacing: -0.03em; color: #fff; text-align: left; } +.heroAccent { color: #4ade80; } +.hero .heroLede { margin: 0 0 20px; font-size: 15px; line-height: 1.6; color: #cbd5e1; } +.hero .command { margin: 0; display: flex; align-items: center; font: 400 13px "JetBrains Mono", ui-monospace, monospace; color: #86efac; } +.typed { display: inline-block; overflow: hidden; white-space: nowrap; width: 34ch; } +.caret { display: inline-block; width: 2px; height: 16px; margin-left: 3px; background: #4ade80; animation: blink 1s steps(1) infinite; } +.running .typed { animation: typing 2.4s steps(34) 1 both; } +@keyframes typing { from { width: 0; } to { width: 34ch; } } +@keyframes blink { 50% { opacity: 0; } } + +.heroArt { position: relative; min-width: 0; } +.hive { display: block; width: 100%; height: auto; } +.hex { fill: #16241c; stroke: #1f3a2a; stroke-width: 1.5; transition: fill 0.5s, stroke 0.5s; } +.hexOn { fill: #2e8555; stroke: #4ade80; } +.hexYou { fill: #fbbf24; stroke: #fde68a; } +.bee { position: absolute; left: 8%; top: 55%; width: 58px; height: 58px; pointer-events: none; } +.bob { width: 100%; height: 100%; } +.running .bee { animation: fly 10s ease-in-out infinite; } +.running .bob { animation: bob 1.1s ease-in-out infinite alternate; } +@keyframes fly { + 0%, 100% { left: 8%; top: 55%; } + 25% { left: 34%; top: 8%; } + 50% { left: 68%; top: 28%; } + 75% { left: 78%; top: 66%; } +} +@keyframes bob { to { transform: translateY(-6px); } } +.wing { transform-box: fill-box; transform-origin: 50% 100%; } +.running .wing { animation: flap 0.14s ease-in-out infinite alternate; } +@keyframes flap { to { transform: scaleY(0.35); } } + +/* Name cards */ +.nameGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.card { padding: 24px; border: 1px solid var(--border); border-radius: 1rem; background: var(--card); box-shadow: var(--shadow); transition: transform 0.2s; } +.card:hover { transform: translateY(-4px); } +.word { margin: 0 0 12px; font: 500 40px/1.1 "JetBrains Mono", ui-monospace, monospace; letter-spacing: -0.02em; color: var(--text); } +.letter { display: inline-block; } +.running .letter { animation: jump 3.2s ease-in-out infinite; } +@keyframes jump { + 0%, 14%, 100% { transform: translateY(0) rotate(0); color: var(--text); } + 6% { transform: translateY(-10px) rotate(-4deg); color: var(--accent); } +} +.miniHexes { display: flex; gap: 4px; margin-bottom: 14px; } +.miniHex polygon { fill: #dcfce7; stroke: #2e8555; stroke-width: 1.5; } +:global(html[data-theme='dark']) .miniHex polygon { fill: #14532d; stroke: #4ade80; } +.running .miniHex polygon { animation: pulseHex 2.4s ease-in-out infinite; animation-delay: inherit; } +@keyframes pulseHex { 0%, 60%, 100% { fill: #dcfce7; } 30% { fill: #2e8555; } } +.section .cardText { margin: 0; font-size: 15px; line-height: 1.6; color: var(--text-2); } +.cardText strong { color: var(--text); } + +/* Timeline */ +.journey { display: grid; grid-template-columns: 1fr 1.1fr; gap: 32px; align-items: start; } +.tabs { display: flex; flex-direction: column; gap: 4px; } +.tab { + position: relative; display: flex; gap: 14px; width: 100%; padding: 14px 16px; text-align: left; cursor: pointer; + border: 1px solid transparent; border-radius: 12px; background: transparent; color: var(--text); font: inherit; + transition: background 0.3s, box-shadow 0.3s, border-color 0.3s; +} +.tabOn { background: var(--card); border-color: var(--border); box-shadow: var(--shadow); } +.tabNum { flex: none; font: 400 12px "JetBrains Mono", ui-monospace, monospace; color: var(--muted); padding-top: 4px; } +.tabBody { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.tabDate { font: 500 11px "JetBrains Mono", ui-monospace, monospace; letter-spacing: 0.06em; color: var(--accent); } +.tabDateNext { color: #d97706; } +.tabTitle { font-family: "Space Grotesk", "Inter", sans-serif; font-size: 16px; line-height: 1.3; font-weight: 700; color: var(--text); } +.tabDesc { display: grid; grid-template-rows: 0fr; opacity: 0; transition: grid-template-rows 420ms ease, opacity 420ms ease, margin 420ms ease; } +.tabDesc > span { overflow: hidden; font-size: 14px; line-height: 1.55; color: var(--text-2); } +.tabOn .tabDesc { grid-template-rows: 1fr; opacity: 1; margin-top: 6px; } +.tabBar { position: absolute; left: 12px; right: 12px; bottom: 0; height: 2px; border-radius: 2px; background: var(--accent); transform-origin: left; animation: fill linear 1 both; } +@keyframes fill { from { transform: scaleX(0); } to { transform: scaleX(1); } } + +.stage { position: relative; min-width: 0; padding: 14px 0 0; } +.ghost { position: absolute; inset: 14px 0 28px; border-radius: 14px; background: var(--card); border: 1px solid var(--border); box-shadow: var(--shadow); } +.ghostA { transform: rotate(-4deg); opacity: 0.7; } +.ghostB { transform: rotate(3deg); opacity: 0.85; } +.browser { position: relative; border: 1px solid var(--border); border-radius: 14px; background: var(--card); box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16); overflow: hidden; } +.browserBar { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--border); background: var(--card-2); } +.dots { display: inline-flex; gap: 6px; } +.dots i { width: 10px; height: 10px; border-radius: 50%; background: #ff5f57; } +.dots i:nth-child(2) { background: #febc2e; } +.dots i:nth-child(3) { background: #28c840; } +.url { flex: 1; max-width: 220px; padding: 3px 12px; border: 1px solid var(--border); border-radius: 999px; background: var(--card); font: 400 11px "JetBrains Mono", ui-monospace, monospace; color: var(--muted); } +.browserDate { margin-left: auto; font: 500 11px "JetBrains Mono", ui-monospace, monospace; color: var(--accent); white-space: nowrap; } +.viewport { position: relative; aspect-ratio: 16 / 10; overflow: hidden; background: var(--card-2); } +.layer { position: absolute; inset: 0; } +.shot { position: absolute; inset: 0; } +.shotImg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: top; transition: opacity 0.3s; } +.skeleton { position: absolute; inset: 0; display: flex; flex-direction: column; gap: 10px; padding: 18px; background: color-mix(in srgb, var(--era) 12%, var(--card-2)); } +.skBar { height: 10px; border-radius: 5px; background: color-mix(in srgb, var(--era) 55%, transparent); } +.skBlock { flex: none; height: 38%; border-radius: 10px; background: color-mix(in srgb, var(--era) 30%, transparent); } +.skLine { height: 8px; border-radius: 4px; background: color-mix(in srgb, var(--era) 22%, transparent); } +.skRow { display: flex; gap: 10px; margin-top: auto; } +.skRow i { flex: 1; height: 44px; border-radius: 8px; background: color-mix(in srgb, var(--era) 22%, transparent); } +.nextFrame { position: absolute; inset: 14px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; padding: 16px; text-align: center; border: 2px dashed #f59e0b; border-radius: 12px; color: var(--text-2); font-size: 14px; } +.nextFrame strong { font-family: "Space Grotesk", "Inter", sans-serif; font-size: 18px; color: var(--text); } +.nextBee { font-size: 34px; } +.caption { display: flex; align-items: center; gap: 8px; margin: 14px 0 0; font-size: 13px; color: var(--muted); } +.captionDot { width: 8px; height: 8px; border-radius: 50%; } + +/* Impact */ +.tiles { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; } +.tile { display: flex; flex-direction: column; gap: 6px; padding: 20px; border: 1px solid var(--border); border-radius: 1rem; background: var(--card); box-shadow: var(--shadow); } +.tileFeatured { background: #205d3b; border-color: #205d3b; } +.tileValue { font-family: "Space Grotesk", "Inter", sans-serif; font-size: 44px; line-height: 1.05; font-weight: 700; letter-spacing: -0.02em; color: var(--text); } +.tileLabel { font-size: 13.5px; color: var(--muted); } +.tileFeatured .tileValue { color: #fff; } +.tileFeatured .tileLabel { color: #bbf7d0; } + +/* Founder */ +.founder { position: relative; display: flex; gap: 24px; align-items: center; margin: 0; padding: 28px; border: 1px solid var(--border); border-radius: 1rem; background: var(--card); box-shadow: var(--shadow); overflow: hidden; } +.quoteMark { position: absolute; top: -6px; right: 22px; font: 700 110px/1 "Space Grotesk", serif; color: var(--accent-tint); pointer-events: none; } +.founderPhoto { flex: none; width: 96px; height: 96px; border-radius: 50%; object-fit: cover; border: 2px solid var(--accent); display: inline-flex; align-items: center; justify-content: center; background: var(--accent-tint); color: var(--accent); font: 700 28px "Space Grotesk", sans-serif; } +.founderQuote { margin: 0 0 12px; padding: 0; border: 0; font-size: 17px; line-height: 1.6; font-style: italic; color: var(--text-2); } +.founderName { display: flex; flex-direction: column; font-size: 13px; color: var(--muted); } +.founderName strong { font-size: 15px; color: var(--text); } + +/* Contributor wall */ +.wall { position: relative; display: flex; flex-direction: column; gap: 10px; padding: 16px 0; border: 1px solid var(--border); border-radius: 1rem; background: var(--card); overflow: hidden; + -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); } +.lane { overflow: hidden; } +.track { display: flex; gap: 10px; width: max-content; } +.running .track { animation: marquee 28s linear infinite; } +.running .trackReverse { animation-direction: reverse; } +.running .wall:hover .track, .running .wall:focus-within .track { animation-play-state: paused; } +@keyframes marquee { to { transform: translateX(-50%); } } +.pill { display: inline-flex; align-items: center; gap: 8px; padding: 5px 14px 5px 5px; border: 1px solid var(--border); border-radius: 999px; background: var(--card-2); color: var(--text); font: 400 12.5px "JetBrains Mono", ui-monospace, monospace; text-decoration: none; white-space: nowrap; } +.pill:hover { border-color: var(--accent); color: var(--text); text-decoration: none; } +.pillAvatar { width: 26px; height: 26px; border-radius: 50%; background: var(--border); } + +/* CTA */ +.cta { display: flex; align-items: center; justify-content: space-between; gap: 24px; flex-wrap: wrap; margin: 56px 0 40px; padding: 28px 32px; border-radius: 1rem; color: #fff; background-color: #205d3b; + background-image: linear-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px); background-size: 22px 22px; font-family: "Inter", sans-serif; } +.cta .ctaTitle { margin: 0 0 6px; padding: 0; max-width: 420px; font-family: "Space Grotesk", "Inter", sans-serif; font-size: 26px; line-height: 1.2; font-weight: 700; color: #fff; } +.cta .ctaText { margin: 0; max-width: 420px; font-size: 14.5px; color: #d1fae5; } +.ctaButtons { display: flex; gap: 10px; flex-wrap: wrap; } +.btnGhost, .btnWhite { display: inline-flex; align-items: center; height: 44px; padding: 0 18px; border-radius: 10px; font: 600 14px "Inter", sans-serif; text-decoration: none; white-space: nowrap; } +.btnGhost { border: 1px solid rgba(255, 255, 255, 0.45); color: #fff; } +.btnGhost:hover { background: rgba(255, 255, 255, 0.1); color: #fff; text-decoration: none; } +.btnWhite { background: #fff; color: #205d3b; border: 1px solid #fff; } +.btnWhite:hover { background: #f0fdf4; color: #205d3b; text-decoration: none; } + +/* Responsive */ +@media (max-width: 1250px) { + .hero .heroTitle { font-size: 36px; } +} +@media (max-width: 768px) { + .hero { grid-template-columns: 1fr; padding: 24px; } + .hero .heroTitle { font-size: 34px; } + .nameGrid { grid-template-columns: 1fr; } + .journey { grid-template-columns: 1fr; } + .stage { order: -1; } + .tiles { grid-template-columns: repeat(2, 1fr); } + .founder { flex-direction: column; align-items: flex-start; } + .section .h2 { font-size: 26px; } + .cta { padding: 24px; } +} + +/* Reduced motion: every animation shows its final state with no movement */ +@media (prefers-reduced-motion: reduce) { + .caret, .typed, .bee, .bob, .wing, .letter, .miniHex polygon, .tabBar, .track { animation: none !important; } + .hex, .tabDesc, .tab { transition: none; } + .wall { padding: 12px; -webkit-mask-image: none; mask-image: none; } + .track { flex-wrap: wrap; width: auto; } + .track > [aria-hidden='true'] { display: none; } +} diff --git a/src/components/docsStory/hooks.ts b/src/components/docsStory/hooks.ts new file mode 100644 index 00000000..cc3c8a8c --- /dev/null +++ b/src/components/docsStory/hooks.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState } from 'react'; + +/** True while the element is on screen. `once` keeps it true after the first time. */ +export function useInView(threshold = 0.1, once = false) { + const ref = useRef(null); + const [inView, setInView] = useState(false); + useEffect(() => { + const el = ref.current; + if (!el || typeof IntersectionObserver === 'undefined') { + setInView(true); + return; + } + const io = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setInView(true); + if (once) io.disconnect(); + } else if (!once) { + setInView(false); + } + }, + { threshold }, + ); + io.observe(el); + return () => io.disconnect(); + }, [threshold, once]); + return [ref, inView] as const; +} + +/** False while the browser tab is hidden. */ +export function usePageVisible(): boolean { + const [visible, setVisible] = useState(true); + useEffect(() => { + const on = () => setVisible(!document.hidden); + on(); + document.addEventListener('visibilitychange', on); + return () => document.removeEventListener('visibilitychange', on); + }, []); + return visible; +} + +/** True when the visitor prefers reduced motion. Defaults to false on the server. */ +export function useReducedMotion(): boolean { + const [reduced, setReduced] = useState(false); + useEffect(() => { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + const on = () => setReduced(mq.matches); + on(); + mq.addEventListener('change', on); + return () => mq.removeEventListener('change', on); + }, []); + return reduced; +} + +/** Animations should run only when on screen, the tab is visible and motion is allowed. */ +export function useRunning(inView: boolean): boolean { + const visible = usePageVisible(); + const reduced = useReducedMotion(); + return inView && visible && !reduced; +} diff --git a/src/components/docsStory/index.ts b/src/components/docsStory/index.ts new file mode 100644 index 00000000..a8328c63 --- /dev/null +++ b/src/components/docsStory/index.ts @@ -0,0 +1,7 @@ +export { default as StoryHero } from './StoryHero'; +export { default as NameCards } from './NameCards'; +export { default as JourneyTimeline } from './JourneyTimeline'; +export { default as ImpactCounters } from './ImpactCounters'; +export { default as FounderNote } from './FounderNote'; +export { default as ContributorWall } from './ContributorWall'; +export { default as CtaBand } from './CtaBand'; diff --git a/src/components/docsStory/storyData.ts b/src/components/docsStory/storyData.ts new file mode 100644 index 00000000..d462fa23 --- /dev/null +++ b/src/components/docsStory/storyData.ts @@ -0,0 +1,132 @@ +import releases from '../../data/releases.json'; +import { formatVersion } from '../dashboard/overview/releaseData'; +import { getContributors } from '../../utils/releaseContributors'; + +// All copy, numbers and milestones for the docs "Our story" intro live here. +// Any field left empty is not rendered. Never ship bracket placeholders. + +type RawRelease = { tagName: string; publishedAt: string; body: string }; +const list = releases as RawRelease[]; + +const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; +const WORDS = ['Zero', 'One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Eleven', 'Twelve']; +const monthYear = (iso: string) => { + const d = new Date(iso); + return `${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`; +}; + +// Release facts come from the build-time snapshot in src/data/releases.json. +const byDate = [...list].sort((a, b) => +new Date(a.publishedAt) - +new Date(b.publishedAt)); +const first = byDate[0]; +const latest = byDate[byDate.length - 1]; +const year = latest ? new Date(latest.publishedAt).getUTCFullYear() : new Date().getFullYear(); +// The same version can be published twice with different tag casing, so count unique versions. +const releasesThisYear = new Set( + list.filter((r) => new Date(r.publishedAt).getUTCFullYear() === year).map((r) => formatVersion(r.tagName)), +).size; + +// Numbers that have no build-time source yet. +// TODO(sanjay): keep in sync with the dashboard Contributors tile (106 at the time of writing). +export const CONTRIBUTORS = 106; +// TODO(sanjay): total stars across the public repos (1.15K at the time of writing). +export const STARS = 1150; +// TODO(sanjay): learners reached by tutorials and sessions (your figure, 12,000+). +export const LEARNERS = 12000; + +export type Era = 'idea' | 'early' | 'apr' | 'oct' | 'next'; + +export type Milestone = { + id: string; + era: Era; + date: string; + title: string; + text: string; + /** Path under /static. If the file is missing a tinted skeleton is shown instead. */ + screenshot?: string; + caption?: string; +}; + +const ALL_MILESTONES: Milestone[] = [ + { + id: 'idea', + era: 'idea', + date: '', // TODO(sanjay): year the idea started + title: 'The idea', + text: '', // TODO(sanjay): one or two sentences about the moment you decided to start recode hive + screenshot: '/img/journey/idea.webp', + caption: '', // TODO(sanjay) + }, + { + id: 'early', + era: 'early', + date: '', // TODO(sanjay): year of the first workshops + title: 'First workshops and learners', + text: '', // TODO(sanjay): a sentence about the first workshops. Keep the "12,000+ learners" claim only if it is accurate. + screenshot: '/img/journey/early.webp', + caption: '', // TODO(sanjay) + }, + { + id: 'apr', + era: 'apr', + date: first ? monthYear(first.publishedAt) : '', + title: first ? `${formatVersion(first.tagName)}: the website goes open source` : '', + text: first + ? `The first tagged release ships with ${getContributors(first.body).length} contributors in its changelog.` + : '', + screenshot: '/img/journey/apr-2026.webp', + caption: 'The blue theme, at the first tagged release', + }, + { + id: 'oct', + era: 'oct', + date: latest ? `${monthYear(latest.publishedAt)} · NOW` : '', + title: latest ? `${formatVersion(latest.tagName)} and ${CONTRIBUTORS} contributors` : '', + text: `${WORDS[releasesThisYear] ?? releasesThisYear} releases in ${year} and a contributor community that keeps growing with every merged PR.`, + screenshot: '/img/journey/oct-2026.webp', + caption: 'Today: the green theme since V1.0.7', + }, + { + id: 'next', + era: 'next', + date: 'NEXT', + title: 'Your first PR', + text: 'The next milestone on this timeline could be yours.', + }, +]; + +/** Milestones with an empty date, title or text are hidden. */ +export const MILESTONES = ALL_MILESTONES.filter((m) => m.date.trim() && m.title.trim() && m.text.trim()); + +export const IMPACT = [ + { id: 'learners', value: LEARNERS, suffix: '+', label: 'learners reached', featured: true, k: false }, + { id: 'contributors', value: CONTRIBUTORS, suffix: '', label: 'contributors on GitHub', featured: false, k: false }, + { id: 'stars', value: STARS, suffix: '', label: 'stars across repos', featured: false, k: true }, + { id: 'releases', value: releasesThisYear, suffix: '', label: `releases in ${year}`, featured: false, k: false }, +].filter((t) => t.value > 0); + +export const FOUNDER = { + // TODO(sanjay): 3 to 5 sentences in your own words. Leave empty to hide the whole section. + quote: '', + name: 'Sanjay Viswanathan', + role: 'Founder, recode hive · GitHub Campus Expert', + photo: '/img/founder/sanjay.jpg', +}; + +// Top contributors by merged PRs named in the release notes (bots and Copilot excluded). +const tally = new Map(); +for (const r of list) { + for (const c of getContributors(r.body)) { + const key = c.handle.toLowerCase(); + const cur = tally.get(key) ?? { handle: c.handle, prs: 0 }; + cur.prs += c.prs; + tally.set(key, cur); + } +} +export const WALL_HANDLES: string[] = [...tally.values()] + .sort((a, b) => b.prs - a.prs || a.handle.localeCompare(b.handle)) + .slice(0, 20) + .map((c) => c.handle); + +export const GOOD_FIRST_ISSUES = + 'https://github.com/recodehive/recode-website/issues?q=is%3Aopen+label%3A%22good+first+issue%22'; +export const SETUP_ANCHOR = '#set-up-the-repository-locally';