diff --git a/app/.env.example b/app/.env.example index 284fb94e..92461b96 100644 --- a/app/.env.example +++ b/app/.env.example @@ -46,6 +46,9 @@ LAUNCH_SYNC_CONFIRMATIONS=2 # Token logo uploads: "local" stores files in .uploads/ (dev). In production set the Tigris/S3 secrets # (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_ENDPOINT_URL_S3, AWS_REGION, BUCKET_NAME) instead. IMAGE_STORE=local +# The hero locker's coin wears a new launch's own picture (our images only, never a copy, never a look-alike name). Set to "off" +# to keep the coin plain (the caption still reacts). On Fly: `fly secrets set HERO_COIN_PICTURES=off` restarts the app, no deploy. +# HERO_COIN_PICTURES=off # Wallets allowed to moderate posts (comma-separated, lowercase) ADMIN_WALLETS= diff --git a/app/src/app/(home)/loading.tsx b/app/src/app/(home)/loading.tsx index 1dc8a34a..008c98d6 100644 --- a/app/src/app/(home)/loading.tsx +++ b/app/src/app/(home)/loading.tsx @@ -1,35 +1,99 @@ import { Sk, SkPost, SkRow } from "@/components/Skeleton"; +import { HERO } from "@/components/launchpad/hero-layout"; +import hero from "@/components/launchpad/LaunchHero.module.css"; +import machine from "@/components/launchpad/LaunchMachine.module.css"; +import totals from "@/components/launchpad/LaunchMechanism.module.css"; +import trend from "@/components/launchpad/TrendingStrip.module.css"; + +/** + * Home skeleton, in the page's own order: hero (text, the locker in its stage and the totals, in the real grid), the live + * line, Trending board, live panel, then the launches list with its side column. The hero's class strings come from + * hero-layout.ts and its grid, stage, locker and totals from the real components' stylesheets, so heights follow the + * real layout (including the short-screen compaction) instead of copying it. Only the Trending and live-panel blocks + * are sized by hand. + */ +/** One compact board card (a runner, or the leader below 1024px) at the heights the real card has. */ +function SkBoardCard() { + return ( +
+
+
+
+ ); +} -/** Home skeleton: hero + totals, list header, rows, side column. Same grid as page.tsx so nothing shifts. */ export default function Loading() { return ( -
-
-
-
- {[0, 1, 2].map((i) => )} +
+
+
+
+ + {/* the headline's own classes: each bar sits in one line of its line-height, so two bars are as tall as the two lines */} +
{[0, 1].map((i) => )}
+ {/* the copy's real wrap, in lines: 6 up to 380px, 5 up to 460px, then 4; beside the locker 6 at 768-790px, 5 to 950px, 4 to 1023px, + 5 again at 1024-1069px, then 4. The last placeholder is the short final line. */} +
+ {[ + { show: "flex" }, + { show: "flex" }, + { show: "flex" }, + { show: "hidden max-[459px]:flex min-[768px]:max-[949px]:flex min-[1024px]:max-[1069px]:flex" }, + { show: "hidden max-[380px]:flex min-[768px]:max-[790px]:flex" }, + { show: "flex", width: "w-2/3" }, + ].map((line, i) => )} +
+
+
{["w-[106px] sm:w-[122px]", "w-[96px] sm:w-[170px]", "w-[133px] sm:w-[152px]"].map((w) => )}
-
{[0, 1, 2].map((i) => )}
-
- -
-
-
-
-
-
{[0, 1, 2].map((i) => )}
-
- +
+
+
+
+
{[0, 1, 2].map((i) => )}
+
+ {/* the locker's proof links drop to a second row on the narrowest phones */} +
+
+
+
+
+
+ {[0, 1, 2, 3, 4].map((i) => ( +
+
+
+
+
+ ))} +
+
+
-
- -
{[0, 1, 2].map((i) =>
)}
- -
+
+
+ {/* Trending (TrendingStrip.tsx): a row to swipe below 1024px, a leader over four runners from there. The row's own stylesheet + class is used, so its padding, gap and card width are the real ones. */} +
+
+
    +
  1. +
    +
    +
    +
    +
    +
  2. + {[0, 1, 2, 3].map((i) =>
  3. )} +
+
+ {/* the live panel (LiveRiver.tsx): a rounded card, 510px on phones, 588px on tablets and 463px on desktop with its bubbles view open */} +
+
+
-
-
+
diff --git a/app/src/app/(home)/page.tsx b/app/src/app/(home)/page.tsx index a1e9ba6c..a9f2737f 100644 --- a/app/src/app/(home)/page.tsx +++ b/app/src/app/(home)/page.tsx @@ -6,17 +6,17 @@ import LiveRiver from "@/components/launchpad/LiveRiver"; import JustLaunched from "@/components/launchpad/JustLaunched"; import { PostsFeed } from "@/components/launchpad/Posts"; import { listFeed } from "@/lib/launchpad/postsServer"; -import { VOLUME_WINDOWS, getLaunchFeed, getTrending, isTrendingSource, listLaunchesPage, parseSort, trendingFrom, type VolumeWindow } from "@/lib/launchpad/queries"; +import { VOLUME_WINDOWS, getLaunchFeed, getTrending, isTrendingSource, listLaunchesPage, parseSort, trendingFrom, withBoardTape, type VolumeWindow } from "@/lib/launchpad/queries"; import { JUST_LAUNCHED_SIZE, PAGE_SIZE } from "@/lib/launchpad/paging"; import { ethUsd } from "@/lib/launchpad/ethPrice"; import { LAUNCHPAD_CONFIGURED, visibleChainOr } from "@/lib/launchpad/config"; import { dbConfigured } from "@/lib/db"; import { isFilter } from "@/lib/launchpad/search"; import { nowMs } from "@/lib/launchpad/time"; -import { inRiverWindow, riverCoverage } from "@/lib/launchpad/river"; +import { feedKey, inRiverWindow, riverCoverage } from "@/lib/launchpad/river"; import TrendingStrip from "@/components/launchpad/TrendingStrip"; +import LaunchPill from "@/components/launchpad/LaunchPill"; import WhyFree from "@/components/launchpad/WhyFree"; -import HeroBackdrop from "@/components/launchpad/HeroBackdrop"; import { MagicCard } from "@/components/vendor/magic-card"; import { LAYOUT_COOKIE, parseLayout } from "@/lib/launchpad/list-layout"; @@ -43,21 +43,16 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s listFeed(30), isTrendingSource(listOpts) ? null : getTrending(usd), ]); - const trending = fetchedTrending ?? trendingFrom(page.items); // the default view's first page doubles as the strip's candidates + const trending = await withBoardTape(fetchedTrending ?? trendingFrom(page.items)); // the default view's first page doubles as the strip's candidates; the tapes are the cards' recent trades const now = nowMs(); return ( -
- {/* the hero and the river share one full-bleed band; it clips its own backdrop so nothing widens the page */} -
- -
- - inRiverWindow(item, now))} recent={feed} serverNow={now} coveredSince={riverCoverage(feed, RIVER_SEED, now)} lastActivityAt={feed[0] ? new Date(feed[0].at).getTime() : 0} /> -
-
-
- + <> +
+ + {/* what is hot first (names and numbers to act on), then the last half hour as it happens, then every launch */} + item.kind === "swap").map(feedKey)} /> + inRiverWindow(item, now))} recent={feed} serverNow={now} coveredSince={riverCoverage(feed, RIVER_SEED, now)} lastActivityAt={feed[0] ? new Date(feed[0].at).getTime() : 0} />
-
-
+
+ {/* outside
: it is fixed to the screen, and as
's last child it switched the spacing of the grid above it + (space-y skips only the last child) every time it came or went, moving the page's height and the footer with it */} + + ); } diff --git a/app/src/app/globals.css b/app/src/app/globals.css index d4fbc062..14dbe376 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -371,6 +371,8 @@ input[type="number"] { -moz-appearance: textfield; appearance: textfield; } .bb-beacon::after { content: ""; position: absolute; inset: 0; border-radius: 9999px; background: var(--color-up); opacity: 0.55; animation: bb-beacon 2s cubic-bezier(0.2, 0.7, 0.3, 1) infinite; } @keyframes bb-beacon { 0% { transform: scale(1); opacity: 0.55; } 70% { transform: scale(2.8); opacity: 0; } 100% { transform: scale(2.8); opacity: 0; } } @media (prefers-reduced-motion: reduce) { .bb-beacon::after { animation: none; opacity: 0; } } +/* a container marked by usePauseOffscreen holds all of its animations while it is off screen, and resumes them where they were */ +[data-offscreen="true"], [data-offscreen="true"] *, [data-offscreen="true"] *::before, [data-offscreen="true"] *::after { animation-play-state: paused !important; } /* One finite entry reaction for a real activity event. */ .bb-tape-enter { animation: bb-tape-enter 240ms ease-out both; } diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx index 53b733e1..ebb88eea 100644 --- a/app/src/app/rules/page.tsx +++ b/app/src/app/rules/page.tsx @@ -74,7 +74,7 @@ export default function RulesPage() {

One signature.
Four things happen.

All in the same transaction.
No separate setup. No platform fee.

- {/* the animated walk-through that used to open the home page: token, pool, lock */} + {/* the animated walk-through that also opens the home page: token, pool, lock */}
    {STEPS.map(({ title, description, value, label }, i) => ( diff --git a/app/src/components/launchpad/ChangeChip.tsx b/app/src/components/launchpad/ChangeChip.tsx index caa76aea..505d6ccd 100644 --- a/app/src/components/launchpad/ChangeChip.tsx +++ b/app/src/components/launchpad/ChangeChip.tsx @@ -1,8 +1,9 @@ import { fmtCompact } from "@/lib/launchpad/math"; import { marketChange } from "@/lib/launchpad/market-format"; +import Roll from "./Roll"; /** "+12%" / "-3.4%" / "19.7×" since launch. Plain component: renders on the server and the client. */ -export default function ChangeChip({ v, className = "", plain = false, context = "since launch" }: { v: number; className?: string; plain?: boolean; context?: string }) { +export default function ChangeChip({ v, className = "", plain = false, roll = false, context = "since launch" }: { v: number; className?: string; plain?: boolean; roll?: boolean; context?: string }) { const pct = v * 100; const available = Number.isFinite(pct); const up = pct >= 0; @@ -14,7 +15,7 @@ export default function ChangeChip({ v, className = "", plain = false, context = const pillColor = !available || flat ? "bg-paper text-muted" : up ? "bg-up-soft text-up" : "bg-down-soft text-down-ink"; return ( - {plain ? market.label : txt} + {roll ? : plain ? market.label : txt} ); } diff --git a/app/src/components/launchpad/HeroBackdrop.tsx b/app/src/components/launchpad/HeroBackdrop.tsx deleted file mode 100644 index cd9a7e27..00000000 --- a/app/src/components/launchpad/HeroBackdrop.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { FlickeringGrid } from "@/components/vendor/flickering-grid"; - -/** - * Behind the home hero and the river: a flickering grid (grey on light, blue on dark) fading out from the top, with no colour wash - * over it. Decorative only; it sits in a clipped band so it never widens the page. - */ -export default function HeroBackdrop() { - return ( -
)} + {/* said once for the board, not inside every card's link, where it was read again on each focus */} + {first ? : null} ); } -/** Whether the rail sits at its start, and at its end. */ -function edgesOf(el: HTMLElement): { start: boolean; end: boolean } { - return { start: el.scrollLeft <= 4, end: el.scrollLeft + el.clientWidth >= el.scrollWidth - 4 }; +const k = (n: number) => ({ "--k": n }) as CSSProperties; +/** How a card plays its reaction: when it starts (`--st`), which way it leans (`--dir`: up for a buy) and how hard (`--bump`). */ +const fxStyle = (fx: CardFx | undefined, leader: boolean) => (fx ? ({ "--st": `${fx.slot}ms`, "--dir": fx.side === "buy" ? -1 : 1, "--bump": bumpOf(fx.mag, leader).toFixed(4) } as CSSProperties) : undefined); +/** The nudge alternates between two identical keyframes, so a hit right after another replays at once. */ +const fxClass = (fx: CardFx | undefined) => (fx ? (fx.parity === 0 ? styles.hitA : styles.hitB) : ""); + +/** + * A trade's edge flash and the figure that lifts off the card. Decorative: the tape and the numbers carry the facts. + * Keyed per reaction, so each is a fresh node and its animation plays once; both end invisible. + */ +function FxNodes({ fx, place }: { fx: CardFx; place: "lead" | "runner" }) { + const buy = fx.side === "buy"; + return ( + <> +