From 0ac7391dcc31ee34d95e223af045712e7f100d96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 23:32:32 +0000 Subject: [PATCH 01/17] feat(marketing): camera story scroll hero on the homepage Port the design system's D3 "camera story" hero exploration into a HeroStory component: a pinned WebGL grid where the camera zooms into the server node, follows the wire and pulls back to the page as you scroll. On top of the exploration: - a live counter loop: server state changes flash the node, a labelled diff packet travels the wire and patches a real HTML card in the page - the card's Add one button sends an @click event up the wire and the diff comes back, the actual pywire round trip - HTML labels (server, browser, count = wire(n)) ride the camera - the Why pywire section rises over the dimmed last frame with a scrim - teardown/remount for Astro view transitions, reduced-motion still frames, no-WebGL grid fallback, portrait layout Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MRL8Z7p5CWKsXXXoi2mRG8 --- site/src/components/HeroStory.astro | 456 +++++++++++++++++++++++++ site/src/pages/index.astro | 241 ++++--------- site/src/scripts/hero-story/runtime.ts | 268 +++++++++++++++ site/src/scripts/hero-story/shader.ts | 148 ++++++++ site/src/scripts/hero-story/story.ts | 343 +++++++++++++++++++ 5 files changed, 1279 insertions(+), 177 deletions(-) create mode 100644 site/src/components/HeroStory.astro create mode 100644 site/src/scripts/hero-story/runtime.ts create mode 100644 site/src/scripts/hero-story/shader.ts create mode 100644 site/src/scripts/hero-story/story.ts diff --git a/site/src/components/HeroStory.astro b/site/src/components/HeroStory.astro new file mode 100644 index 0000000..7a2282d --- /dev/null +++ b/site/src/components/HeroStory.astro @@ -0,0 +1,456 @@ +--- +import { Link } from 'accessible-astro-components' +import { Icon } from 'astro-icon/components' + +/** + * HeroStory — "the camera story" from the design system's hero explorations. + * + * A pinned scroll story over a WebGL grid: the hero, then three steps (server, + * wire, browser) while the camera zooms into the server node, follows the wire + * and pulls back to the page. Once the page is up, a live counter runs the + * pywire loop and its button sends a real round trip down the wire. + * The section after it should overlap the last screen (see `.story-after`). + */ +--- + +
+
+ +
+ + + + +
+

Count: 0

+ +
+
+ +
+

+ The Live Conduit for Python +

+

HTML-over-the-wire without the JavaScript hangover.

+
+ + Get started +
+
+
+

01 · Server

+

State lives on the server

+

Variables wrapped in wire() become reactive data sources that drive UI updates.

+
+
+

02 · Wire

+

Diffs over the wire

+

When state changes, the server pushes a DOM diff over a WebSocket.

+
+
+

03 · Browser

+

The page patches in place

+

+ Each diff lands on the element it changed. Click Add one to send an event up the wire. +

+
+ +
+
+ + + + diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index 618f9ff..c089ef9 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -1,8 +1,8 @@ --- import DefaultLayout from '@layouts/DefaultLayout.astro' import Feature from '@components/Feature.astro' -import { Heading, Link } from 'accessible-astro-components' -import { Icon } from 'astro-icon/components' +import HeroStory from '@components/HeroStory.astro' +import { Heading } from 'accessible-astro-components' import { createHighlighter } from 'shiki' import pywireGrammar from '../../public/grammars/pywire.tmLanguage.json' @@ -37,152 +37,78 @@ const pywireHtmlLight = highlighter.codeToHtml(pywireCode, { --- -
- - -
-
-
-

- The Live
Conduit

for Python -

-

HTML-over-the-wire without the JavaScript hangover.

-
- - Get started -
@@ -158,38 +194,71 @@ import { Icon } from 'astro-icon/components' top: 0; left: 0; opacity: 0; - will-change: transform, opacity; } - .ov-label { - color: var(--text-decoration-color); - font-weight: 500; - font-size: 8px; + // Shared window chrome for the server card and the browser. + .srv, + .brw { + display: flex; + position: absolute; + flex-direction: column; + opacity: 0; + box-shadow: 0 12px 40px color-mix(in oklch, var(--background-color) 60%, transparent); + border: 1px solid color-mix(in oklch, var(--accent-color) 45%, var(--border-color)); + border-radius: 10px; + background: light-dark(var(--theme-light-bg), #11141f); + overflow: hidden; + } + + .win-bar { + display: flex; + flex: none; + align-items: center; + gap: 5px; + border-bottom: 1px solid var(--border-color); + background: light-dark(var(--theme-light-surface-2), #191c2a); + padding: 7px 10px; + font-size: 10px; font-family: var(--font-mono); - letter-spacing: 0.12em; + + i { + border-radius: 50%; + background: var(--border-color); + width: 8px; + height: 8px; + } + } + + .win-title { + color: var(--foreground-color); + } + + .win-tag { + margin-left: auto; + color: var(--accent-color); + font-size: 8.5px; + letter-spacing: 0.14em; text-transform: uppercase; - white-space: nowrap; } - .ov-server { - display: grid; - position: absolute; - top: 0; - left: 26px; - gap: 4px; - translate: 0 -50%; + .srv-led { + box-shadow: 0 0 6px var(--accent-color); + border-radius: 50%; + background: var(--accent-color); + width: 7px; + height: 7px; } - .chip { - box-shadow: 0 0 calc(var(--hit, 0) * 14px) color-mix(in oklch, var(--accent-color) 45%, transparent); - border: 1px solid color-mix(in oklch, var(--accent-color) calc(35% + var(--hit, 0) * 50%), var(--border-color)); - border-radius: 4px; - background: color-mix(in oklch, var(--background-color) 85%, transparent); - padding: 3px 7px; + .srv-code { + padding: 10px 12px; color: var(--foreground-color); - font-size: 10px; + font-size: 11px; + line-height: 1.6; font-family: var(--font-mono); - white-space: nowrap; + + > div { + white-space: pre; + } b { color: var(--accent-color); @@ -197,83 +266,194 @@ import { Icon } from 'astro-icon/components' } } - [data-ov='server'] { - --hit: 0; + .srv-state { + transition: background-color 0.4s; + margin: 0 -12px; + padding: 0 12px; } - // Portrait: the node sits centred at the top, so the chip goes above it. - :global([data-orient='portrait']) .ov-server { - left: 0; - justify-items: center; - translate: -50% calc(-100% - 22px); + .srv:global(.hit) .srv-state { + animation: line-hit 0.5s ease-out; } - .ov-browser { - position: absolute; - bottom: 6px; - left: 0; + @keyframes line-hit { + from { + background: color-mix(in oklch, var(--accent-color) 28%, transparent); + } } - .packet { - position: absolute; - bottom: 10px; - left: 0; - translate: -50% 0; - border: 1px solid color-mix(in oklch, var(--accent-color) 60%, transparent); - border-radius: 4px; - background: color-mix(in oklch, var(--background-color) 88%, transparent); - padding: 2px 6px; - color: var(--accent-color); - font-size: 10px; - font-family: var(--font-mono); - white-space: nowrap; + .c-f { + color: var(--text-decoration-color); + } - b { - font-weight: 600; - } + .c-k { + color: light-dark(#8839ef, #cba6f7); } - .packet-event { - color: var(--foreground-color); + .c-t { + color: light-dark(#1e66f5, #89b4fa); } - .ov-card { - --patch: 0; + .c-a { + color: light-dark(#df8e1d, #f9e2af); + } + + .brw { + pointer-events: auto; + } + + .brw-url { + flex: 1; + margin-left: 6px; + border-radius: 999px; + background: var(--background-color); + padding: 2px 10px; + max-width: 55%; + overflow: hidden; + color: var(--text-decoration-color); + text-overflow: ellipsis; + white-space: nowrap; + } + .brw-body { display: grid; - position: absolute; - place-content: center; + position: relative; + flex: 1; + align-content: center; justify-items: center; gap: 12px; - opacity: 0; - box-shadow: 0 0 calc(var(--patch) * 28px) color-mix(in oklch, var(--accent-color) 40%, transparent); - border: 1px solid color-mix(in oklch, var(--accent-color) calc(40% + var(--patch) * 60%), transparent); - border-radius: 4px; - background: color-mix(in oklch, var(--background-color) 70%, transparent); - pointer-events: auto; - text-align: center; + padding: 12px; + } + + // Faint page skeleton so the window reads as a page, not a dialog. + .brw-skel { + display: grid; + position: absolute; + top: 12px; + right: 12px; + left: 12px; + gap: 6px; + + i { + border-radius: 3px; + background: var(--border-color); + height: 6px; + } + + i:nth-child(2) { + width: 60%; + } } - .card-count { + .brw-count { margin: 0; color: var(--foreground-color); font-weight: 600; - font-size: 1.5rem; + font-size: 1.4rem; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; span { + display: inline-block; + border-radius: 4px; + padding: 0 3px; color: var(--accent-color); } + + // Fine-grained: only the text node that changed lights up. + span:global(.patched) { + animation: patched 0.6s ease-out; + } + } + + @keyframes patched { + from { + box-shadow: 0 0 0 1.5px var(--accent-color); + background: color-mix(in oklch, var(--accent-color) 25%, transparent); + } + } + + .brw-act { + position: relative; + } + + .ghost { + position: absolute; + top: 60%; + left: 70%; + opacity: 0; + transition: transform 0.12s; + pointer-events: none; + + svg { + fill: #fff; + stroke: #0a0c14; + } + + &:global(.down) { + transform: scale(0.82); + } + } + + .lbl { + display: block; + translate: -50% -50%; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--background-color); + padding: 1px 6px; + color: var(--text-decoration-color); + font-size: 9px; + font-family: var(--font-mono); + white-space: nowrap; + } + + .lbl-stack { + text-decoration: line-through; + text-decoration-color: color-mix(in oklch, var(--text-decoration-color) 70%, transparent); + } + + .lbl-tx { + translate: 0 calc(-100% - 4px); + transition: + color 0.2s, + border-color 0.2s; + } + + [data-ov='tx']:global(.on) .lbl-tx { + border-color: var(--accent-color); + color: var(--accent-color); + } + + :global([data-orient='portrait']) .lbl-tx { + translate: 6px -50%; + } + + .wchip { + display: block; + translate: -50% calc(-100% - 8px); + border: 1px solid color-mix(in oklch, var(--accent-color) 60%, transparent); + border-radius: 4px; + background: var(--background-color); + padding: 2px 7px; + color: var(--accent-color); + font-size: 10px; + font-family: var(--font-mono); + white-space: nowrap; + } + + :global([data-orient='portrait']) .wchip { + translate: 10px -50%; } .card-btn { + position: relative; transition: background-color var(--animation-speed-fast); cursor: pointer; border: 0; border-radius: 8px; background: var(--btn-bg); - padding: 0.5rem 0.9rem; + padding: 0.5rem 0.95rem; color: var(--btn-fg); font: 600 0.9rem var(--font-body); @@ -281,7 +461,8 @@ import { Icon } from 'astro-icon/components' background: var(--btn-bg-hover); } - &:active { + &:active, + &:global(.pressed) { transform: translateY(1px); } @@ -289,6 +470,21 @@ import { Icon } from 'astro-icon/components' outline: 2px solid var(--accent-color); outline-offset: 3px; } + + // After the scripted demo, invite the visitor to take over. + &:global(.your-turn) { + animation: your-turn 1.6s ease-out infinite; + } + } + + @keyframes your-turn { + from { + box-shadow: 0 0 0 0 color-mix(in oklch, var(--accent-color) 55%, transparent); + } + + to { + box-shadow: 0 0 0 12px transparent; + } } // --- Copy ------------------------------------------------------------- @@ -415,17 +611,21 @@ import { Icon } from 'astro-icon/components' background: color-mix(in oklch, var(--background-color) 60%, transparent) !important; } + // Outgoing copy clears quickly; incoming copy waits for it so two steps never overlap. .step { transform: translateY(16px); opacity: 0; transition: - opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), - transform 0.5s cubic-bezier(0.16, 1, 0.3, 1); + opacity 0.18s ease-in, + transform 0.18s ease-in; pointer-events: none; &:global(.on) { transform: none; opacity: 1; + transition: + opacity 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.16s, + transform 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.16s; pointer-events: auto; } } @@ -448,6 +648,10 @@ import { Icon } from 'astro-icon/components' } @media (prefers-reduced-motion: reduce) { + .card-btn:global(.your-turn) { + animation: none; + } + .step { transform: none; transition: none; diff --git a/site/src/pages/index.astro b/site/src/pages/index.astro index c089ef9..f2e5fe7 100644 --- a/site/src/pages/index.astro +++ b/site/src/pages/index.astro @@ -3,37 +3,6 @@ import DefaultLayout from '@layouts/DefaultLayout.astro' import Feature from '@components/Feature.astro' import HeroStory from '@components/HeroStory.astro' import { Heading } from 'accessible-astro-components' -import { createHighlighter } from 'shiki' -import pywireGrammar from '../../public/grammars/pywire.tmLanguage.json' - -const highlighter = await createHighlighter({ - themes: ['catppuccin-latte', 'catppuccin-mocha'], - langs: [ - 'python', - { - ...pywireGrammar, - name: 'pywire', - } as any, - ], -}) - -const pywireCode = `--- -count = wire(0) ---- -

Count: {count}

-` - -const pywireHtmlDark = highlighter.codeToHtml(pywireCode, { - lang: 'pywire', - theme: 'catppuccin-mocha', -}) - -const pywireHtmlLight = highlighter.codeToHtml(pywireCode, { - lang: 'pywire', - theme: 'catppuccin-latte', -}) --- @@ -44,39 +13,23 @@ const pywireHtmlLight = highlighter.codeToHtml(pywireCode, { The frontend ergonomics of Svelte. The simplicity of Python. -
-
-
- - - - counter.wire -
-
- -
-
- -
-
-
- - Wrap a value in wire() and any template that reads it updates automatically — no hooks, no virtual - DOM, no client routing. - - - .wire single-file components: Python on top of HTML. First-class JS and CSS in the same file. JS component - islands coming soon. - - - DOM diffs stream from the server over your transport of choice. No JSON APIs to invent, no client state to - sync. - -
+
+ + Wrap a value in wire() and any template that reads it updates automatically — no hooks, no virtual + DOM, no client routing. + + + .wire single-file components: Python on top of HTML. First-class JS and CSS in the same file. JS component + islands coming soon. + + + DOM diffs stream from the server over your transport of choice. No JSON APIs to invent, no client state to + sync. +
@@ -86,107 +39,21 @@ const pywireHtmlLight = highlighter.codeToHtml(pywireCode, { // The section after the story rises over its last, pinned screen: the // camera keeps pulling back and the grid dims behind this copy. .story-after { + display: grid; position: relative; + align-content: center; z-index: 2; margin-top: -100svh; background: linear-gradient( to bottom, transparent, - color-mix(in oklch, var(--background-color) 80%, transparent) 14%, - var(--background-color) 45% + color-mix(in oklch, var(--background-color) 80%, transparent) 18%, + var(--background-color) 50% ); padding: clamp(96px, 16svh, 180px) 0 var(--space-3xl); min-height: 100svh; } - .after-row { - display: grid; - grid-template-columns: 1fr; - align-items: start; - gap: var(--space-l); - margin-top: var(--space-l); - - @media (min-width: 1000px) { - grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); - } - } - - // macOS-chrome code window — adapts to Lab / Void per components/code-window. - // Lab: light surface, light syntax theme. Void: dark surface, dark syntax. - // Same recipe: 1px accent ring + soft accent-tint halo at matched intensity. - .code-window { - position: relative; - box-shadow: - 0 0 0 1px color-mix(in oklch, var(--accent-color) 30%, transparent), - 0 8px 24px color-mix(in oklch, var(--accent-color) 12%, transparent); - border: 1px solid var(--border-color); - border-radius: var(--radius-l); - background: light-dark(var(--theme-light-surface-2), #1c1f2e); - overflow: hidden; - font-family: var(--font-mono); - } - - .code-window-chrome { - display: flex; - align-items: center; - gap: 0.4rem; - border-bottom: 1px solid var(--border-color); - background: light-dark(var(--theme-light-surface), #252839); - padding: 0.65rem 0.9rem; - - .dot { - display: inline-block; - border-radius: 50%; - width: 11px; - height: 11px; - } - - .dot-red { - background: #ff5f57; - } - - .dot-yellow { - background: #febc2e; - } - - .dot-green { - background: #28c840; - } - - .filename { - margin-left: 0.5rem; - color: var(--text-decoration-color); - font-size: 0.78rem; - font-family: var(--font-mono); - } - } - - .code-window-body { - padding: 0.9rem 1.1rem; - font-size: 0.85rem; - line-height: 1.6; - - :global(pre) { - margin: 0; - background: transparent !important; - overflow-x: auto; - } - } - - // Theme-paired bodies: only one renders depending on user's color scheme. - // Defeats Astro's view-transitions hydration timing on theme swap. - .code-window-body--dark { - display: none; - } - - :root.darkmode .code-window-body--dark { - display: block; - } - - :root.darkmode .code-window-body--light { - display: none; - } - :global(.features-h2) { margin: 0.5rem 0 var(--space-l); max-width: 38ch; @@ -196,7 +63,12 @@ const pywireHtmlLight = highlighter.codeToHtml(pywireCode, { .features-grid { display: grid; - gap: var(--space-s); + grid-template-columns: 1fr; + gap: var(--space-m); + + @media (min-width: 800px) { + grid-template-columns: repeat(3, 1fr); + } } .eyebrow { diff --git a/site/src/scripts/hero-story/runtime.ts b/site/src/scripts/hero-story/runtime.ts index f9d0354..6186dff 100644 --- a/site/src/scripts/hero-story/runtime.ts +++ b/site/src/scripts/hero-story/runtime.ts @@ -12,6 +12,8 @@ export interface HeroState { h: number scale: number light: boolean + /** Last pointer press on the canvas: normalized x, y and scene time. */ + zap: [number, number, number] } export interface HeroTheme { @@ -46,7 +48,18 @@ interface RunnerOptions { export function runHero(section: HTMLElement, canvas: HTMLCanvasElement, o: RunnerOptions) { const coarse = matchMedia('(pointer: coarse)').matches const reduce = reducedMotion() - const st: HeroState = { time: 0, dt: 0, mouse: [0.5, 0.5], scroll: 0, px: 1, w: 1, h: 1, scale: 1, light: isLight() } + const st: HeroState = { + time: 0, + dt: 0, + mouse: [0.5, 0.5], + scroll: 0, + px: 1, + w: 1, + h: 1, + scale: 1, + light: isLight(), + zap: [0.5, 0.5, -99], + } const target = [0.5, 0.5] const maxDpr = coarse ? 1.5 : 2 let visible = false @@ -147,6 +160,13 @@ export function runHero(section: HTMLElement, canvas: HTMLCanvasElement, o: Runn target[1] = 1 - (e.clientY - r.top) / r.height lastMove = performance.now() } + const onDown = (e: PointerEvent) => { + onPtr(e) + // Snap the smoothed pointer so the discharge starts where the press was. + st.mouse[0] = target[0] + st.mouse[1] = target[1] + st.zap = [target[0], target[1], st.time] + } const onTheme = () => { st.light = isLight() if (!raf) still() @@ -161,7 +181,7 @@ export function runHero(section: HTMLElement, canvas: HTMLCanvasElement, o: Runn document.addEventListener('visibilitychange', onVis) reduce.addEventListener('change', onReduce) section.addEventListener('pointermove', onPtr, { passive: true }) - section.addEventListener('pointerdown', onPtr, { passive: true }) + section.addEventListener('pointerdown', onDown, { passive: true }) addEventListener('scroll', onScroll, { passive: true }) measure() @@ -177,7 +197,7 @@ export function runHero(section: HTMLElement, canvas: HTMLCanvasElement, o: Runn document.removeEventListener('visibilitychange', onVis) reduce.removeEventListener('change', onReduce) section.removeEventListener('pointermove', onPtr) - section.removeEventListener('pointerdown', onPtr) + section.removeEventListener('pointerdown', onDown) removeEventListener('scroll', onScroll) }, } diff --git a/site/src/scripts/hero-story/shader.ts b/site/src/scripts/hero-story/shader.ts index 1a9732a..d353e98 100644 --- a/site/src/scripts/hero-story/shader.ts +++ b/site/src/scripts/hero-story/shader.ts @@ -1,148 +1,173 @@ -// Camera story fragment shader (ported from the design system's D3 exploration). +// "The live conduit" fragment shader. // -// One full-screen pass draws the whole scene in world space (CSS px, origin -// bottom-left) seen through a 2D camera `u_cam` = (x, y, zoom). `u_s` (0..1) -// walks the story: -// 0 ambient grid with signals running along random lines -// 0.2 signals converge on the server node -// 0.5 a routed wire draws from the node to the page -// 0.75 diffs radiate from the wire's port and patch a page layout into the grid -// On top of that, the live counter loop drives three extra uniforms: -// u_pk distance along the wire of the diff packet (x, server -> browser) -// and the event packet (y, browser -> server); < 0 means none -// u_hit server node flash, 1 on a state change, decays to 0 -// u_patch live card flash, 1 when a diff lands, decays to 0 +// One full-screen pass draws the circuit board behind the hero story in world +// space (CSS px, origin bottom-left), seen through a 2D camera `u_cam` = +// (x, y, zoom). The server card and browser window are HTML laid over the top; +// the shader draws everything between and around them: +// - the grid, ambient signals, and the cursor "probe": current flows along +// the traces toward the pointer, and a press discharges along them +// - the server and browser footprints, which "compile" out of grid cells as +// they appear (u_srvOn, u_brwOn) and glow when they fire (u_srvHit, u_brwHit) +// - the conduit between them, drawn in (u_draw), the tangle of old-stack +// routes that merges into it (u_stack), and the three transport lanes it +// splits into (u_tx) +// - arcs: an event or a patch crossing the wire. u_arcUp / u_arcDn are +// (head distance, intensity, lane, unused); they cross in a few frames and +// light the whole wire at once, like current, instead of travelling. export const storyFrag = `precision highp float; uniform vec2 u_res;uniform float u_time;uniform vec2 u_mouse;uniform float u_light;uniform float u_px; uniform vec3 u_bg;uniform vec3 u_ink;uniform vec3 u_acc; -uniform float u_s;uniform float u_out;uniform vec2 u_node;uniform float u_nodeS;uniform vec4 u_panel;uniform vec2 u_ta;uniform vec2 u_tb; -uniform vec3 u_cam;uniform float u_vert; -uniform vec2 u_pk;uniform float u_hit;uniform float u_patch;uniform float u_live; +uniform vec3 u_cam;uniform float u_out;uniform float u_amb;uniform float u_probe;uniform vec3 u_zap; +uniform vec4 u_srv;uniform vec4 u_brw;uniform vec2 u_ta;uniform vec2 u_tb;uniform float u_vert; +uniform float u_srvOn;uniform float u_brwOn;uniform float u_srvHit;uniform float u_brwHit;uniform float u_conv; +uniform float u_draw;uniform vec2 u_stack;uniform float u_tx;uniform vec4 u_arcUp;uniform vec4 u_arcDn; const float C=32.0; float aa,W,Z; float h1(float n){return fract(sin(n*127.1+311.7)*43758.5453);} float h2(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);} float ln(float d,float w){return 1.0-smoothstep(w*W,w*W+aa,d);} vec2 segH(vec2 p,float x0,float x1,float y){float lo=min(x0,x1),hi=max(x0,x1);float dx=max(0.0,max(lo-p.x,p.x-hi));return vec2(length(vec2(dx,p.y-y)),clamp(abs(p.x-x0),0.0,hi-lo));} -// Distance to a three-segment routed wire A -> B, distance along it, total length. -vec3 lane(vec2 p,vec2 A,vec2 B){ - float mx=floor((A.x+B.x)*0.5/C+0.5)*C; - vec2 a=segH(p,A.x,mx,A.y);float l1=abs(mx-A.x); - vec2 b=segH(p.yx,A.y,B.y,mx);float l2=abs(B.y-A.y); - vec2 c=segH(p,mx,B.x,B.y);float l3=abs(B.x-mx); +// A three-segment routed wire A -> B, shifted sideways by o with its middle +// run moved by sh. Returns distance to it, distance along it, and its length. +vec3 lane(vec2 p,vec2 A,vec2 B,float o,float sh){ + float sx=B.x>=A.x?1.0:-1.0;float sy=B.y>=A.y?1.0:-1.0; + float mx=floor((A.x+B.x)*0.5/C+0.5)*C+sh-o*sx*sy; + float y0=A.y+o,y1=B.y+o; + vec2 a=segH(p,A.x,mx,y0);float l1=abs(mx-A.x); + vec2 b=segH(p.yx,y0,y1,mx);float l2=abs(y1-y0); + vec2 c=segH(p,mx,B.x,y1);float l3=abs(B.x-mx); vec3 r=vec3(a.x,a.y,0.0); if(b.xlo.x&&cc.y>lo.y&&cc.x0.0?r.y:r.z-r.y; + float lit=step(q,a.x)*a.y; + float core=exp(-abs(q-a.x)/6.0)*step(a.x,r.z-1.0)*a.y; + return max(lit*0.85,core*1.4); } void main(){ vec2 size=u_res/u_px;Z=u_cam.z;aa=1.0/(u_px*Z);W=0.5/Z; vec2 scr=gl_FragCoord.xy/u_px; vec2 css=(scr-size*0.5)/Z+u_cam.xy; - float s=u_s; - float p1=smoothstep(0.16,0.26,s); - float conv=p1*(1.0-smoothstep(0.46,0.56,s)); - float pT=smoothstep(0.46,0.56,s); - float draw=smoothstep(0.48,0.68,s); - float p3=smoothstep(0.72,0.8,s); - float grow=smoothstep(0.74,0.96,s); - float amb=1.0-0.7*p1; vec2 g=css/C;vec2 gi=floor(g);vec2 gf=fract(g); vec2 dl=min(gf,1.0-gf)*C; float gx=ln(dl.x,1.0),gy=ln(dl.y,1.0);float gridL=max(gx,gy);vec2 li=floor(g+0.5); float major=max(gx*step(mod(li.x,4.0),0.5),gy*step(mod(li.y,4.0),0.5)); - vec2 mc=(u_mouse*size-size*0.5)/Z+u_cam.xy;float md=length(css-mc)*Z; - float near=exp(-md*md/(2.0*150.0*150.0)); - float rr=mod(u_time*170.0,560.0); - float ring=exp(-pow((md-rr)/16.0,2.0))*(1.0-rr/560.0); + vec2 inset=step(vec2(3.0),gf*C)*step(gf*C,vec2(C-3.0));float insq=inset.x*inset.y; + vec2 cc=(gi+0.5)*C; + + // Ambient signals running along random grid lines. float cols=size.x/C;float rows=size.y/C; float sH=0.0,sV=0.0,hH=0.0,hV=0.0; float hr=h1(li.y); - if(hr>0.7){float head=(fract(u_time*(0.05+0.08*h1(li.y+17.0))+hr*9.0)*1.6-0.3)*cols; + if(hr>0.72){float head=(fract(u_time*(0.05+0.08*h1(li.y+17.0))+hr*9.0)*1.6-0.3)*cols; float x=hr>0.86?cols-g.x:g.x;float dx=head-x;float tail=dx>=0.0?exp(-dx*0.3):exp(dx*5.0); float dy=abs(g.y-li.y)*C;sH=tail*ln(dy,1.2);hH=tail*exp(-dy*0.3*Z);} float hc=h1(li.x+71.0); if(hc>0.86){float head=(fract(u_time*(0.05+0.07*h1(li.x+3.0))+hc*5.0)*1.6-0.3)*rows; float dy=head-(rows-g.y);float tail=dy>=0.0?exp(-dy*0.3):exp(dy*5.0); float dxl=abs(g.x-li.x)*C;sV=tail*ln(dxl,1.2);hV=tail*exp(-dxl*0.3*Z);} - vec2 inset=step(vec2(3.0),gf*C)*step(gf*C,vec2(C-3.0));float insq=inset.x*inset.y; - vec2 cc=(gi+0.5)*C; - // Server node: a small chip with a pulsing ring, halo and state-change shockwave. - vec2 dn=abs(css-u_node);float L1=dn.x+dn.y;float nb=max(dn.x,dn.y); + + // Cursor probe: the nearest grid node becomes a live pad and current flows + // toward it along the traces through it and their neighbours. + vec2 mc=(u_mouse*size-size*0.5)/Z+u_cam.xy; + vec2 pad=floor(mc/C+0.5)*C; + vec2 dp=css-pad; + float near=exp(-dot(css-mc,css-mc)/(2.0*170.0*170.0)); + float probe=0.0; + for(int k=-1;k<=1;k++){ + float fk=float(k);float wgt=k==0?1.0:0.45; + float ly=ln(abs(dp.y-fk*C),1.3),lx=ln(abs(dp.x-fk*C),1.3); + float fh=pow(fract(abs(dp.x)/56.0+u_time*1.7+fk*0.37),10.0)*exp(-abs(dp.x)/(k==0?260.0:150.0)); + float fv=pow(fract(abs(dp.y)/56.0+u_time*1.7+fk*0.61),10.0)*exp(-abs(dp.y)/(k==0?260.0:150.0)); + probe=max(probe,max(ly*fh,lx*fv)*wgt); + } + float padR=ln(abs(max(abs(dp.x),abs(dp.y))-5.0),1.3); + float padF=1.0-smoothstep(2.5,2.5+aa,max(abs(dp.x),abs(dp.y))); + probe=max(probe,max(padR,padF)*(0.75+0.25*sin(u_time*6.0))); + probe*=u_probe; + + // Press discharge: a pulse races out along the traces from where you pressed. + float zap=0.0; + float age=u_time-u_zap.z; + if(age>=0.0&&age<1.2){ + vec2 zp=floor(u_zap.xy/C+0.5)*C;vec2 dz=css-zp; + float front=age*900.0;float fade=exp(-age*3.2); + float cross=max(ln(abs(dz.y),1.6)*exp(-abs(abs(dz.x)-front)/14.0),ln(abs(dz.x),1.6)*exp(-abs(abs(dz.y)-front)/14.0)); + float L1=abs(dz.x)+abs(dz.y); + float dia=gridL*exp(-abs(L1-front*0.7)/10.0)*exp(-L1/500.0); + zap=(cross+dia*0.8)*fade; + } + + // Server and browser footprints. + float sdS=sdBox(css,u_srv);float sdB=sdBox(css,u_brw); + float inS=step(sdBox(cc,u_srv),0.0);float inB=step(sdBox(cc,u_brw),0.0); + float tS=h2(gi)*0.55;float onS=smoothstep(tS,tS+0.25,u_srvOn); + float fS=inS*insq*onS*(1.0-smoothstep(tS+0.25,tS+0.55,u_srvOn)); + vec2 dpB=abs(cc-u_tb);float tB=clamp((dpB.x+dpB.y)/max(1.0,2.0*(u_brw.z+u_brw.w)),0.0,1.0)*0.6+h2(gi+3.0)*0.12; + float onB=smoothstep(tB,tB+0.2,u_brwOn); + float fB=inB*insq*onB*(1.0-smoothstep(tB+0.2,tB+0.5,u_brwOn)); + float haloS=exp(-max(sdS,0.0)/(26.0+30.0*u_srvHit))*u_srvOn*(0.25+0.9*u_srvHit)*step(0.0,sdS); + float haloB=exp(-max(sdB,0.0)/(26.0+30.0*u_brwHit))*u_brwOn*(0.2+0.9*u_brwHit)*step(0.0,sdB); + // Signals converging into the server while it compiles. + float Ls=max(abs(css.x-u_srv.x)-u_srv.z,0.0)+max(abs(css.y-u_srv.y)-u_srv.w,0.0); float sel=max(gx*step(0.4,h1(li.y+5.0)),gy*step(0.4,h1(li.x+9.0))); - float fall=0.3+0.7*exp(-L1/900.0); - float inW=pow(1.0-fract(L1/224.0+u_time*0.8),9.0)*sel*conv*fall*step(u_nodeS+4.0,nb); - float nFill=(1.0-smoothstep(u_nodeS,u_nodeS+aa,nb))*p1; - float nPulse=0.5+0.5*sin(u_time*(conv>0.5?5.0:3.0)); - float nEdge=ln(abs(nb-(u_nodeS+10.0)),1.2); - float nRing=nEdge*p1*(0.5+0.5*max(nPulse,u_hit)); - float shock=ln(abs(nb-(u_nodeS+12.0+(1.0-u_hit)*56.0)),1.4)*u_hit*u_hit*p1; - float halo=exp(-L1/70.0)*p1*(1.0+1.6*u_hit); - // The wire, its ambient traffic and the live loop's packets. - float trL=0.0,trP=0.0,trG=0.0; - if(pT>0.001){ + float inW=pow(1.0-fract(Ls/224.0+u_time*0.8),9.0)*sel*u_conv*(0.3+0.7*exp(-Ls/700.0))*step(4.0,sdS); + + // Wires. + float wire=0.0,spark=0.0,glow=0.0,stack=0.0; + if(u_draw>0.001){ vec2 P=css,A=u_ta,B=u_tb;if(u_vert>0.5){P=P.yx;A=A.yx;B=B.yx;} - vec3 r=lane(P,A,B); - float head=r.z*draw; - float vis=step(r.y,head+0.5); - trL=ln(r.x,1.3)*vis; - float ph=fract(r.y/150.0-u_time*0.9+h1(4.0)); - float pk=pow(ph,14.0)*vis*pT*(1.0-0.8*u_live); - float tip=exp(-abs(r.y-head)/18.0)*(1.0-step(0.999,draw))*vis; - float dd=r.y-u_pk.x; - float diff=step(0.0,u_pk.x)*(exp(-abs(dd)/5.0)+step(dd,0.0)*exp(dd/90.0)*0.7); - float de=r.y-u_pk.y; - float ev=step(0.0,u_pk.y)*(exp(-abs(de)/5.0)+step(0.0,de)*exp(-de/90.0)*0.7); - float live=max(diff,ev)*vis; - trP=max(max(pk,tip)*ln(r.x,1.7),live*ln(r.x,2.4)); - trG=max(max(pk,tip),live*1.6)*exp(-r.x*0.22*Z); - trL*=pT; - } - // Page layout that the diffs patch into the grid. - float ins=0.0,edg=0.0,rid=-1.0; - if(p3>0.001){ - R(vec4(0.0,0.0,1.0,0.09),cc,css,0.0,ins,edg,rid); - R(vec4(0.0,0.14,0.2,0.86),cc,css,1.0,ins,edg,rid); - R(vec4(0.25,0.15,0.46,0.09),cc,css,2.0,ins,edg,rid); - R(vec4(0.25,0.29,0.7,0.05),cc,css,3.0,ins,edg,rid); - R(vec4(0.25,0.37,0.52,0.05),cc,css,4.0,ins,edg,rid); - R(vec4(0.25,0.5,0.33,0.5),cc,css,5.0,ins,edg,rid); - R(vec4(0.62,0.5,0.38,0.5),cc,css,6.0,ins,edg,rid); + for(int k=-1;k<=1;k++){ + float fk=float(k); + if(k!=0&&u_tx<0.001)continue; + vec3 r=lane(P,A,B,fk*C*u_tx,0.0); + float head=r.z*u_draw; + float vis=step(r.y,head+0.5)*(k==0?1.0:smoothstep(0.0,0.4,u_tx)); + wire=max(wire,ln(r.x,1.3)*vis); + float tip=exp(-abs(r.y-head)/16.0)*(1.0-step(0.999,u_draw))*vis; + float up=arc(r,u_arcUp,-1.0)*step(abs(u_arcUp.z-fk),0.5); + float dn=arc(r,u_arcDn,1.0)*step(abs(u_arcDn.z-fk),0.5); + float a=max(max(up,dn),tip)*vis; + spark=max(spark,a*ln(r.x,2.2)); + glow=max(glow,a*exp(-r.x*0.2*Z)); + } + // The old stack: alternate routes that merge into the one conduit. + if(u_stack.x>0.001){ + float m=u_stack.y; + for(int k=0;k<3;k++){ + float fk=float(k); + float o=(fk==0.0?-48.0:fk==1.0?40.0:88.0)*(1.0-m); + float sh=(fk==0.0?-128.0:fk==1.0?96.0:192.0)*(1.0-m); + vec3 r=lane(P,A,B,o,sh); + float dash=step(0.45,fract(r.y/12.0-u_time*(0.6+0.3*fk))); + stack=max(stack,ln(r.x,1.2)*dash); + } + stack*=u_stack.x*(1.0-m); + } } - float rad=grow*(size.x+size.y); - vec2 dp=abs(cc-u_tb);float since=rad-(dp.x+dp.y); - float rev=step(0.0,since);float flash=rev*exp(-max(since,0.0)/110.0); - vec2 de2=abs(css-u_tb);float erev=step(de2.x+de2.y,rad); - float card=step(5.5,rid); - // Patch flash sweeps across the live card from its left edge as the diff lands. - float sweep=0.0; - if(card>0.5){float x0=floor((u_panel.x+0.62*u_panel.z)/C+0.5)*C;float k=(cc.x-x0)/max(1.0,0.38*u_panel.z); - sweep=u_patch*smoothstep(0.35,0.0,abs(k-(1.0-u_patch)*1.2));} - float fill=ins*rev*insq*(0.08+0.5*flash+card*(0.05+0.35*u_patch+0.45*sweep))*p3; - float edgeA=edg*erev*p3*(0.7+0.3*card*(1.0+u_patch)); - float ph2=u_time*0.5+h2(gi)*20.0; - float fl=step(0.975,h2(gi+floor(ph2)*1.37))*pow(1.0-fract(ph2),2.0)*amb*insq*(1.0-ins); - vec3 sig=u_acc; + + float ph=u_time*0.5+h2(gi)*20.0; + float fl=step(0.975,h2(gi+floor(ph)*1.37))*pow(1.0-fract(ph),2.0)*insq*(1.0-inS*u_srvOn)*(1.0-inB*u_brwOn); vec3 col=u_bg; - float gA=mix(0.06,0.12,major)+near*0.3+ring*0.55*amb; - col=mix(col,mix(u_ink,u_acc,clamp(near*1.6+ring*2.0*amb,0.0,1.0)),gridL*clamp(gA,0.0,1.0)); - col=mix(col,u_acc,fl*0.16); - col=mix(col,u_acc,clamp(fill,0.0,1.0)); - col=mix(col,u_acc,clamp((sH+sV)*amb+edgeA,0.0,1.0)); - col=mix(col,sig,clamp(inW+trL*mix(0.5,0.35,p3)+trP,0.0,1.0)); - col=mix(col,sig,clamp(nFill+nRing+shock,0.0,1.0)); - // Additive glow only reads on the Void; on the Lab the packets carry by colour alone. - col+=(u_acc*(hH+hV)*amb*0.1+sig*(halo*0.18+trG*0.26))*(1.0-u_light); - col=mix(col,sig,clamp(trG*0.35*u_light,0.0,1.0)); + float gA=mix(0.06,0.12,major)+near*0.22*u_probe; + col=mix(col,mix(u_ink,u_acc,clamp(near*0.9*u_probe,0.0,1.0)),gridL*clamp(gA,0.0,1.0)); + col=mix(col,u_acc,fl*0.16*u_amb); + col=mix(col,u_acc,clamp((sH+sV)*u_amb*0.9,0.0,1.0)); + col=mix(col,u_ink,clamp(stack*0.45,0.0,1.0)); + col=mix(col,u_acc,clamp(inW+fS*0.7+fB*0.7,0.0,1.0)); + col=mix(col,u_acc,clamp(wire*0.45+spark,0.0,1.0)); + col=mix(col,u_acc,clamp(probe+zap,0.0,1.0)); + float halo=haloS+haloB; + col+=(u_acc*(hH+hV)*u_amb*0.1+u_acc*(halo*0.22+glow*0.35+probe*0.15+zap*0.2))*(1.0-u_light); + col=mix(col,u_acc,clamp((halo*0.12+glow*0.25)*u_light,0.0,1.0)); vec2 uv=gl_FragCoord.xy/u_res; float vg=smoothstep(1.25,0.4,length((uv-0.5)*vec2(1.3,1.0))); - col=mix(u_bg,col,mix(0.55,1.0,max(vg,p3*ins))); + col=mix(u_bg,col,mix(0.55,1.0,vg)); gl_FragColor=vec4(mix(col,u_bg,u_out),1.0); }` diff --git a/site/src/scripts/hero-story/story.ts b/site/src/scripts/hero-story/story.ts index 36deb98..950842c 100644 --- a/site/src/scripts/hero-story/story.ts +++ b/site/src/scripts/hero-story/story.ts @@ -1,16 +1,20 @@ -// "The camera story" hero: a pinned scroll story in four steps. The camera -// zooms into the server node, follows the wire and pulls back to the page, -// then a live counter runs the pywire loop for real: a click in the page -// sends an event up the wire, the server's state changes, and a diff comes -// back down and patches the card. +// "The live conduit" hero: a pinned scroll story over a circuit-board grid. +// 0 hero the cursor is a probe: current flows along the traces toward it +// 1 python the server card compiles out of the grid (a .wire component) +// 2 conduit the old stack's tangle of routes merges into one live wire +// 3 patch a click crosses the wire, the server bumps state, and only the +// changed text node patches; three scripted clicks, then yours +// 4 transport the conduit splits into WebSocket, WebTransport, long polling +// The camera frames each beat; HTML cards ride the same camera transform. import { createShaderHero, reducedMotion, type HeroState } from './runtime' import { storyFrag } from './shader' type V2 = [number, number] +/** Center x, y (world, origin bottom-left) and half width, half height. */ +type Rect = [number, number, number, number] interface Layout { - node: V2 - nodeS: number - panel: [number, number, number, number] + srv: Rect + brw: Rect ta: V2 tb: V2 vert: boolean @@ -19,43 +23,58 @@ interface Layout { const C = 32 const sn = (v: number) => Math.round(v / C) * C const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v) -const ss = (t: number) => t * t * (3 - 2 * t) -const TAIL = 1 / 4.5 // share of the scroll spent pulling the camera out -const stepOf = (s: number) => (s < 0.22 ? 0 : s < 0.5 ? 1 : s < 0.76 ? 2 : 3) +const ss = (t: number) => { + const x = clamp01(t) + return x * x * (3 - 2 * x) +} +const ramp = (s: number, a: number, d: number) => ss((s - a) / d) +const TAIL = 1 / 5.5 // share of the scroll spent pulling the camera out +const BEATS = [0.14, 0.36, 0.58, 0.8] +const stepOf = (s: number) => BEATS.filter((b) => s >= b).length +const ARC = 0.07 // seconds for an arc to cross the wire +const LANES = [-1, 0, 1] -// World space is CSS px of the stage with the origin at the bottom-left. -function layout(w: number, h: number, portrait: boolean): Layout { +function layout(w: number, h: number): Layout { + const portrait = w < 800 || w / h < 1.05 + // Positions are picked top-down, then flipped into world space. + const y = (top: number) => h - top if (portrait) { - const node: V2 = [sn(w * 0.5), sn(h * 0.84)] - const panel: Layout['panel'] = [w * 0.07, h * 0.4, w * 0.86, h * 0.34] + // Keep the diagram in the top half; the step copy owns the bottom. + const sw = Math.min(136, w * 0.4) + const srv: Rect = [sn(w * 0.5), sn(y(h * 0.17)), sw, 64] + const bw = Math.min(260, w * 0.43) + const bh = Math.min(110, h * 0.1) + const brw: Rect = [sn(w * 0.5), sn(y(h * 0.45)), bw, bh] return { - node, - nodeS: 6, - panel, - ta: [node[0], node[1] - 16], - tb: [sn(panel[0] + panel[2] * 0.3), sn(panel[1] + panel[3])], + srv, + brw, + ta: [srv[0] - C, srv[1] - srv[3]], + tb: [brw[0] + C, brw[1] + brw[3]], vert: true, } } - const node: V2 = [sn(w * 0.1), sn(h * 0.78)] - const panel: Layout['panel'] = [w * 0.5, h * 0.14, w * 0.43, h * 0.7] - return { - node, - nodeS: 6, - panel, - ta: [node[0] + 16, node[1]], - tb: [sn(panel[0]), sn(panel[1] + panel[3] * 0.62)], - vert: false, - } + const srv: Rect = [sn(w * 0.2), sn(y(h * 0.33)), 148, 80] + const bw = Math.min(210, w * 0.17) + const bh = Math.min(136, h * 0.17) + const brw: Rect = [sn(w * 0.7), sn(y(h * 0.4)), bw, bh] + return { srv, brw, ta: [srv[0] + srv[2], srv[1]], tb: [brw[0] - brw[2], brw[1]], vert: false } } -/** The wire as polyline points, matching `lane()` in the shader. */ -function wirePath(L: Layout): V2[] { +/** A lane as polyline points, matching `lane()` in the shader. */ +function lanePath(L: Layout, o: number, sh: number): V2[] { const sw = (p: V2): V2 => (L.vert ? [p[1], p[0]] : p) const A = sw(L.ta) const B = sw(L.tb) - const mx = Math.floor(((A[0] + B[0]) * 0.5) / C + 0.5) * C - return [A, [mx, A[1]], [mx, B[1]], B].map((p) => sw(p as V2)) + const sx = B[0] >= A[0] ? 1 : -1 + const sy = B[1] >= A[1] ? 1 : -1 + const mx = Math.floor(((A[0] + B[0]) * 0.5) / C + 0.5) * C + sh - o * sx * sy + const pts: V2[] = [ + [A[0], A[1] + o], + [mx, A[1] + o], + [mx, B[1] + o], + [B[0], B[1] + o], + ] + return pts.map(sw) } const pathLength = (pts: V2[]) => pts.slice(1).reduce((n, p, i) => n + Math.abs(p[0] - pts[i][0]) + Math.abs(p[1] - pts[i][1]), 0) @@ -73,14 +92,6 @@ function pointAt(pts: V2[], d: number): V2 { return pts[pts.length - 1] } -/** The live card, the page layout's bottom-right region, snapped like the shader's `R()`. */ -function cardRect(L: Layout) { - const [px, py, pw, ph] = L.panel - const lo: V2 = [sn(px + 0.62 * pw), sn(py)] - const hi: V2 = [Math.max(sn(px + pw), lo[0] + C), Math.max(sn(py + 0.5 * ph), lo[1] + C)] - return { lo, hi } -} - type Key = [number, number, number, number] function keyed(s: number, keys: Key[]): [number, number, number] { if (s <= keys[0][0]) return [keys[0][1], keys[0][2], keys[0][3]] @@ -102,91 +113,133 @@ function keyed(s: number, keys: Key[]): [number, number, number] { function camera(s: number, out: number, L: Layout, w: number, h: number): [number, number, number] { const c: V2 = [w / 2, h / 2] - // Portrait: frame the node a little lower so its chip (above it) stays in view. - const n: V2 = L.vert ? [L.node[0], L.node[1] + 24] : L.node - const zn = L.vert ? 2 : 2.4 - const m: V2 = [(L.ta[0] + L.tb[0]) / 2, (L.ta[1] + L.tb[1]) / 2] + const { srv, brw } = L + // Frame both cards; on landscape keep the pair in the top of the screen so + // the step copy has the bottom. + const both: V2 = L.vert ? c : [(srv[0] + brw[0]) / 2, h / 2] + const patch: V2 = L.vert ? c : [(srv[0] + brw[0]) / 2, (srv[1] + brw[1]) / 2 - 90] + const zs = L.vert ? 1.35 : 1.7 + const zp = L.vert ? 1.06 : 1.18 const k = keyed(s, [ - [0.1, ...c, 1], - [0.3, ...n, zn], - [0.44, ...n, zn], - [0.58, ...m, 1.4], - [0.68, ...m, 1.4], - [0.86, ...c, 1], + [0.06, ...c, 1], + [0.2, srv[0], srv[1] - 30, zs], + [0.32, srv[0], srv[1] - 30, zs], + [0.44, ...both, 1], + [0.56, ...both, 1], + [0.64, ...patch, zp], + [0.76, ...patch, zp], + [0.86, ...both, 1], ]) k[2] *= 1 - 0.3 * ss(out) return k } const UNIFORMS = [ - 'u_s', + 'u_cam', 'u_out', - 'u_node', - 'u_nodeS', - 'u_panel', + 'u_amb', + 'u_probe', + 'u_zap', + 'u_srv', + 'u_brw', 'u_ta', 'u_tb', - 'u_cam', 'u_vert', - 'u_pk', - 'u_hit', - 'u_patch', - 'u_live', + 'u_srvOn', + 'u_brwOn', + 'u_srvHit', + 'u_brwHit', + 'u_conv', + 'u_draw', + 'u_stack', + 'u_tx', + 'u_arcUp', + 'u_arcDn', ] +interface Arc { + t: number // seconds since it left + lane: number + i: number // peak intensity +} + export function mountStory(root: HTMLElement) { const canvas = root.querySelector('canvas')! const world = root.querySelector('[data-world]')! const steps = [...root.querySelectorAll('[data-step]')] const bar = root.querySelector('[data-progress]') - const el = (name: string) => root.querySelector(`[data-ov="${name}"]`)! - const ov = { - server: el('server'), - browser: el('browser'), - card: el('card'), - diff: el('diff'), - event: el('event'), - } - const serverCount = root.querySelector('[data-count="server"]')! - const browserCount = root.querySelector('[data-count="browser"]')! - const diffCount = root.querySelector('[data-count="diff"]')! - const button = root.querySelector('[data-add]')! + const q = (sel: string) => root.querySelector(sel)! + const srvEl = q('[data-ov="server"]') + const brwEl = q('[data-ov="browser"]') + const chip = q('[data-ov="chip"]') + const chipText = q('[data-chip-text]') + const ghost = q('[data-ghost]') + const stackEls = [...root.querySelectorAll('[data-ov="stack"]')] + const txEls = [...root.querySelectorAll('[data-ov="tx"]')] + const serverCount = q('[data-count="server"]') + const browserCount = q('[data-count="browser"]') + const button = q('[data-add]') const reduce = reducedMotion() - // Live loop state. let count = 0 - let shown = 0 - let diff = -1 // distance along the wire, server -> browser - let event = -1 // distance along the wire, browser -> server - let queued = 0 // clicks waiting to be sent - let hit = 0 - let patch = 0 - let auto = 1.2 - let live = false let geom = '' - let L = layout(1, 1, false) - let pts = wirePath(L) + let L = layout(1, 1) + let main = lanePath(L, 0, 0) let len = 1 let cur = -2 - let cardOn = false + let live = false + // Round trip: the event arc goes up, the server bumps state, the patch comes down. + let up: Arc | null = null + let dn: Arc | null = null + let queued = 0 + let srvHit = 0 + let brwHit = 0 + let chipT = 9 + let lane = 0 + let ping = 0 + // Scripted demo: three clicks a second apart the first time beat 3 is reached. + let demo = -1 // seconds into the demo, -1 = not started + let demoDone = false + let clicks = 0 - const setCount = (n: HTMLElement, v: number) => { - n.textContent = String(v) + const setText = (n: HTMLElement, v: string) => { + if (n.textContent !== v) n.textContent = v + } + const flash = (el: HTMLElement, cls: string) => { + el.classList.remove(cls) + void el.offsetWidth + el.classList.add(cls) } - const land = () => { - shown = count - setCount(browserCount, shown) - patch = 1 + const send = () => { + up = { t: 0, lane, i: 1 } + setText(chipText, '@click') + chipT = 0 } - const bump = () => { + const onServer = () => { count++ - setCount(serverCount, count) - setCount(diffCount, count) - hit = 1 + setText(serverCount, String(count)) + flash(srvEl, 'hit') + srvHit = 1 + dn = { t: -0.02, lane: up?.lane ?? lane, i: 1 } + } + const onBrowser = () => { + setText(browserCount, String(count)) + flash(browserCount, 'patched') + setText(chipText, `patch "${count}"`) + chipT = 0 + brwHit = 1 } - const place = (node: HTMLElement, x: number, y: number, h: number) => { - node.style.transform = `translate(${x}px, ${h - y}px)` + const place = (el: HTMLElement, x: number, y: number, h: number) => { + el.style.transform = `translate(${x}px, ${h - y}px)` + } + const size = (el: HTMLElement, r: Rect, h: number) => { + Object.assign(el.style, { + left: `${r[0] - r[2]}px`, + top: `${h - r[1] - r[3]}px`, + width: `${2 * r[2]}px`, + height: `${2 * r[3]}px`, + }) } const split = (p: number) => ({ s: Math.min(1, p / (1 - TAIL)), out: Math.max(0, (p - (1 - TAIL)) / TAIL) }) @@ -201,111 +254,146 @@ export function mountStory(root: HTMLElement) { if (bar) bar.style.transform = `scaleX(${s})` } + const stackShift = [ + [-48, -128], + [40, 96], + [88, 192], + ] + const frame = (st: HeroState, set: (n: string, ...v: number[]) => void) => { const { w, h, dt } = st - const portrait = w < 800 || w / h < 1.05 const key = `${w}x${h}` if (key !== geom) { geom = key - L = layout(w, h, portrait) - pts = wirePath(L) - len = pathLength(pts) - const { lo, hi } = cardRect(L) - Object.assign(ov.card.style, { - left: `${lo[0]}px`, - top: `${h - hi[1]}px`, - width: `${hi[0] - lo[0]}px`, - height: `${hi[1] - lo[1]}px`, + L = layout(w, h) + main = lanePath(L, 0, 0) + len = pathLength(main) + size(srvEl, L.srv, h) + size(brwEl, L.brw, h) + root.dataset.orient = L.vert ? 'portrait' : 'landscape' + const mid = pointAt(main, len / 2) + place(chip, mid[0], mid[1], h) + stackEls.forEach((el, k) => { + const pts = lanePath(L, stackShift[k][0], stackShift[k][1]) + const p = pointAt(pts, pathLength(pts) * 0.5) + place(el, p[0], p[1], h) + }) + txEls.forEach((el, k) => { + const pts = lanePath(L, LANES[k] * C, 0) + const p = pointAt(pts, L.vert ? 56 : 96) + place(el, p[0], p[1], h) }) - place(ov.server, L.node[0], L.node[1], h) - place(ov.browser, sn(L.panel[0]), sn(L.panel[1] + L.panel[3]), h) - root.dataset.orient = portrait ? 'portrait' : 'landscape' } const { s, out } = split(st.scroll) const [cx, cy, z] = camera(s, out, L, w, h) - // The loop runs once the wire is fully drawn and the page is showing. - live = s >= 0.7 && out < 0.85 + const srvOn = ramp(s, 0.14, 0.14) + const brwOn = ramp(s, 0.36, 0.1) + const draw = ramp(s, 0.4, 0.08) + const stackVis = ramp(s, 0.4, 0.04) + const merge = ramp(s, 0.47, 0.07) + const tx = ramp(s, 0.8, 0.06) + const conv = ramp(s, 0.12, 0.06) * (1 - ramp(s, 0.3, 0.06)) + live = s >= 0.56 && out < 0.9 + if (dt > 0) { - const speed = len / 1.1 + // Scripted demo clicks, then it's the visitor's turn. + if (live && !demoDone && s >= 0.6 && s < 0.8) { + demo = Math.max(demo, 0) + dt + const at = 0.6 + clicks + if (clicks < 3 && demo >= at) { + clicks++ + flash(button, 'pressed') + queued++ + } + if (demo >= 3.4) { + demoDone = true + button.classList.add('your-turn') + } + } + if (demo >= 0 && !demoDone) { + const t = demo - (0.6 + clicks - 1) + ghost.style.opacity = String(clamp01(demo * 4) * (s >= 0.6 && s < 0.8 ? 1 : 0)) + ghost.classList.toggle('down', clicks > 0 && t >= 0 && t < 0.15) + } else ghost.style.opacity = '0' + if (live) { - if (event >= 0) { - event -= speed * 1.25 * dt - if (event <= 0) { - event = -1 - bump() - diff = 0 + // In the transport beat, keepalive pings walk the three lanes. + if (tx > 0.5) { + ping -= dt + if (ping <= 0 && !up && !dn && queued === 0) { + lane = LANES[(LANES.indexOf(lane) + 1) % 3] + dn = { t: 0, lane, i: 0.55 } + ping = 0.8 } - } else if (queued > 0 && diff < 0) { + } else lane = 0 + if (queued > 0 && !up && !(dn && dn.i > 0.9)) { queued-- - event = len - } - if (diff >= 0) { - diff += speed * dt - if (diff >= len) { - diff = -1 - land() - } - } - auto -= dt - if (auto <= 0 && diff < 0 && event < 0 && queued === 0) { - bump() - diff = 0 - auto = 2.8 + send() } } else { - diff = -1 - event = -1 + up = dn = null queued = 0 - if (shown !== count) land() } - hit = Math.max(0, hit - dt * 1.6) - patch = Math.max(0, patch - dt * 1.3) + if (up) { + const before = up.t + up.t += dt + if (before < ARC && up.t >= ARC) onServer() + if (up.t > 0.4) up = null + } + if (dn) { + const before = dn.t + dn.t += dt + if (before < ARC && dn.t >= ARC && dn.i > 0.9) onBrowser() + if (dn.t > 0.4) dn = null + } + srvHit = Math.max(0, srvHit - dt * 2.2) + brwHit = Math.max(0, brwHit - dt * 2.2) + chipT += dt } + const arcU = (a: Arc | null): [number, number, number, number] => + a && a.t >= 0 + ? [(len * Math.min(a.t, ARC)) / ARC, a.i * Math.exp(-Math.max(0, a.t - ARC) * 6), a.lane, 0] + : [0, 0, 0, 0] - set('u_s', s) + set('u_cam', cx, cy, z) + // The press position is normalized to the stage; the discharge wants world space. + set('u_zap', (st.zap[0] - 0.5) * (w / z) + cx, (st.zap[1] - 0.5) * (h / z) + cy, st.zap[2]) set('u_out', out * 0.8) - set('u_node', ...L.node) - set('u_nodeS', L.nodeS) - set('u_panel', ...L.panel) + set('u_amb', 1 - 0.6 * srvOn) + set('u_probe', 1 - 0.45 * ramp(s, 0.1, 0.1)) + set('u_srv', ...L.srv) + set('u_brw', ...L.brw) set('u_ta', ...L.ta) set('u_tb', ...L.tb) - set('u_cam', cx, cy, z) set('u_vert', L.vert ? 1 : 0) - set('u_pk', diff, event) - set('u_hit', hit) - set('u_patch', patch) - set('u_live', live ? 1 : 0) + set('u_srvOn', srvOn) + set('u_brwOn', brwOn) + set('u_srvHit', srvHit) + set('u_brwHit', brwHit) + set('u_conv', conv) + set('u_draw', draw) + set('u_stack', stackVis, merge) + set('u_tx', tx) + set('u_arcUp', ...arcU(up)) + set('u_arcDn', ...arcU(dn)) // HTML overlays share the camera: one transform maps world to screen. world.style.transform = `translate(${w / 2 - cx * z}px, ${h / 2 - (h - cy) * z}px) scale(${z})` - const fadeOut = clamp01(1 - out * 2.2) - const grow = ss(clamp01((s - 0.74) / 0.22)) * (w + h) - const { lo, hi } = cardRect(L) - const cardDist = Math.abs((lo[0] + hi[0]) / 2 - L.tb[0]) + Math.abs((lo[1] + hi[1]) / 2 - L.tb[1]) - const cardA = clamp01((grow - cardDist) / 160) * fadeOut - ov.server.style.opacity = String(clamp01((s - 0.2) / 0.08) * fadeOut) - ov.browser.style.opacity = String(clamp01((s - 0.74) / 0.06) * fadeOut) - ov.card.style.opacity = String(cardA) - const on = cardA > 0.6 - if (on !== cardOn) { - cardOn = on - button.disabled = !on - ov.card.toggleAttribute('inert', !on) - } - ov.card.style.setProperty('--patch', patch.toFixed(3)) - ov.server.style.setProperty('--hit', hit.toFixed(3)) - for (const [node, d] of [ - [ov.diff, diff], - [ov.event, event], - ] as const) { - if (d >= 0) { - const [x, y] = pointAt(pts, d) - place(node, x, y, h) - node.style.opacity = String(fadeOut * clamp01(Math.min(d, len - d) / 60)) - } else node.style.opacity = '0' + const fade = clamp01(1 - out * 2.2) + srvEl.style.opacity = String(ramp(srvOn, 0.55, 0.45) * fade) + brwEl.style.opacity = String(ramp(brwOn, 0.5, 0.5) * fade) + const cardOn = brwOn > 0.9 && fade > 0.5 + if (button.disabled === cardOn) { + button.disabled = !cardOn + brwEl.toggleAttribute('inert', !cardOn) } - + chip.style.opacity = String(live ? clamp01(1 - (chipT - 0.5) / 0.4) * fade : 0) + stackEls.forEach((el) => (el.style.opacity = String(stackVis * (1 - merge) * fade))) + txEls.forEach((el, k) => { + el.style.opacity = String(ramp(tx, 0.4, 0.6) * fade) + el.classList.toggle('on', tx > 0.5 && (up?.lane ?? dn?.lane) === LANES[k]) + }) updateSteps(s, out) } @@ -323,15 +411,15 @@ export function mountStory(root: HTMLElement) { } const onAdd = () => { + button.classList.remove('your-turn') if (reduce.matches || !hero) { - // No animation: the round trip happens instantly. - bump() - land() + count++ + setText(serverCount, String(count)) + setText(browserCount, String(count)) hero?.still() return } - queued = Math.min(queued + 1, 3) - auto = 4 + queued = Math.min(queued + 1, 4) } button.addEventListener('click', onAdd) From f1c29ac012f5b43645781f00a662ca244241fb93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 00:52:58 +0000 Subject: [PATCH 03/17] feat(marketing): circuit-board hero, Next button, highlighted server code - Drop every pointer effect from the hero background. The board is now buses of traces routed with 45 degree jogs on two layers that conduct now and then; scrolling speeds up and brightens the conduction. - Add a Next pill that names the upcoming step and scrolls to it. - Render the server card's counter.wire with Shiki and the pywire grammar. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MRL8Z7p5CWKsXXXoi2mRG8 --- site/src/components/HeroStory.astro | 170 +++++++++++++++++++------ site/src/scripts/hero-story/runtime.ts | 38 +----- site/src/scripts/hero-story/shader.ts | 120 +++++++++-------- site/src/scripts/hero-story/story.ts | 43 ++++++- 4 files changed, 239 insertions(+), 132 deletions(-) diff --git a/site/src/components/HeroStory.astro b/site/src/components/HeroStory.astro index c143219..fb22fd8 100644 --- a/site/src/components/HeroStory.astro +++ b/site/src/components/HeroStory.astro @@ -2,12 +2,37 @@ import { Link } from 'accessible-astro-components' import { Icon } from 'astro-icon/components' -// The template half of counter.wire, as highlighted HTML (braces would read as -// Astro expressions if written inline). -const templateLines = [ - '<p>Count: {count}</p>', - '<button @click={count += 1}>', -] +import { createHighlighter } from 'shiki' +import pywireGrammar from '../../public/grammars/pywire.tmLanguage.json' + +// counter.wire for the server card, highlighted with the real .wire grammar in +// both catppuccin themes. The state line and its value are tagged so the story +// can flash the line and update the number live. +const highlighter = await createHighlighter({ + themes: ['catppuccin-latte', 'catppuccin-mocha'], + langs: ['python', { ...pywireGrammar, name: 'pywire' } as any], +}) +const counterCode = `--- +count = wire(0) +--- +

Count: {count}

+ @@ -251,28 +276,38 @@ const templateLines = [ .srv-code { padding: 10px 12px; - color: var(--foreground-color); font-size: 11px; line-height: 1.6; font-family: var(--font-mono); - > div { - white-space: pre; + :global(pre) { + margin: 0; + background: transparent !important; + overflow: visible; + } + + :global(code) { + font: inherit; } - b { - color: var(--accent-color); - font-weight: 600; + :global([data-count]) { + font-weight: 700; } } - .srv-state { - transition: background-color 0.4s; + // Shiki's dual themes: light colours inline, dark ones in --shiki-dark. + :global(:root.darkmode) .srv-code :global(.shiki span) { + color: var(--shiki-dark) !important; + } + + .srv-code :global(.srv-state) { + display: inline-block; margin: 0 -12px; padding: 0 12px; + width: calc(100% + 24px); } - .srv:global(.hit) .srv-state { + .srv:global(.hit) .srv-code :global(.srv-state) { animation: line-hit 0.5s ease-out; } @@ -282,22 +317,6 @@ const templateLines = [ } } - .c-f { - color: var(--text-decoration-color); - } - - .c-k { - color: light-dark(#8839ef, #cba6f7); - } - - .c-t { - color: light-dark(#1e66f5, #89b4fa); - } - - .c-a { - color: light-dark(#df8e1d, #f9e2af); - } - .brw { pointer-events: auto; } @@ -514,7 +533,7 @@ const templateLines = [ &.low { top: auto; - bottom: clamp(64px, 12svh, 140px); + bottom: clamp(84px, 13svh, 150px); } h2 { @@ -630,6 +649,78 @@ const templateLines = [ } } + // Tells you there is more below and what it is; clicking jumps to that beat. + .next { + display: inline-flex; + position: absolute; + // On the hero the stage still sits under the sticky nav, so its bottom + // edge is below the fold; lift the button until the story is pinned. + bottom: 76px; + left: 50%; + align-items: center; + gap: 8px; + translate: -50% 0; + backdrop-filter: blur(6px); + transition: + opacity 0.3s, + border-color var(--animation-speed-fast), + background-color var(--animation-speed-fast); + cursor: pointer; + border: 1px solid var(--border-color); + border-radius: 999px; + background: color-mix(in oklch, var(--background-color) 80%, transparent); + padding: 7px 12px 7px 14px; + max-width: calc(100% - 2 * var(--gut)); + color: var(--foreground-color); + font: 500 0.85rem var(--font-body); + white-space: nowrap; + + &:hover { + border-color: var(--accent-color); + background: var(--accent-bg-color); + } + + &:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 3px; + } + + &:global(.gone) { + opacity: 0; + pointer-events: none; + } + } + + :global([data-story]:not([data-step='0'])) .next { + bottom: 18px; + } + + .next-k { + color: var(--accent-color); + font-weight: 500; + font-size: 0.7rem; + font-family: var(--font-mono); + letter-spacing: 0.12em; + text-transform: uppercase; + } + + .next-label { + overflow: hidden; + text-overflow: ellipsis; + } + + :global(.next-icon) { + flex: none; + animation: nudge 1.8s ease-in-out infinite; + color: var(--accent-color); + } + + @keyframes nudge { + 50% { + transform: translateY(3px); + } + } + .progress { position: absolute; right: 0; @@ -648,7 +739,8 @@ const templateLines = [ } @media (prefers-reduced-motion: reduce) { - .card-btn:global(.your-turn) { + .card-btn:global(.your-turn), + :global(.next-icon) { animation: none; } diff --git a/site/src/scripts/hero-story/runtime.ts b/site/src/scripts/hero-story/runtime.ts index 6186dff..b3a9e71 100644 --- a/site/src/scripts/hero-story/runtime.ts +++ b/site/src/scripts/hero-story/runtime.ts @@ -1,19 +1,15 @@ // Shared runtime for the hero canvas: DPR cap, adaptive render scale, pause when -// offscreen or in a background tab, a still frame for reduced motion, and a -// smoothed pointer that drifts on its own while idle. +// offscreen or in a background tab, and a still frame for reduced motion. export interface HeroState { time: number dt: number - mouse: [number, number] scroll: number px: number w: number h: number scale: number light: boolean - /** Last pointer press on the canvas: normalized x, y and scene time. */ - zap: [number, number, number] } export interface HeroTheme { @@ -51,21 +47,17 @@ export function runHero(section: HTMLElement, canvas: HTMLCanvasElement, o: Runn const st: HeroState = { time: 0, dt: 0, - mouse: [0.5, 0.5], scroll: 0, px: 1, w: 1, h: 1, scale: 1, light: isLight(), - zap: [0.5, 0.5, -99], } - const target = [0.5, 0.5] const maxDpr = coarse ? 1.5 : 2 let visible = false let raf = 0 let last = 0 - let lastMove = -1e9 let accT = 0 let frames = 0 let lastAdapt = 0 @@ -95,14 +87,6 @@ export function runHero(section: HTMLElement, canvas: HTMLCanvasElement, o: Runn last = now st.dt = dt st.time += dt - const idle = now - lastMove > 2500 - if (idle) { - target[0] = 0.5 + 0.3 * Math.sin(st.time * 0.23) - target[1] = 0.5 + 0.22 * Math.sin(st.time * 0.17 + 1.3) - } - const k = 1 - Math.exp(-dt * (idle ? 1.2 : 5)) - st.mouse[0] += (target[0] - st.mouse[0]) * k - st.mouse[1] += (target[1] - st.mouse[1]) * k st.scroll = scrollP() o.render(st) // Adaptive resolution: drop render scale when frames run long, recover slowly. @@ -154,19 +138,6 @@ export function runHero(section: HTMLElement, canvas: HTMLCanvasElement, o: Runn start() still() } - const onPtr = (e: PointerEvent) => { - const r = canvas.getBoundingClientRect() - target[0] = (e.clientX - r.left) / r.width - target[1] = 1 - (e.clientY - r.top) / r.height - lastMove = performance.now() - } - const onDown = (e: PointerEvent) => { - onPtr(e) - // Snap the smoothed pointer so the discharge starts where the press was. - st.mouse[0] = target[0] - st.mouse[1] = target[1] - st.zap = [target[0], target[1], st.time] - } const onTheme = () => { st.light = isLight() if (!raf) still() @@ -180,8 +151,6 @@ export function runHero(section: HTMLElement, canvas: HTMLCanvasElement, o: Runn mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) document.addEventListener('visibilitychange', onVis) reduce.addEventListener('change', onReduce) - section.addEventListener('pointermove', onPtr, { passive: true }) - section.addEventListener('pointerdown', onDown, { passive: true }) addEventListener('scroll', onScroll, { passive: true }) measure() @@ -196,14 +165,12 @@ export function runHero(section: HTMLElement, canvas: HTMLCanvasElement, o: Runn mo.disconnect() document.removeEventListener('visibilitychange', onVis) reduce.removeEventListener('change', onReduce) - section.removeEventListener('pointermove', onPtr) - section.removeEventListener('pointerdown', onDown) removeEventListener('scroll', onScroll) }, } } -const COMMON_UNIFORMS = ['u_res', 'u_time', 'u_mouse', 'u_light', 'u_px', 'u_bg', 'u_ink', 'u_acc'] as const +const COMMON_UNIFORMS = ['u_res', 'u_time', 'u_light', 'u_px', 'u_bg', 'u_ink', 'u_acc'] as const /** * Compile a full-screen fragment shader onto `canvas` and run it. `frame` is @@ -265,7 +232,6 @@ export function createShaderHero( const T = themes[st.light ? 'light' : 'dark'] set('u_res', canvas.width, canvas.height) set('u_time', st.time) - set('u_mouse', st.mouse[0], st.mouse[1]) set('u_light', st.light ? 1 : 0) set('u_px', canvas.width / st.w) set('u_bg', ...T.bg) diff --git a/site/src/scripts/hero-story/shader.ts b/site/src/scripts/hero-story/shader.ts index d353e98..fe6beb0 100644 --- a/site/src/scripts/hero-story/shader.ts +++ b/site/src/scripts/hero-story/shader.ts @@ -4,8 +4,8 @@ // space (CSS px, origin bottom-left), seen through a 2D camera `u_cam` = // (x, y, zoom). The server card and browser window are HTML laid over the top; // the shader draws everything between and around them: -// - the grid, ambient signals, and the cursor "probe": current flows along -// the traces toward the pointer, and a press discharges along them +// - the grid and a board of short traces that conduct now and then; scrolling +// speeds up the clock that drives them (u_flow) and brightens them (u_energy) // - the server and browser footprints, which "compile" out of grid cells as // they appear (u_srvOn, u_brwOn) and glow when they fire (u_srvHit, u_brwHit) // - the conduit between them, drawn in (u_draw), the tangle of old-stack @@ -15,9 +15,9 @@ // (head distance, intensity, lane, unused); they cross in a few frames and // light the whole wire at once, like current, instead of travelling. export const storyFrag = `precision highp float; -uniform vec2 u_res;uniform float u_time;uniform vec2 u_mouse;uniform float u_light;uniform float u_px; +uniform vec2 u_res;uniform float u_time;uniform float u_light;uniform float u_px; uniform vec3 u_bg;uniform vec3 u_ink;uniform vec3 u_acc; -uniform vec3 u_cam;uniform float u_out;uniform float u_amb;uniform float u_probe;uniform vec3 u_zap; +uniform vec3 u_cam;uniform float u_out;uniform float u_amb;uniform float u_flow;uniform float u_energy; uniform vec4 u_srv;uniform vec4 u_brw;uniform vec2 u_ta;uniform vec2 u_tb;uniform float u_vert; uniform float u_srvOn;uniform float u_brwOn;uniform float u_srvHit;uniform float u_brwHit;uniform float u_conv; uniform float u_draw;uniform vec2 u_stack;uniform float u_tx;uniform vec4 u_arcUp;uniform vec4 u_arcDn; @@ -41,6 +41,8 @@ vec3 lane(vec2 p,vec2 A,vec2 B,float o,float sh){ if(c.x0.62)return vec3(0.0); + vec2 A=bo+vec2(1.0+floor(h2(bi+1.1)*8.0),1.0+floor(h2(bi+2.3)*8.0))+0.5; + vec2 B=bo+vec2(1.0+floor(h2(bi+4.7)*8.0),1.0+floor(h2(bi+6.1)*8.0))+0.5; + if(abs(B.x-A.x)+abs(B.y-A.y)<4.0)B.x=A.x=ad.y){float k=floor((ad.x-ad.y)*h2(bi+8.9)+0.5);P1=vec2(A.x+sg.x*k,A.y);P2=vec2(P1.x+sg.x*ad.y,B.y);} + else{float k=floor((ad.y-ad.x)*h2(bi+8.9)+0.5);P1=vec2(A.x,A.y+sg.y*k);P2=vec2(B.x,P1.y+sg.y*ad.x);} + vec2 s1=seg(g,A,P1),s2=seg(g,P1,P2),s3=seg(g,P2,B); + float l1=length(P1-A),l2=length(P2-P1),Lt=l1+l2+length(B-P2); + vec2 a0=A,a1=P1;vec2 r=s1; + if(s2.x=n)break;lines=max(lines,ln(abs(sdist-float(k)*SP),1.1));} + lines*=inner*step(-3.0,sdist); + vec2 dA=l1>0.01?P1-A:(l2>0.01?P2-P1:B-P2);vec2 dB=length(B-P2)>0.01?B-P2:(l2>0.01?P2-P1:P1-A); + vec2 nA=normalize(vec2(-dA.y,dA.x))*SP/C,nB=normalize(vec2(-dB.y,dB.x))*SP/C; + float pA=1e3,pB=1e3; + for(int k=0;k<3;k++){if(float(k)>=n)break;vec2 q=vec2(float(k)); + vec2 ea=abs(g-A-nA*q.x)*C;vec2 eb=abs(g-B-nB*q.x)*C; + pA=min(pA,max(ea.x,ea.y));pB=min(pB,max(eb.x,eb.y));} + float pads=max(ln(max(pA-1.8,0.0),1.0),ln(max(pB-1.8,0.0),1.0)); + // Conduction: each cycle a bus may fire, from either end. + float P=1.8+1.8*h2(bi+3.3);float cyc=u_flow/P+h2(bi+5.5);float cn=floor(cyc); + float age=fract(cyc)*P; + float fire=step(h2(bi+cn*1.7),0.3+0.55*u_energy); + float fwd=step(0.5,h2(bi+cn*2.9)); + float q=(fwd>0.5?r.y:Lt-r.y)*C;float L=Lt*C; + float head=age*1400.0;float done=L/1400.0; + float lit=step(q,head)*exp(-max(age-done,0.0)*2.2); + float core=exp(-abs(q-head)/5.0)*step(head,L); + float land=step(done,age)*exp(-(age-done)*3.0)*(fwd>0.5?ln(max(pB-1.8,0.0),1.0):ln(max(pA-1.8,0.0),1.0)); + float cur=fire*max(max(lit,core*1.4)*lines,land); + return vec3(max(lines,pads),cur,0.0); +} void main(){ vec2 size=u_res/u_px;Z=u_cam.z;aa=1.0/(u_px*Z);W=0.5/Z; vec2 scr=gl_FragCoord.xy/u_px; @@ -61,49 +113,6 @@ void main(){ vec2 inset=step(vec2(3.0),gf*C)*step(gf*C,vec2(C-3.0));float insq=inset.x*inset.y; vec2 cc=(gi+0.5)*C; - // Ambient signals running along random grid lines. - float cols=size.x/C;float rows=size.y/C; - float sH=0.0,sV=0.0,hH=0.0,hV=0.0; - float hr=h1(li.y); - if(hr>0.72){float head=(fract(u_time*(0.05+0.08*h1(li.y+17.0))+hr*9.0)*1.6-0.3)*cols; - float x=hr>0.86?cols-g.x:g.x;float dx=head-x;float tail=dx>=0.0?exp(-dx*0.3):exp(dx*5.0); - float dy=abs(g.y-li.y)*C;sH=tail*ln(dy,1.2);hH=tail*exp(-dy*0.3*Z);} - float hc=h1(li.x+71.0); - if(hc>0.86){float head=(fract(u_time*(0.05+0.07*h1(li.x+3.0))+hc*5.0)*1.6-0.3)*rows; - float dy=head-(rows-g.y);float tail=dy>=0.0?exp(-dy*0.3):exp(dy*5.0); - float dxl=abs(g.x-li.x)*C;sV=tail*ln(dxl,1.2);hV=tail*exp(-dxl*0.3*Z);} - - // Cursor probe: the nearest grid node becomes a live pad and current flows - // toward it along the traces through it and their neighbours. - vec2 mc=(u_mouse*size-size*0.5)/Z+u_cam.xy; - vec2 pad=floor(mc/C+0.5)*C; - vec2 dp=css-pad; - float near=exp(-dot(css-mc,css-mc)/(2.0*170.0*170.0)); - float probe=0.0; - for(int k=-1;k<=1;k++){ - float fk=float(k);float wgt=k==0?1.0:0.45; - float ly=ln(abs(dp.y-fk*C),1.3),lx=ln(abs(dp.x-fk*C),1.3); - float fh=pow(fract(abs(dp.x)/56.0+u_time*1.7+fk*0.37),10.0)*exp(-abs(dp.x)/(k==0?260.0:150.0)); - float fv=pow(fract(abs(dp.y)/56.0+u_time*1.7+fk*0.61),10.0)*exp(-abs(dp.y)/(k==0?260.0:150.0)); - probe=max(probe,max(ly*fh,lx*fv)*wgt); - } - float padR=ln(abs(max(abs(dp.x),abs(dp.y))-5.0),1.3); - float padF=1.0-smoothstep(2.5,2.5+aa,max(abs(dp.x),abs(dp.y))); - probe=max(probe,max(padR,padF)*(0.75+0.25*sin(u_time*6.0))); - probe*=u_probe; - - // Press discharge: a pulse races out along the traces from where you pressed. - float zap=0.0; - float age=u_time-u_zap.z; - if(age>=0.0&&age<1.2){ - vec2 zp=floor(u_zap.xy/C+0.5)*C;vec2 dz=css-zp; - float front=age*900.0;float fade=exp(-age*3.2); - float cross=max(ln(abs(dz.y),1.6)*exp(-abs(abs(dz.x)-front)/14.0),ln(abs(dz.x),1.6)*exp(-abs(abs(dz.y)-front)/14.0)); - float L1=abs(dz.x)+abs(dz.y); - float dia=gridL*exp(-abs(L1-front*0.7)/10.0)*exp(-L1/500.0); - zap=(cross+dia*0.8)*fade; - } - // Server and browser footprints. float sdS=sdBox(css,u_srv);float sdB=sdBox(css,u_brw); float inS=step(sdBox(cc,u_srv),0.0);float inB=step(sdBox(cc,u_brw),0.0); @@ -119,6 +128,14 @@ void main(){ float sel=max(gx*step(0.4,h1(li.y+5.0)),gy*step(0.4,h1(li.x+9.0))); float inW=pow(1.0-fract(Ls/224.0+u_time*0.8),9.0)*sel*u_conv*(0.3+0.7*exp(-Ls/700.0))*step(4.0,sdS); + // The board: buses of one to three traces between pads, routed with 45 degree + // jogs like a circuit board, on two layers (the lower one dimmer). Now and + // then a bus conducts and lights end to end almost at once. + vec3 top=bus(g,0.0),bot=bus(g,0.5); + float clr=mix(1.0,smoothstep(8.0,56.0,sdS),u_srvOn)*mix(1.0,smoothstep(8.0,56.0,sdB),u_brwOn); + float trace=max(top.x,bot.x*0.5)*clr; + float cur=max(top.y,bot.y*0.55)*clr*(0.8+0.5*u_energy); + // Wires. float wire=0.0,spark=0.0,glow=0.0,stack=0.0; if(u_draw>0.001){ @@ -155,16 +172,15 @@ void main(){ float ph=u_time*0.5+h2(gi)*20.0; float fl=step(0.975,h2(gi+floor(ph)*1.37))*pow(1.0-fract(ph),2.0)*insq*(1.0-inS*u_srvOn)*(1.0-inB*u_brwOn); vec3 col=u_bg; - float gA=mix(0.06,0.12,major)+near*0.22*u_probe; - col=mix(col,mix(u_ink,u_acc,clamp(near*0.9*u_probe,0.0,1.0)),gridL*clamp(gA,0.0,1.0)); + col=mix(col,u_ink,gridL*mix(0.06,0.12,major)); col=mix(col,u_acc,fl*0.16*u_amb); - col=mix(col,u_acc,clamp((sH+sV)*u_amb*0.9,0.0,1.0)); + col=mix(col,u_ink,clamp(trace*0.2*u_amb,0.0,1.0)); + col=mix(col,u_acc,clamp(cur*u_amb,0.0,1.0)); col=mix(col,u_ink,clamp(stack*0.45,0.0,1.0)); col=mix(col,u_acc,clamp(inW+fS*0.7+fB*0.7,0.0,1.0)); col=mix(col,u_acc,clamp(wire*0.45+spark,0.0,1.0)); - col=mix(col,u_acc,clamp(probe+zap,0.0,1.0)); float halo=haloS+haloB; - col+=(u_acc*(hH+hV)*u_amb*0.1+u_acc*(halo*0.22+glow*0.35+probe*0.15+zap*0.2))*(1.0-u_light); + col+=(u_acc*(halo*0.22+glow*0.35+cur*u_amb*0.18))*(1.0-u_light); col=mix(col,u_acc,clamp((halo*0.12+glow*0.25)*u_light,0.0,1.0)); vec2 uv=gl_FragCoord.xy/u_res; float vg=smoothstep(1.25,0.4,length((uv-0.5)*vec2(1.3,1.0))); diff --git a/site/src/scripts/hero-story/story.ts b/site/src/scripts/hero-story/story.ts index 950842c..8cd567d 100644 --- a/site/src/scripts/hero-story/story.ts +++ b/site/src/scripts/hero-story/story.ts @@ -31,6 +31,14 @@ const ramp = (s: number, a: number, d: number) => ss((s - a) / d) const TAIL = 1 / 5.5 // share of the scroll spent pulling the camera out const BEATS = [0.14, 0.36, 0.58, 0.8] const stepOf = (s: number) => BEATS.filter((b) => s >= b).length +// Where the next button lands for each step (story progress), and what it says. +const NEXT: [number, string][] = [ + [0.27, 'Write Python, not a frontend'], + [0.555, 'One conduit, no API layer'], + [0.67, 'Click. Patch. Done.'], + [0.9, "Any wire you've got"], + [-1, 'Why pywire'], +] const ARC = 0.07 // seconds for an arc to cross the wire const LANES = [-1, 0, 1] @@ -138,8 +146,8 @@ const UNIFORMS = [ 'u_cam', 'u_out', 'u_amb', - 'u_probe', - 'u_zap', + 'u_flow', + 'u_energy', 'u_srv', 'u_brw', 'u_ta', @@ -180,6 +188,8 @@ export function mountStory(root: HTMLElement) { const browserCount = q('[data-count="browser"]') const button = q('[data-add]') const reduce = reducedMotion() + const next = q('[data-next]') + const nextLabel = q('[data-next-label]') let count = 0 let geom = '' @@ -201,6 +211,10 @@ export function mountStory(root: HTMLElement) { let demo = -1 // seconds into the demo, -1 = not started let demoDone = false let clicks = 0 + let flow = 0 + let energy = 0 + let lastScroll = 0 + let scrollRange = 1 const setText = (n: HTMLElement, v: string) => { if (n.textContent !== v) n.textContent = v @@ -250,6 +264,9 @@ export function mountStory(root: HTMLElement) { cur = i steps.forEach((node, j) => node.classList.toggle('on', j === i)) root.dataset.step = String(i) + if (i >= 0) setText(nextLabel, NEXT[i][1]) + next.classList.toggle('gone', i < 0) + next.disabled = i < 0 } if (bar) bar.style.transform = `scaleX(${s})` } @@ -271,6 +288,7 @@ export function mountStory(root: HTMLElement) { size(srvEl, L.srv, h) size(brwEl, L.brw, h) root.dataset.orient = L.vert ? 'portrait' : 'landscape' + scrollRange = Math.max(1, root.offsetHeight - innerHeight) const mid = pointAt(main, len / 2) place(chip, mid[0], mid[1], h) stackEls.forEach((el, k) => { @@ -357,11 +375,15 @@ export function mountStory(root: HTMLElement) { : [0, 0, 0, 0] set('u_cam', cx, cy, z) - // The press position is normalized to the stage; the discharge wants world space. - set('u_zap', (st.zap[0] - 0.5) * (w / z) + cx, (st.zap[1] - 0.5) * (h / z) + cy, st.zap[2]) + // Scrolling energizes the board: its clock runs up to 3.5x faster and it glows. + const sv = dt > 0 ? (Math.abs(st.scroll - lastScroll) * scrollRange) / dt : 0 + lastScroll = st.scroll + energy += (Math.min(1, sv / 1400) - energy) * (1 - Math.exp(-dt * (sv > 0 ? 6 : 1.5))) + flow += dt * (1 + 2.5 * energy) + set('u_flow', flow) + set('u_energy', energy) set('u_out', out * 0.8) set('u_amb', 1 - 0.6 * srvOn) - set('u_probe', 1 - 0.45 * ramp(s, 0.1, 0.1)) set('u_srv', ...L.srv) set('u_brw', ...L.brw) set('u_ta', ...L.ta) @@ -423,8 +445,19 @@ export function mountStory(root: HTMLElement) { } button.addEventListener('click', onAdd) + const onNext = () => { + const [target] = NEXT[Math.max(0, cur)] + const r = root.getBoundingClientRect() + const range = r.height - innerHeight + const top = scrollY + r.top + const y = top + (target < 0 ? 1 : target * (1 - TAIL)) * range + scrollTo({ top: y, behavior: reduce.matches ? 'auto' : 'smooth' }) + } + next.addEventListener('click', onNext) + return () => { button.removeEventListener('click', onAdd) + next.removeEventListener('click', onNext) removeEventListener('scroll', onScroll) hero?.destroy() } From 553affae32cca95430eb6afbc471f2f1264814b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 01:10:12 +0000 Subject: [PATCH 04/17] feat(marketing): snap the hero story between steps, add the pywire chip - Between the hero and the end of the story, a scroll gesture (wheel, swipe or arrow keys) past a small threshold glides to the next step instead of scrolling freely. Reduced motion keeps native scrolling. - Next uses the same slower glide, and is bigger with an accent arrow. - Reveals (server compiling, browser, wire, merge, transport) keep their own pace, so a fast scroll can't rush them. - The hero's right side now has the pywire chip, its pins fanning out into traces that conduct into it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01MRL8Z7p5CWKsXXXoi2mRG8 --- site/src/components/HeroStory.astro | 75 +++++++++--- site/src/scripts/hero-story/shader.ts | 59 +++++++++- site/src/scripts/hero-story/story.ts | 163 ++++++++++++++++++++++++-- 3 files changed, 268 insertions(+), 29 deletions(-) diff --git a/site/src/components/HeroStory.astro b/site/src/components/HeroStory.astro index fb22fd8..0ecff29 100644 --- a/site/src/components/HeroStory.astro +++ b/site/src/components/HeroStory.astro @@ -3,6 +3,7 @@ import { Link } from 'accessible-astro-components' import { Icon } from 'astro-icon/components' import { createHighlighter } from 'shiki' +import PywireMark from '../assets/img/pywire-mark.svg' import pywireGrammar from '../../public/grammars/pywire.tmLanguage.json' // counter.wire for the server card, highlighted with the real .wire grammar in @@ -49,6 +50,9 @@ const counterHtml = highlighter.codeToHtml(counterCode, {
+