Compare commits
5
Commits
main
...
design/content
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b369cfae3 | ||
|
|
d11a9dc65d | ||
|
|
26f1e29c23 | ||
|
|
af55f7eb6f | ||
|
|
505c45ffdb |
No files matched your search
@@ -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 & 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;
|
||||
}
|
||||
Reference in new issue
Block a user