diff --git a/frontend/apps/artcraft-website-next/src/components/landing/hero-galaxy.tsx b/frontend/apps/artcraft-website-next/src/components/landing/hero-galaxy.tsx index a9cdd82565..585047cb4a 100644 --- a/frontend/apps/artcraft-website-next/src/components/landing/hero-galaxy.tsx +++ b/frontend/apps/artcraft-website-next/src/components/landing/hero-galaxy.tsx @@ -453,6 +453,22 @@ function GalaxyScene({ })), [layout], ); + // Index order is arm-aligned (arms deal round-robin), so anything that + // reveals cards sequentially by index — the intro stagger, the governor + // regrowing shed cards — would paint aligned rings. This golden-ratio + // permutation scatters that order across arms and slots. + const liveOrder = useMemo(() => { + const idx = cards.map((_, i) => i); + idx.sort((a, b) => ((a * 0.618034) % 1) - ((b * 0.618034) % 1)); + return idx; + }, [cards]); + const liveRank = useMemo(() => { + const r = new Int32Array(cards.length); + liveOrder.forEach((ci, k) => { + r[ci] = k; + }); + return r; + }, [cards, liveOrder]); const materials = useMemo( () => cards.map( @@ -577,24 +593,28 @@ function GalaxyScene({ clipBest.fill(-1); // Perf governor: EMA of the real frame time. Sustained drops below the - // FPS floor shed the highest-index cards (and their decode pressure) - // quickly; recovery regrows slowly so it never oscillates. + // FPS floor shed cards (and their decode pressure) quickly; recovery + // regrows slowly so it never oscillates. The first seconds are a grace + // period — page load always stutters (shader compile, video priming) + // and shedding the intro looks like a broken spawn. st.frameEma += (Math.min(delta, 0.25) - st.frameEma) * 0.05; - if (mv.perfFloor > 0) { + if (mv.perfFloor > 0 && t > 4) { const budget = 1 / mv.perfFloor; if (st.frameEma > budget) { st.perfScale = Math.max(0.35, st.perfScale - dt * 0.3); } else if (st.frameEma < budget * 0.7) { st.perfScale = Math.min(1, st.perfScale + dt * 0.05); } - } else { + } else if (mv.perfFloor === 0) { st.perfScale = 1; } const liveN = Math.max(6, Math.round(cards.length * st.perfScale)); - // Pass 1: place every live card along its arm. All positions must be - // known before any card can size itself against its neighbors. - for (let i = 0; i < liveN; i++) { + // Pass 1: place every live card along its arm (in permuted order — the + // live set is the first liveN entries of liveOrder). All positions must + // be known before any card can size itself against its neighbors. + for (let k = 0; k < liveN; k++) { + const i = liveOrder[k]; const card = cards[i]; const c = cycle( (card.slot + 0.5) / L.slotsPerArm + P + card.arm * L.armJitter, @@ -618,7 +638,7 @@ function GalaxyScene({ for (let i = 0; i < cards.length; i++) { const mesh = cardRefs.current[i]; if (!mesh) continue; - if (i >= liveN) { + if (liveRank[i] >= liveN) { mesh.visible = false; continue; } @@ -641,7 +661,8 @@ function GalaxyScene({ // is eased so freed space fills organically; shrinking is instant so // the guarantee never breaks mid-frame. let sep = Infinity; - for (let j = 0; j < liveN; j++) { + for (let k = 0; k < liveN; k++) { + const j = liveOrder[k]; if (j === i) continue; const s = Math.max( (Math.abs(cardPos[j * 2] - x) * 9) / 16, @@ -673,7 +694,8 @@ function GalaxyScene({ const lifecycle = clamp01(c / lk.fadeBand); const solid = lk.washInner + (1 - lk.washInner) * c; const intro = clamp01( - (t - mv.introDelay - i * mv.introStagger) / Math.max(0.05, mv.introDur), + (t - mv.introDelay - liveRank[i] * mv.introStagger) / + Math.max(0.05, mv.introDur), ); const ready = videos[card.clip].readyState >= 2 ? 1 : 0; diff --git a/frontend/apps/artcraft-website-next/src/components/ruler/heading-flow.tsx b/frontend/apps/artcraft-website-next/src/components/ruler/heading-flow.tsx index 9cfff6bc45..44e5b19942 100644 --- a/frontend/apps/artcraft-website-next/src/components/ruler/heading-flow.tsx +++ b/frontend/apps/artcraft-website-next/src/components/ruler/heading-flow.tsx @@ -91,6 +91,8 @@ export default function HeadingFlow({ null, ); const wordRefs = useRef([]); + const topPoolRef = useRef(null); + const bottomPoolRef = useRef(null); const fs = useRef({ lastY: 0, vel: 0, @@ -240,6 +242,26 @@ export default function HeadingFlow({ else if (p.flipP > 0) flipShift += p.flipP; } + // Pool presence follows real occupancy: the top pool fades in as the + // first word (the wordmark) settles into the stack; the bottom pool + // fades out as the last queued word climbs onto the rail. The hero + // never holds the bottom pool — it has no queue home. + let topK = 0; + let botK = 0; + for (let wi = 0; wi < phases.length; wi++) { + if (phases[wi].flipP > topK) topK = phases[wi].flipP; + if (!sections[wi].isHero) { + const q = 1 - phases[wi].detachP; + if (q > botK) botK = q; + } + } + if (topPoolRef.current) { + topPoolRef.current.style.opacity = String(lk.poolAlpha * topK); + } + if (bottomPoolRef.current) { + bottomPoolRef.current.style.opacity = String(lk.poolAlpha * botK); + } + // Horizontal home for a word: letters run inward from the rail, with // the reading direction arranged so the word's tail sits nearest the // rail on a right-side ruler (and its head nearest on the left). @@ -594,46 +616,54 @@ export default function HeadingFlow({ layout.headingPx + pools.poolPad; const bottomPoolH = layout.queuePad + n * layout.queueSlot + pools.poolPad; + // Masks complete their fade INSIDE the pool's box (transparent by 97% of + // the box, not of an oversized ellipse), so the rectangle bounds never + // read as a cut. const poolMask = (cornerY: string) => - `radial-gradient(115% 115% at ${side === "right" ? "100%" : "0%"} ${cornerY}, black 45%, rgba(0,0,0,0.8) 62%, rgba(0,0,0,0.45) 78%, rgba(0,0,0,0.15) 91%, transparent 100%)`; + `radial-gradient(100% 100% at ${side === "right" ? "100%" : "0%"} ${cornerY}, black 38%, rgba(0,0,0,0.82) 56%, rgba(0,0,0,0.48) 72%, rgba(0,0,0,0.2) 86%, rgba(0,0,0,0.05) 94%, transparent 97%)`; return ( -
+ <> {/* Contrast pools: frosted page-bg fades pinned to the rail's top and bottom corners, so the top stack and the bottom queue always read over whatever content scrolls beneath them. Blur level matches the rail frost, so a letter flying between rail and stack stays in one - continuous frosted world. */} -
-
+ continuous frosted world. Below z-40: everything — rail, section + letters, AND the hero wordmark's z-40 spans — draws above them. + Opacity is occupancy-driven from the frame loop (starts empty). */} +
+
+
+
+
{sections.map((s, wi) => { const refs = (wordRefs.current[wi] ??= { letters: [], @@ -675,7 +705,8 @@ export default function HeadingFlow({ ); })} -
+
+ ); } diff --git a/frontend/apps/artcraft-website-next/src/components/ruler/ruler-tunables.ts b/frontend/apps/artcraft-website-next/src/components/ruler/ruler-tunables.ts index b8cedc25e8..9755da554b 100644 --- a/frontend/apps/artcraft-website-next/src/components/ruler/ruler-tunables.ts +++ b/frontend/apps/artcraft-website-next/src/components/ruler/ruler-tunables.ts @@ -363,6 +363,22 @@ export const rulerLookTuner = defineTunables("rulerLook", "Ruler look", { default: 1, info: "Strength of the page-bg contrast pools behind the top stack and the bottom queue (0 = none).", }, + frostSolid: { + label: "Frost solid %", + min: 0, + max: 80, + step: 5, + default: 40, + info: "Portion of the rail (from the outer edge) where the frost is at full strength before the feather begins.", + }, + frostGamma: { + label: "Frost ease", + min: 0.5, + max: 4, + step: 0.1, + default: 2.2, + info: "Falloff curve of the frost's inner feather — higher eases in longer toward the page (a slower, less visible tail).", + }, poolPad: { label: "Pool pad px", min: 0, diff --git a/frontend/apps/artcraft-website-next/src/components/ruler/scroll-ruler.tsx b/frontend/apps/artcraft-website-next/src/components/ruler/scroll-ruler.tsx index 0793ce811d..b66d576b17 100644 --- a/frontend/apps/artcraft-website-next/src/components/ruler/scroll-ruler.tsx +++ b/frontend/apps/artcraft-website-next/src/components/ruler/scroll-ruler.tsx @@ -550,9 +550,9 @@ export default function ScrollRuler() { > {/* Frost underlay: content flows under the rail (no reserved gutter); this pane blurs and tints whatever passes beneath so - the instrumentation always reads. The inner edge feathers on a - smoothstep-like curve — soft enough that letters flying in and - out never read a seam. */} + the instrumentation always reads. The inner feather follows a + tunable power curve easing in toward the outer edge, so no seam + reads while letters fly in and out. */}
@@ -720,3 +720,21 @@ export default function ScrollRuler() { ); } + +// The frost's inner feather: full strength for the first `solid` percent +// from the outer edge, then a power-curve falloff — alpha = (1 - x)^gamma +// over the remaining span, sampled into gradient stops. Higher gamma spends +// its fade early and approaches the page on a long, invisible tail. +function frostMask(side: RulerSide, solid: number, gamma: number): string { + const dir = side === "right" ? "to left" : "to right"; + const stops = [`black ${solid.toFixed(0)}%`]; + const steps = 8; + for (let k = 1; k < steps; k++) { + const x = k / steps; + const a = Math.pow(1 - x, gamma); + const pos = solid + x * (100 - solid); + stops.push(`rgba(0,0,0,${a.toFixed(3)}) ${pos.toFixed(1)}%`); + } + stops.push("transparent 100%"); + return `linear-gradient(${dir}, ${stops.join(", ")})`; +}