From dfbc443c2333e6c6576d4e7861ff1665744ac67a Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Thu, 10 Sep 2026 13:56:28 -0700 Subject: [PATCH] ruler+hero: pools under everything & occupancy-gated; scattered spawn order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contrast pools move to their own z-39 layer, so the hero wordmark's z-40 letters (and everything else — rail, section words) draw above them; their masks now complete the fade inside the pool's own bounds so the rectangle edge never reads as a cut. Pool presence follows real occupancy, driven from the frame loop: the top pool fades in as the wordmark settles into the stack, the bottom pool fades out as the last queued word climbs onto the rail. The rail frost's inner feather becomes a generated power-curve gradient easing in toward the outer edge, with Frost solid % and Frost ease knobs. And the galaxy's aligned-ring spawn is fixed: card index order is arm-aligned (round-robin deal), so the intro stagger and the governor's shed/regrow now walk a golden-ratio permutation instead — plus a 4s governor grace period over the load stutter, which was shedding the intro and regrowing it as visible rings. Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B --- .../src/components/landing/hero-galaxy.tsx | 42 ++++++-- .../src/components/ruler/heading-flow.tsx | 99 ++++++++++++------- .../src/components/ruler/ruler-tunables.ts | 16 +++ .../src/components/ruler/scroll-ruler.tsx | 28 +++++- 4 files changed, 136 insertions(+), 49 deletions(-) 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(", ")})`; +}