diff --git a/CLAUDE.md b/CLAUDE.md index f1a9769..a7025bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,9 +65,10 @@ App lives in `apps/web`. Social media videos live in `apps/video` (Remotion, sta `data-glow` surfaces). Reuse them instead of inventing new effects. - The homepage is one story with a visible thread (`apps/web/src/components/story/`). Every chapter opens with one sentence from `story` in the content files; read one after the other they tell the way of a - project from the idea to the first email. `StoryThread` is one line down the whole page that draws itself - as the visitor scrolls: under each sentence it crosses the page, stands a figure on the line and continues - on the other side, with a glowing dot at its head. `Block` prop `chapter` gives a section its sentence and + project from the idea to the first email. `StoryThread` is one trace down the whole page, in the look of a circuit + board (45 degree bends, solder points, a second parallel track), that draws itself as the visitor scrolls: + under each sentence it crosses the page, stands a component with the chapter number in binary on the track + and continues on the other side, with a bit (1/0) travelling at its head. `Block` prop `chapter` gives a section its sentence and figure; how much of the line is drawn is decided in `src/domain/thread.ts`. A new section on the homepage needs a sentence that continues the story, or it breaks the thread. No rails, no "next question" links (owner rejected both). diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index d1c2e79..a2a61ff 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1340,31 +1340,70 @@ pre:has(code[class*="language-"]) { } /* - * The story thread (see StoryThread): one line down the page, indigo on light - * sections and light on dark ones, with a glowing dot at its head. + * The story thread (see StoryThread): a trace down the page like a track on a + * circuit board. Indigo on light sections, light on dark ones. */ -.story-thread path { - stroke-width: 1.5; - stroke-linecap: round; - stroke-linejoin: round; +.story-thread { + --thread-ink: #4f46e5; + --thread-ground: #ffffff; } -.story-thread-light { - stroke: #4f46e5; +.story-thread-on-light { + --thread-ink: #4f46e5; + --thread-ground: #ffffff; } -.story-thread-dark { - stroke: #c7d2fe; - stroke-width: 2; +.story-thread-on-dark { + --thread-ink: #c7d2fe; + --thread-ground: #0f172a; } -.story-thread-dot { - width: 12px; - height: 12px; - margin: -6px 0 0 -6px; - border-radius: 9999px; +.story-thread path, +.story-thread line { + stroke: var(--thread-ink); + stroke-width: 1.75; + stroke-linecap: square; + stroke-linejoin: miter; +} +/* The second track of the bus. */ +.story-thread path.story-thread-bus { + stroke-width: 1; + opacity: 0.38; +} +/* Solder points where the track bends or branches. */ +.story-thread-pad { + fill: var(--thread-ground); + stroke: var(--thread-ink); + stroke-width: 1.75; + opacity: 0; + transition: opacity 250ms ease; +} +/* What makes a figure a component: its pins and its number in binary. */ +.story-thread-part { + opacity: 0; + transition: opacity 500ms ease 350ms; +} +.story-thread-part text { + fill: var(--thread-ink); + font-family: var(--font-jetbrains-mono), ui-monospace, monospace; + font-weight: 700; + letter-spacing: 0.14em; +} +/* The bit that travels at the head of the track. */ +.story-thread-bit { + display: grid; + place-items: center; + width: 18px; + height: 18px; + margin: -9px 0 0 -9px; + border-radius: 3px; background: #fde68a; + color: #0f172a; + font-family: var(--font-jetbrains-mono), ui-monospace, monospace; + font-size: 12px; + font-weight: 700; + line-height: 1; box-shadow: - 0 0 0 4px rgba(253, 230, 138, 0.28), - 0 0 18px 5px rgba(251, 191, 36, 0.85), - 0 0 44px 14px rgba(129, 140, 248, 0.55); + 0 0 0 3px rgba(253, 230, 138, 0.25), + 0 0 18px 4px rgba(251, 191, 36, 0.8), + 0 0 40px 12px rgba(129, 140, 248, 0.5); transition: opacity 300ms ease; will-change: transform; } diff --git a/apps/web/src/components/story/StoryThread.tsx b/apps/web/src/components/story/StoryThread.tsx index 062eec8..f333cae 100644 --- a/apps/web/src/components/story/StoryThread.tsx +++ b/apps/web/src/components/story/StoryThread.tsx @@ -2,7 +2,15 @@ import * as React from "react"; import { deepestSoFar, lengthAtDepth } from "@/src/domain/thread"; -import { threadPath, type Box, type Figure, type Stop } from "./threadPath"; +import { + FIGURE_CENTRE, + FIGURE_PINS, + threadPath, + type Box, + type Figure, + type Point, + type Stop, +} from "./threadPath"; /** Marks the box a chapter's figure stands in; the value is the figure. */ export const FIGURE_ATTRIBUTE = "data-thread-figure"; @@ -18,12 +26,31 @@ const READING_LINE = 0.66; /** How quickly the drawing catches up with the scroll position, per frame. */ const FOLLOW = 0.075; const CONTENT_WIDTH = 1400; +/** Offset of the second, parallel trace. */ +const BUS_OFFSET = 5; +const PAD_RADIUS = 4; +/** The bit at the head of the trace flips after this much travel, in px. */ +const FLIP_EVERY = 46; +/** The first chapter with a figure is the second of the page. */ +const FIRST_CHAPTER = 2; + +type Mark = { readonly x: number; readonly y: number; readonly dark: boolean }; + +type Part = { + readonly figure: Figure; + readonly box: Box; + readonly dark: boolean; + /** The chapter number as four binary digits. */ + readonly label: string; +}; type Layout = { readonly width: number; readonly height: number; readonly d: string; readonly dark: readonly Box[]; + readonly pads: readonly Mark[]; + readonly parts: readonly Part[]; }; const FIGURES: readonly Figure[] = [ @@ -37,18 +64,20 @@ const FIGURES: readonly Figure[] = [ const isFigure = (value: string | null): value is Figure => FIGURES.some((figure) => figure === value); +const inside = (box: Box, x: number, y: number): boolean => + x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height; + /** - * StoryThread: one line of light that runs down the whole homepage and draws - * itself as the visitor scrolls. Under each chapter's sentence it crosses the - * page, stands a figure on the line, and continues on the other side, so the - * eye is led from chapter to chapter. A glowing dot travels at its head. + * StoryThread: one trace that runs down the whole homepage like a track on a + * circuit board and draws itself as the visitor scrolls. Under each chapter's + * sentence it crosses the page, stands a component on the track, and continues + * on the other side, so the eye is led from chapter to chapter. Solder points + * mark its bends, a second track runs beside it, and a bit travels at its head. * Put it last inside the `relative` wrapper that holds the sections. */ export const StoryThread: React.FC = () => { const svgRef = React.useRef(null); - const lightRef = React.useRef(null); - const darkRef = React.useRef(null); - const dotRef = React.useRef(null); + const bitRef = React.useRef(null); const clipId = React.useId(); const [layout, setLayout] = React.useState(null); @@ -77,8 +106,7 @@ export const StoryThread: React.FC = () => { if (isFigure(figure) && box.width > 0) stops.push({ figure, box }); }); const startSection = wrapper.querySelector(`[${START_ATTRIBUTE}]`); - const first = stops[0]; - if (!startSection || !first) { + if (!startSection || stops.length === 0) { setLayout(null); return; } @@ -90,20 +118,37 @@ export const StoryThread: React.FC = () => { const right = Math.round(origin.width - margin); const endElement = wrapper.querySelector(`[${END_ATTRIBUTE}]`); const endBox = endElement ? boxOf(endElement) : null; + const dark = Array.from( + wrapper.querySelectorAll("section.bg-slate-900"), + ).map(boxOf); + const isDark = (x: number, y: number): boolean => + dark.some((box) => inside(box, x, y)); + const thread = threadPath({ + start: [left, boxOf(startSection).y + 1], + stops, + end: endBox ? [endBox.x, endBox.y] : null, + left, + right, + }); setLayout({ width: origin.width, height: origin.height, - d: threadPath({ - start: [left, boxOf(startSection).y], - stops, - end: endBox ? [endBox.x, endBox.y] : null, - left, - right, - }), - dark: Array.from(wrapper.querySelectorAll("section.bg-slate-900")).map( - boxOf, - ), + d: thread.d, + dark, + pads: thread.pads.map(([x, y]: Point) => ({ + x, + y, + dark: isDark(x, y), + })), + parts: stops.map((stop, index) => ({ + ...stop, + dark: isDark( + stop.box.x + stop.box.width / 2, + stop.box.y + stop.box.height / 2, + ), + label: (index + FIRST_CHAPTER).toString(2).padStart(4, "0"), + })), }); }; @@ -120,36 +165,52 @@ export const StoryThread: React.FC = () => { }; }, []); - // Draw as far as the visitor has read, and move the dot to the head of the line. + // Draw as far as the visitor has read, and move the bit to the head of the trace. React.useEffect(() => { const svg = svgRef.current; - const light = lightRef.current; - const dark = darkRef.current; - const dot = dotRef.current; - if (!layout || !svg || !light || !dark || !dot) return; + const bit = bitRef.current; + if (!layout || !svg || !bit) return; + const traces = Array.from( + svg.querySelectorAll("path[data-trace]"), + ); + const main = traces[0]; + if (!main) return; - const total = light.getTotalLength(); - const ys: number[] = []; + const total = main.getTotalLength(); + const points: DOMPoint[] = []; for (let at = 0; at <= total; at += SAMPLE_STEP) { - ys.push(light.getPointAtLength(at).y); - } - const depths = deepestSoFar(ys); - for (const path of [light, dark]) { - path.style.strokeDasharray = `${total}`; + points.push(main.getPointAtLength(at)); } + const depths = deepestSoFar(points.map((point) => point.y)); + for (const trace of traces) trace.style.strokeDasharray = `${total}`; + + // Solder points and components appear once the trace has reached them. + const reached = Array.from( + svg.querySelectorAll("[data-at-x]"), + ).map((element) => { + const x = Number(element.dataset.atX); + const y = Number(element.dataset.atY); + const distances = points.map((point) => + Math.hypot(point.x - x, point.y - y), + ); + const nearest = distances.indexOf(Math.min(...distances)); + return { element, length: nearest * SAMPLE_STEP }; + }); const show = (length: number) => { const offset = `${total - length}`; - light.style.strokeDashoffset = offset; - dark.style.strokeDashoffset = offset; - const head = light.getPointAtLength(length); - dot.style.transform = `translate(${head.x}px, ${head.y}px)`; - dot.style.opacity = length > 0 && length < total ? "1" : "0"; + for (const trace of traces) trace.style.strokeDashoffset = offset; + for (const mark of reached) { + mark.element.style.opacity = length + 2 >= mark.length ? "1" : "0"; + } + const head = main.getPointAtLength(length); + bit.style.transform = `translate(${head.x}px, ${head.y}px)`; + bit.style.opacity = length > 0 && length < total - 1 ? "1" : "0"; + bit.textContent = Math.floor(length / FLIP_EVERY) % 2 === 0 ? "1" : "0"; }; if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { show(total); - dot.style.opacity = "0"; return; } @@ -172,6 +233,9 @@ export const StoryThread: React.FC = () => { return () => window.cancelAnimationFrame(frame); }, [layout]); + const tone = (dark: boolean) => + dark ? "story-thread-on-dark" : "story-thread-on-light"; + return ( <> { ))} - + {/* The main track first: it is the one that is measured. */} + + + + + + + {layout.parts.map((part) => { + const place = ([ux, uy]: Point): Point => [ + part.box.x + (ux / 100) * part.box.width, + part.box.y + (uy / 100) * part.box.height, + ]; + const [labelX, labelY] = place(FIGURE_CENTRE[part.figure]); + return ( + + {FIGURE_PINS[part.figure].map(([from, to]) => { + const [x1, y1] = place(from); + const [x2, y2] = place(to); + return ( + + ); + })} + + {part.label} + + + ); + })} + + {layout.pads.map((pad) => ( + + ))} )}