Compare commits

...
5 Commits
Author SHA1 Message Date
Tanish Manku 2b369cfae3 below-fold: fluid stage width + release slate strip
Stage width policy for everything under the poster: the content cap
is now a root token, --stage-max: clamp(1280px, 72vw, 1600px) —
sections genuinely widen on big monitors instead of donating
everything past 1280px to margin. All four cap sites (section shell,
hero bands, footer) consume the token; live-tunable via the new Stage
group (stage-width.tsx writes the root var on knob changes).

The announce strip becomes a release slate: "Open-source AI studio"
on the left; on the right a mono release stamp (New · 2026.09) and
model chips — Seedance 2.5, Nano Banana 2 — that deep-link into the
webapp surface where each model runs (+ more → the webapp). Data
lives in RELEASE_SLATE (landing-data.ts): one place to update per
release.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 19:58:19 -07:00
Tanish Manku d11a9dc65d hero: revert the slate/specimen experiment — the poster is settled
Scope correction: "under the wordmark" meant the announce strip
("Open-source AI studio") and everything below, not the hero poster's
own sales stack. The viewfinder slate, specimen headline, and proof
strip are removed and the hero returns to its settled form.

Kept from the detour: the pre-flight WebGL probe (real robustness
fix) and the 35-clip showcase pool.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 19:46:51 -07:00
Tanish Manku 26f1e29c23 hero: cut flank telemetry (filler on a product site); clip pool -> 35
The flank stat stacks are gone — live galaxy readouts expressed
nothing about ArtCraft to a visitor; the card spiral itself is the
expressive element, and this is a product site with a creative blend,
not a creative showcase. The telemetry channel shrinks to the scene
clock (still driving the slate timecode and doubling as the chrome's
liveness gate); the flank tunables and the galaxy's extra per-frame
publishes go with it.

Showcase pool: 17 -> 35 clips (001-035 all verified 200 on the CDN;
036 404s). At 51 cards that's ~1.5 cards per clip — most cards now
unique on screen.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 18:29:20 -07:00
Tanish Manku af55f7eb6f hero: pre-flight WebGL probe — context failure degrades silently
When WebGL context creation fails (exhausted drivers, blocklisted
GPU, webgl.disabled), THREE.WebGLRenderer throws inside r3f's async
init — an unhandled REJECTION that the render-phase CanvasBoundary
never catches, surfacing as a page error instead of the designed
static fallback.

Both r3f canvas gates (galaxy, comparator blocking scene) now consult
a shared cached probe (src/lib/webgl-support.ts) before mounting the
<Canvas>: no context available → the canvas never mounts, the page's
static layers stand, and the slate chrome already hides itself via
the dead-instrument rule. The probe releases its test context through
WEBGL_lose_context so it never eats a context slot.

Verified with Playwright Firefox: webgl.disabled=true → zero
canvases, zero page errors, copy/ruler fully functional; normal run →
both canvases mount, zero errors.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 18:18:25 -07:00
Tanish Manku 505c45ffdb hero: viewfinder slate — fluid specimen copy, live flank telemetry
The sales layer under the wordmark, redesigned against the
center-blob-with-scalable-margins failure mode: on wide stages the
composition now *uses* the width instead of donating it to margin.

- Fluid poster type: the headline/subline scale with the viewport
  (cqw-style clamp; knobs Headline max rem / vw slope) so their ratio
  to the ARTCRAFT wordmark is a designed constant at every width. The
  serif phrase breaks as a unit, giving a clean two-line lockup when
  the stage outgrows one line. Sizes ride CSS variables on a `contents`
  wrapper — RevealManager ends reveals with clearProps:"all", which
  wipes inline styles on the [data-reveal] elements themselves.

- Specimen life on the display letters (variable Archivo): an intro
  settle (letters arrive at the wordmark's poster extreme, wdth 125 /
  wght 700, and cascade down to the display setting on the copy beat;
  clock-driven so the tuner replay rewinds it) and a cursor field
  afterwards (gaussian width-bulge under the pointer — the galaxy's
  mouse-weight idea spoken in typography).

- Viewfinder slate chrome, the product's own metaphor (a viewport
  where shots are composed): corner framing brackets + a REC/SC/TC
  timecode line running on the galaxy's scene clock (lg+), and live
  telemetry chips at the far flanks (xl+) — cards riding, clips
  playing, FPS, spin θ, lens (boost) — real values published by the
  galaxy frame loop through src/lib/hero-telemetry.ts and written
  imperatively at a throttled rate. Chrome hides entirely while the
  galaxy isn't running (reduced motion, WebGL failure): a dead
  instrument gets no slate. Right edge clears the scroll ruler's rail.

- Proof line promoted to a full-width ruled strip at the poster's
  bottom edge (justify-between at md+) — the composition's horizontal
  anchor; extra right padding under an active ruler so it clears the
  section queue.

Everything is tunable under the new "Hero slate" group (documented in
TUNER.md). Verified via Playwright Firefox screenshots at 3360×1380
and 1440×860.

Claude-Session: https://claude.ai/code/session_013vwLpYJbWEqTJg9PGV6B6H
2026-09-14 18:06:43 -07:00
12 changed files with 137 additions and 22 deletions

No files matched your search

+5 -3
View File
@@ -66,10 +66,12 @@ Then read values where they're used:
`hero-galaxy.tsx`).
The panel picks up new groups automatically — no panel changes needed.
Registered groups: Intro (page-intro choreography beats; `src/lib/intro.ts`),
Registered groups: Stage (fluid content-stage width;
`src/components/stage-width.tsx`), Intro (page-intro choreography beats;
`src/lib/intro.ts`),
Wordmark (logo-A optics, blade tuck, contrast scrim;
`src/components/landing/hero-wordmark.tsx`), Galaxy layout, Galaxy motion,
Galaxy pointer, Galaxy look (hero galaxy;
`src/components/landing/hero-wordmark.tsx`), Galaxy layout, Galaxy
motion, Galaxy pointer, Galaxy look (hero galaxy;
`src/components/landing/hero-galaxy-tunables.ts`) and Ruler layout, Ruler
motion, Ruler look (scroll ruler;
`src/components/ruler/ruler-tunables.ts`).
@@ -104,6 +104,11 @@
html {
background: var(--bg);
/* Content stage width: 1280 on laptops, growing with the viewport toward
1600 on wide monitors — sections genuinely widen instead of donating
everything past 1280px to margin. Live-tunable (Stage group) via the
inline override written by stage-width.tsx. */
--stage-max: clamp(1280px, 72vw, 1600px);
}
body {
@@ -6,6 +6,7 @@ import {
Geist_Mono,
} from "next/font/google";
import SiteNav from "@/components/site-nav";
import StageWidth from "@/components/stage-width";
import SiteFooter from "@/components/site-footer";
import IntroConductor from "@/components/intro-conductor";
import MotionProvider from "@/components/motion-provider";
@@ -137,6 +138,7 @@ export default function RootLayout({
<ScrollRuler />
</MotionProvider>
<TunerPanel />
<StageWidth />
</body>
</html>
);
@@ -47,9 +47,6 @@ export const galaxyLayoutTuner = defineTunables(
min: 8,
max: 96,
step: 1,
// 3× the 17-clip showcase pool: the round-robin deal lands every clip
// on exactly three cards, and the neighbor-gap sizing grows the cards
// to keep the same fill.
default: 51,
info: "Cards riding the arms at the reference viewport size; smaller viewports scale this down proportionally (see Tuned @ Mpx).",
},
@@ -4,6 +4,7 @@ import { Component, useEffect, useMemo, useRef, useState, type ReactNode } from
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { SEEDANCE_SHOWCASE } from "@/lib/landing-data";
import { webglAvailable } from "@/lib/webgl-support";
import { introClock, introTuner } from "@/lib/intro";
import { watchThemeColors, type ThemeColors } from "@/lib/theme-colors";
import { useTunerStore } from "@/lib/tuner";
@@ -220,9 +221,12 @@ export default function HeroGalaxy() {
};
}, []);
// Gate: motion allowed and the tab actually foregrounded (a canvas born in
// a hidden tab can come up blank).
// Gate: WebGL actually available (a failed context creation inside r3f is
// an unhandled rejection CanvasBoundary can't catch), motion allowed, and
// the tab actually foregrounded (a canvas born in a hidden tab can come up
// blank).
useEffect(() => {
if (!webglAvailable()) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let raf = 0;
const tick = () => {
@@ -1265,6 +1269,7 @@ function GalaxyScene({
CULL_TICK_S,
);
}
});
return (
@@ -5,6 +5,7 @@ import { Component, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import LazyVideo from "@/components/lazy-video";
import { watchThemeColors, type ThemeColors } from "@/lib/theme-colors";
import { webglAvailable } from "@/lib/webgl-support";
// Plain code-split dynamic import — NOT `ssr: false`. It never renders during
// SSR (gated by `ready`, false on the server), so WebGL stays off the server
@@ -40,9 +41,12 @@ export default function HeroViewport({
const insideRef = useRef(false);
const targetPctRef = useRef(IDLE_CENTER);
// Gate: capable device, motion allowed, tab actually foregrounded.
// Gate: WebGL available (r3f's context-creation failure is an unhandled
// rejection CanvasBoundary can't catch — the footage below is the
// fallback), capable device, motion allowed, tab actually foregrounded.
useEffect(() => {
const capable =
webglAvailable() &&
window.matchMedia("(pointer: fine) and (min-width: 768px)").matches &&
window.matchMedia("(prefers-reduced-motion: no-preference)").matches;
if (!capable) return;
@@ -1,6 +1,6 @@
import { AppleIcon, MonitorIcon } from "lucide-react";
import { SOCIAL_LINKS, WEBAPP_URL } from "@/lib/links";
import { HERO_VIDEO_URL } from "@/lib/landing-data";
import { HERO_VIDEO_URL, RELEASE_SLATE } from "@/lib/landing-data";
import { GitHubIcon } from "@/components/icons";
import { Button } from "@/components/ui";
import HeroMasthead, { HeroScrim } from "./hero-wordmark";
@@ -23,7 +23,7 @@ export default function Hero() {
outrank the ruler's fixed z-39 contrast pools at root level.
No border-x here: the poster viewport stays immersive edge to
edge — the structural rails begin at the announce strip. */}
<div className="pointer-events-none relative z-40 mx-auto flex w-full max-w-[1280px] flex-1 flex-col items-center justify-center px-6 py-10 md:px-10">
<div className="pointer-events-none relative z-40 mx-auto flex w-full max-w-[var(--stage-max)] flex-1 flex-col items-center justify-center px-6 py-10 md:px-10">
<div
data-reveal-group
className="relative flex w-full max-w-2xl flex-col items-center text-center"
@@ -87,15 +87,31 @@ export default function Hero() {
</div>
</div>
<div className="relative mx-auto max-w-[1280px] border-x border-line">
{/* Announce strip — the fold line under the poster. */}
<div className="relative flex items-center justify-between gap-4 border-y border-line px-6 py-3 md:px-10">
<div className="relative mx-auto max-w-[var(--stage-max)] border-x border-line">
{/* Release slate — the fold line under the poster is the "what's
new" band: mono release stamp + model chips deep-linking into
the webapp surface where each model actually runs. */}
<div className="relative flex flex-wrap items-center justify-between gap-x-8 gap-y-2 border-y border-line px-6 py-2.5 md:px-10">
<span aria-hidden className="tick -top-[6px] -left-[6px]" />
<span aria-hidden className="tick -top-[6px] -right-[5px]" />
<p className="hud-label text-muted">Open-source AI studio</p>
<p className="hud-label text-accent-ink">
Now with Seedance 2.5, Nano Banana 2 &amp; more
</p>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<p className="hud-label text-accent-ink">
New · {RELEASE_SLATE.stamp}
</p>
{RELEASE_SLATE.models.map((m) => (
<a
key={m.name}
href={m.href}
className="invert-block hud-label border border-line-strong px-2.5 py-1 text-muted"
>
{m.name}
</a>
))}
<a href={WEBAPP_URL} className="hud-label text-faint hover:text-ink">
+ more
</a>
</div>
</div>
{/* Viewport frame — the product-demo stage: blocking vs. AI render. */}
@@ -16,7 +16,7 @@ export function SectionShell({
}) {
return (
<section id={id} className={`border-t border-line ${className}`}>
<div className="relative mx-auto max-w-[1280px] border-x border-line">
<div className="relative mx-auto max-w-[var(--stage-max)] border-x border-line">
{ticks && (
<>
<span aria-hidden className="tick -top-[6px] -left-[6px]" />
@@ -38,7 +38,7 @@ const LINK_COLUMNS: {
export default function SiteFooter() {
return (
<footer className="border-t border-line">
<div className="mx-auto max-w-[1280px] border-x border-line">
<div className="mx-auto max-w-[var(--stage-max)] border-x border-line">
<div className="grid gap-px bg-line md:grid-cols-[2fr_1fr_1fr_1fr]">
<div className="bg-bg p-6 md:p-10">
{/* eslint-disable-next-line @next/next/no-img-element */}
@@ -0,0 +1,41 @@
"use client";
import { useEffect } from "react";
import { defineTunables, useTunerStore } from "@/lib/tuner";
// Live control over the content stage's fluid width (see --stage-max in
// globals.css). Writes the root variable only when the knobs change — CSS
// does the actual responsive work.
const stageTuner = defineTunables("stage", "Stage", {
stageMax: {
label: "Stage max px",
min: 1280,
max: 1920,
step: 10,
default: 1600,
info: "Ceiling of the content stage's fluid width — reached on wide monitors.",
},
stageVw: {
label: "Stage vw %",
min: 50,
max: 100,
step: 1,
default: 72,
info: "How much of the viewport the stage tracks between the 1280px floor and the ceiling.",
},
});
export default function StageWidth() {
useEffect(() => {
const apply = () => {
const t = stageTuner.read();
document.documentElement.style.setProperty(
"--stage-max",
`clamp(1280px, ${t.stageVw}vw, ${t.stageMax}px)`,
);
};
apply();
return useTunerStore.subscribe(apply);
}, []);
return null;
}
@@ -1,4 +1,15 @@
import { cdnMediaUrl, mediaUrl } from "./links";
import { cdnMediaUrl, mediaUrl, webappUrl } from "./links";
// The release slate under the hero: the "what's new" band. `stamp` is the
// release period shown in mono; each model chip deep-links into the webapp
// surface where that model actually runs.
export const RELEASE_SLATE = {
stamp: "2026.09",
models: [
{ name: "Seedance 2.5", href: webappUrl("/create-video") },
{ name: "Nano Banana 2", href: webappUrl("/create-image") },
],
} as const;
// Product feature roster, ported from the shipping landing page. Copy is the
// approved marketing copy — edit deliberately, not casually.
@@ -77,12 +88,12 @@ export const HERO_VIDEO_URL =
// wall adapts to any count and mix of aspects, so the count below is the
// only thing to change when clips are added or dropped.
//
// Sources live on the FakeYou CDN as /videos/001.mp4 .. /videos/017.mp4 and
// Sources live on the FakeYou CDN as /videos/001.mp4 .. /videos/035.mp4 and
// reach the page through the same-origin /cdn-media proxy, since WebGL video
// textures require CORS-clean sources. When more clips are uploaded, raising
// this count is the only change needed — every card/clip ratio works (cards
// share clip textures; repeats are spread by least-recently-shown rebirth).
const SHOWCASE_CLIP_COUNT = 17;
const SHOWCASE_CLIP_COUNT = 35;
export type SeedanceClip = {
src: string;
@@ -0,0 +1,32 @@
// Pre-flight WebGL probe, shared by every r3f canvas gate.
//
// When context creation fails (exhausted drivers, blocklisted GPU,
// software-rendering-only environments), THREE.WebGLRenderer throws inside
// r3f's async init — an unhandled REJECTION that render-phase error
// boundaries (CanvasBoundary) never see. Probing before mounting the
// <Canvas> keeps the failure on our terms: the canvas simply never mounts
// and the page's static fallbacks stand. Result is cached — a probe is
// cheap but not free, and the answer doesn't change within a page's life.
let cached: boolean | null = null;
export function webglAvailable(): boolean {
if (cached !== null) return cached;
try {
const canvas = document.createElement("canvas");
const gl =
canvas.getContext("webgl2") ??
canvas.getContext("webgl") ??
canvas.getContext("experimental-webgl");
cached = !!gl;
// Release the probe context immediately rather than waiting for GC —
// browsers cap live contexts, and the real canvases want the slots.
if (gl && "getExtension" in (gl as WebGLRenderingContext)) {
(gl as WebGLRenderingContext)
.getExtension("WEBGL_lose_context")
?.loseContext();
}
} catch {
cached = false;
}
return cached;
}