-
+
-
+ +++++
+ {[0, 1, 2, 3].map((i) =>
The launch
One signature.
Four things happen.
All in the same transaction.
No separate setup. No platform fee.
-
{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 ?
- MIT licensed -
Source on GitHub
- - Verified contracts -
- MIT licensed +
SourceSource on GitHub
+ - Verified contracts +
- {CHAIN_SHORT[k]} launches
- {count(t.by_chain[k]?.launches ?? 0)}
- All-time trades
- {count(t.trades)}
- All-time volume
- {usd(t.volume_usd)}
- Fees to recipients
- {usd(t.fees_to_creators_usd)}
- Fees burned
- {usd(t.fees_burned_usd)}
GITLAWB burned - {fmtQuote(t.gitlawb_burned, GITLAWB_DECIMALS, GITLAWB_SYMBOL)}
TWIG burned - {fmtQuote(twigBurned, TWIG_DECIMALS, TWIG_SYMBOL)}
- {CHAIN_LABELS[k]} launches
- {count(t.by_chain[k]?.launches ?? 0)}
- All-time trades
- {count(t.trades)}
- All-time volume
- {usd(t.volume_usd)}
- Fees to recipients
- {usd(t.fees_to_creators_usd)}
- Fees burned
- {usd(t.fees_burned_usd)}
GITLAWB burned - {fmtQuote(t.gitlawb_burned, GITLAWB_DECIMALS, GITLAWB_SYMBOL)}
TWIG burned - {fmtQuote(twigBurned, TWIG_DECIMALS, TWIG_SYMBOL)}
- {k} -
- {children} -
- {label} +
- {children} +
- {note} +
{/* the list follows the shared live poll */}
diff --git a/app/src/components/launchpad/LaunchHero.module.css b/app/src/components/launchpad/LaunchHero.module.css
new file mode 100644
index 00000000..b8004b9d
--- /dev/null
+++ b/app/src/components/launchpad/LaunchHero.module.css
@@ -0,0 +1,43 @@
+/* The hero's grid, in three arrangements. Phones: copy, the locker, then the totals. From 768px the text sits left of the
+ locker and the totals run under both. From 1024px the totals move up into the left column under the text, and the locker
+ takes the whole height beside them: copy hugs the bottom of its row and the totals the top of theirs, so the pair stays
+ together however tall the locker is. */
+.layout { display: grid; align-items: center; column-gap: 2.5rem; row-gap: 1.25rem; grid-template-columns: minmax(0, 1fr); grid-template-areas: "copy" "stage" "totals"; }
+.copyArea { grid-area: copy; min-width: 0; }
+.totalsArea { grid-area: totals; min-width: 0; }
+@media (min-width: 640px) { .layout { row-gap: 2rem; } }
+@media (min-width: 768px) { .layout { grid-template-columns: minmax(0, 1.06fr) minmax(0, 1fr); grid-template-areas: "copy stage" "totals totals"; } }
+@media (min-width: 1024px) {
+ .layout { grid-template-areas: "copy stage" "totals stage"; row-gap: 1.25rem; }
+ .copyArea { align-self: end; }
+ .totalsArea { align-self: start; }
+}
+@media (min-width: 1280px) { .layout { column-gap: 3.5rem; } }
+
+/* The hero's stage for the launch mechanism: a plotting grid that fades out, and four corner marks. Decorative. */
+.stage { grid-area: stage; position: relative; min-width: 0; padding: 14px 14px 10px; }
+.grid {
+ position: absolute; inset: 0; pointer-events: none; opacity: .5;
+ background-image: radial-gradient(circle at 1px 1px, var(--color-faint) 1px, transparent 1.5px);
+ background-size: 22px 22px; background-position: 50% 0;
+ -webkit-mask-image: radial-gradient(ellipse 62% 46% at 50% 40%, #000 25%, transparent 100%); mask-image: radial-gradient(ellipse 62% 46% at 50% 40%, #000 25%, transparent 100%);
+}
+.corner { position: absolute; width: 12px; height: 12px; pointer-events: none; border: 0 solid var(--color-line-strong); }
+.corner[data-corner="tl"] { top: 0; left: 0; border-top-width: 1px; border-left-width: 1px; }
+.corner[data-corner="tr"] { top: 0; right: 0; border-top-width: 1px; border-right-width: 1px; }
+.corner[data-corner="bl"] { bottom: 0; left: 0; border-bottom-width: 1px; border-left-width: 1px; }
+.corner[data-corner="br"] { right: 0; bottom: 0; border-right-width: 1px; border-bottom-width: 1px; }
+@media (min-width: 640px) {
+ .stage { padding: 18px 20px 12px; }
+}
+/* short laptop screens (1536x730, 1366x768, 1280x720): the totals must still be on the first screen, so the
+ mechanism is drawn smaller and the hero gives back its padding. These beat the Tailwind paddings on the same elements. */
+@media (min-width: 1024px) and (max-height: 840px) {
+ .stage { --machine-max: 400px; --detail-min: 88px; }
+ .hero { padding-top: 8px; }
+ .layout { row-gap: .75rem; }
+}
+/* a 768px-tall display leaves about 657px under the browser's own bars: one more step keeps the figures in view there */
+@media (min-width: 1024px) and (max-height: 700px) {
+ .stage { --machine-max: 340px; }
+}
diff --git a/app/src/components/launchpad/LaunchHero.tsx b/app/src/components/launchpad/LaunchHero.tsx
index cb2104fd..f19e9fc9 100644
--- a/app/src/components/launchpad/LaunchHero.tsx
+++ b/app/src/components/launchpad/LaunchHero.tsx
@@ -1,16 +1,17 @@
import Link from "next/link";
-import type { CSSProperties } from "react";
import { ArrowRight, ArrowUpRight, Play, Rocket, Scale, ShieldCheck } from "lucide-react";
import LaunchMechanism from "./LaunchMechanism";
+import HeroLocker from "./HeroLocker";
import HeroCtaLink from "./HeroCtaLink";
-import { BRAND_GITHUB } from "@/lib/brand";
-import { AnimatedShinyText } from "@/components/vendor/animated-shiny-text";
-import { BorderBeam } from "@/components/vendor/border-beam";
+import LiveTicker from "./LiveTicker";
import { ChainLogoStack } from "./ChainLogo";
+import { BRAND_GITHUB } from "@/lib/brand";
import { VISIBLE_CHAINS } from "@/lib/launchpad/config";
+import { coinPicturesEnabled, imagePublicBase } from "@/lib/launchpad/imageStore";
+import { HERO } from "./hero-layout";
+import styles from "./LaunchHero.module.css";
-const rise = (ms: number) => ({ "--d": `${ms}ms` }) as CSSProperties;
-const proof = "inline-flex min-h-9 items-center gap-2 rounded-full border border-line bg-card/70 px-3.5 text-xs font-medium text-body backdrop-blur transition-[color,border-color,background-color,transform] duration-150 hover:border-line-strong hover:bg-card hover:text-ink active:scale-[0.98] motion-reduce:transition-colors motion-reduce:active:scale-100";
+const proof = "inline-flex min-h-8 items-center gap-1.5 rounded-full border border-line bg-card/70 px-2.5 text-[11px] font-medium text-body backdrop-blur transition-[color,border-color,background-color,transform] duration-150 hover:border-line-strong hover:bg-card hover:text-ink active:scale-[0.98] motion-reduce:transition-colors motion-reduce:active:scale-100 sm:min-h-9 sm:gap-2 sm:px-3.5 sm:text-xs";
/** GitHub's mark (lucide has no brand icons), for the link to the source. */
function GithubMark() {
@@ -22,59 +23,67 @@ function GithubMark() {
}
/**
- * First viewport of the home page. Left: the promise and the one filled CTA, rising in on first paint (pure CSS,
- * so it is all there before any script). Right: the network's real totals as one sentence in a glass card with a
- * travelling border. The flickering grid and glow behind it live in HeroBackdrop.
+ * First viewport of the home page. Left: the promise, the two ways in, the places to check the claim and, from 1024px, the
+ * network's real totals. Right: the launch mechanism itself (token, pool, lock) as a looping drawing. Below: one live line,
+ * the newest thing on the network. Trending and the live panel follow, so the proof is the market itself.
+ * The class strings live in hero-layout.ts, and the grid in LaunchHero.module.css, because the loading skeleton renders the same.
*/
export default function LaunchHero({ configured }: { configured: boolean }) {
return (
-
-
-
-
- Free on Base, Robinhood Chain & ArcFree to launch on Base, Robinhood Chain and Arc
-
-
-
- Launch a token.
- We take nothing.
-
-
- {/* the brand as a plain word, once, above the fold: the title and footer alone read as a domain */}
- openlaunch is the free, open-source launchpad on Base, Robinhood Chain and Arc. One transaction: your token, a Uniswap v4 pool, and a liquidity position locked forever. 100% of the supply goes into the pool at launch. You only pay gas.
-
-
- {/* the header watches this id: while it is on screen the header CTA stays quiet (one filled blue per screen) */}
-
- {/* the rocket lifts off on hover */}
-
- Launch a token
-
-
-
- See how it works
+
+
+
+
+
+ Free to launch on Base, Robinhood Chain and Arc
+
-
- {/* the proofs: three small chips, each one a place to check the claim */}
-
-
- {!configured ? (
-
- Launchpad contracts are not configured yet. Read-only until NEXT_PUBLIC_LAUNCH_FACTORY / _LOCKER are set.
+
+ Launch a token.
+
+ We take nothing.
+
+
+ {/* the brand as a plain word, once, above the fold: the title and footer alone read as a domain */}
+ openlaunch is the free, open-source launchpad on Base, Robinhood Chain and Arc. One transaction: your token, a Uniswap v4 pool, and a liquidity position locked forever. 100% of the supply goes into the pool at launch. You only pay gas.
- ) : null}
-
-
- {/* a soft glow behind the panel lifts it off the grid */}
-
-
-
+
+ {/* the header watches this id: while it is on screen the header CTA stays quiet (one filled blue per screen) */}
+
+ {/* the rocket lifts off on hover */}
+
+ Launch a token
+
+ {/* the way in for someone who wants it explained first: the rules guide. Below 380px just the tile, so the filled button keeps its words */}
+
+
+
+
+
+
+ {/* the proofs: three small chips, each one a place to check the claim */}
+
+
+ {!configured ? (
+
+ Launchpad contracts are not configured yet. Read-only until NEXT_PUBLIC_LAUNCH_FACTORY / _LOCKER are set.
+
+ ) : null}
+
+ {/* the stage: a faint plotting grid and four corner marks frame the mechanism like a drawing on a bench */}
+
+
+ {["tl", "tr", "bl", "br"].map((corner) => )}
+
+
+
+
);
}
diff --git a/app/src/components/launchpad/LaunchList.tsx b/app/src/components/launchpad/LaunchList.tsx
index 94a139e8..d8282f8d 100644
--- a/app/src/components/launchpad/LaunchList.tsx
+++ b/app/src/components/launchpad/LaunchList.tsx
@@ -6,6 +6,7 @@ import { Fragment, useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowDownWideNarrow, ArrowRight, LayoutGrid, Rows3, Search, SlidersHorizontal, Star, X } from "lucide-react";
import LaunchRow, { LaunchListHeader, type RowHighlight } from "./LaunchRow";
+import { usePauseOffscreen } from "./usePauseOffscreen";
import LaunchCard from "./LaunchCard";
import { useLive } from "./LiveProvider";
import WatchButton from "./WatchButton";
@@ -82,6 +83,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
const pendingOrder = useRef(null);
const interaction = useRef({ pointer: false, focus: false, at: 0 });
const listRef = useRef(null);
+ const section = usePauseOffscreen(); // a beacon pulses on every live row: not while the list is far off screen
useEffect(() => {
// the watchlist reads its own endpoint; the shared poll only carries the market list while it is on screen
@@ -254,7 +256,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
const firstQuiet = ranked ? shown.findIndex((row) => liveTier(row, now) === "quiet") : -1; // one divider, where the database's order enters the quiet tier
return (
-
+
diff --git a/app/src/components/launchpad/LaunchMachine.module.css b/app/src/components/launchpad/LaunchMachine.module.css
index a099178f..bc281e15 100644
--- a/app/src/components/launchpad/LaunchMachine.module.css
+++ b/app/src/components/launchpad/LaunchMachine.module.css
@@ -1,4 +1,4 @@
-.machine { --cycle: 7.2s; --motion-state: paused; min-width: 0; width: 100%; max-width: 560px; margin-inline: auto; }
+.machine { --cycle: 7.2s; --motion-state: paused; min-width: 0; width: 100%; max-width: var(--machine-max, 560px); margin-inline: auto; }
.heading { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-inline: 2px; }
.eyebrow { display: flex; align-items: center; gap: 9px; color: var(--color-muted); font-size: 11px; }
.crosshair { width: 9px; height: 9px; position: relative; }
@@ -150,7 +150,10 @@
40% { transform: scaleX(1); opacity: 1; }
40.001%, 100% { transform: scaleX(1); opacity: 0; }
}
-.detail { display: grid; align-content: start; padding: 16px 8px 0; min-height: 105px; }
+/* the host may tighten both (the home hero does on short screens): --machine-max caps the drawing, --detail-min the reserved copy height */
+.detail { display: grid; align-content: start; padding: 16px 8px 0; min-height: var(--detail-min, 105px); }
+/* from 1024px the copy is two lines at most, so less height is held for it */
+@media (min-width: 1024px) { .detail { min-height: var(--detail-min, 84px); } }
.detailContent { grid-area: 1 / 1; }
.detailContent[aria-hidden="true"] { visibility: hidden; }
.machine[data-loop="true"] .detailContent[aria-hidden="false"] { animation: detail-enter 180ms ease-out both; animation-play-state: var(--motion-state); }
@@ -176,6 +179,17 @@
.stageButton { gap: 6px; padding-inline: 6px; font-size: 11px; }
.stageButton::after, .stageProgress { left: 6px; right: 6px; }
.stageDot { display: none; }
- .detail { min-height: 115px; padding-inline: 6px; }
+ .detail { min-height: 80px; padding: 12px 6px 0; }
.proof { padding-inline: 6px; }
}
+
+/* A real launch's picture on the coin's face. It pops in, rides the coin down into the pool, and is gone by 15 s whether or not
+ the next poll has dropped it: the last keyframe leaves it invisible. Wall-clock, so it does not wait on the loop's own pause. */
+.coinFace { transform-box: fill-box; transform-origin: center; animation: coin-in 520ms cubic-bezier(.3, 1.4, .5, 1) backwards, coin-out 700ms ease-in 14.3s forwards; }
+.coinDisc { fill: var(--color-card); }
+.coinRing { fill: none; stroke: color-mix(in srgb, var(--color-card) 92%, transparent); stroke-width: 1.5; }
+@keyframes coin-in { from { opacity: 0; transform: scale(.6); } }
+@keyframes coin-out { to { opacity: 0; } }
+@keyframes coin-fade { from { opacity: 0; } }
+@media (prefers-reduced-motion: reduce) { .coinFace { animation: coin-fade 300ms ease-out backwards, coin-out 700ms ease-in 14.3s forwards; } }
+
diff --git a/app/src/components/launchpad/LaunchMachine.tsx b/app/src/components/launchpad/LaunchMachine.tsx
index 8f435ac3..18b08cda 100644
--- a/app/src/components/launchpad/LaunchMachine.tsx
+++ b/app/src/components/launchpad/LaunchMachine.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useRef, useState, useSyncExternalStore, type PointerEvent } from "react";
+import { useEffect, useId, useRef, useState, useSyncExternalStore, type PointerEvent } from "react";
import { Pause, Play, ArrowUpRight, LockKeyhole } from "lucide-react";
import { launchpad } from "@/lib/launchpad/config";
import { CHAIN_KEYS, CHAIN_SHORT, explorerAddress } from "@/lib/chainPublic";
@@ -27,8 +27,12 @@ const motionSnapshot = () => window.matchMedia(MOTION_QUERY).matches;
const visibilitySnapshot = () => !document.hidden;
const serverSnapshot = () => false;
-/** Original SVG/CSS illustration. The browser owns its clock; React never ticks per frame. */
-export default function LaunchMachine() {
+/**
+ * Original SVG/CSS illustration. The browser owns its clock; React never ticks per frame.
+ * A host may put a picture on the coin's face (`coin`, an already vetted URL) and change the caption (`eyebrow`); with neither it is the drawing /rules shows.
+ */
+export default function LaunchMachine({ coin = null, eyebrow }: { coin?: { key: string; src: string } | null; eyebrow?: string }) {
+ const clip = `coin-clip-${useId().replace(/:/g, "")}`;
const root = useRef(null);
const scene = useRef(null);
const [stage, setStage] = useState(2);
@@ -74,7 +78,7 @@ export default function LaunchMachine() {
return
- One transaction. Built to stay.
+ {eyebrow ?? "One transaction. Built to stay."}
@@ -123,6 +127,17 @@ export default function LaunchMachine() {
+ {/* a real launch's picture, upright and foreshortened to the coin's inner ring (43 x 18); it rides the coin down into the pool */}
+ {coin ? (
+
+
+
+
+
+
+
+
+ ) : null}
diff --git a/app/src/components/launchpad/LaunchMechanism.module.css b/app/src/components/launchpad/LaunchMechanism.module.css
new file mode 100644
index 00000000..9dddf61b
--- /dev/null
+++ b/app/src/components/launchpad/LaunchMechanism.module.css
@@ -0,0 +1,70 @@
+/* The totals strip: one hairline panel, five readings. Trades is the page's one big live figure. */
+.panel { overflow: hidden; border: 1px solid var(--color-line); border-radius: 16px; background: var(--color-card); }
+/* 1px gaps over the line colour draw the dividers, whatever the wrap */
+.readings { display: grid; gap: 1px; background: var(--color-line); }
+
+/* phones: compact rows, label left and figure right on one baseline, notes hidden */
+.reading { --fig-max: 20px; --fig-line: 26px; --fig-drop: 7px; display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; min-width: 0; padding: 9px 16px; background: var(--color-card); }
+.lead { --fig-max: 30px; --fig-line: 38px; --fig-drop: 9px; }
+.label { flex: none; white-space: nowrap; font-size: 11px; font-weight: 500; line-height: 1; letter-spacing: .08em; text-transform: uppercase; color: var(--color-muted); }
+/* Bold figures crowd their digits at negative tracking, so none. The figure's box is the room it has (a query container);
+ the value inside is sized from that room and its own length, so a long value shrinks instead of clipping or wrapping.
+ NumberFlow lets its digits overhang the figure sideways by the width of its edge fade. Keep that fade narrow and inside
+ the figure's own (padded, pulled-back) box, so the box never reports an overflow that is not there. The fade above and
+ below is kept short too, so a rolling digit does not ghost over the label. */
+.figure { flex: 1 1 0; min-width: 0; container-type: inline-size; text-align: right; white-space: nowrap; font-family: var(--font-mono); font-weight: 700; line-height: 1; letter-spacing: 0; font-variant-numeric: tabular-nums; --number-flow-mask-width: .2em; --number-flow-mask-height: .16em; margin-inline: calc(var(--fig-max) * -.2); padding-inline: calc(var(--fig-max) * .2); }
+/* the first size is for a browser without container units. A character is counted as .74em: a tabular bold digit is about
+ .64em wide (a comma .24em, an M .92em), plus room for a reader's own letter spacing (WCAG 1.4.12 asks for .12em), so a long count still fits with it on */
+.value { font-size: var(--fig-max); font-size: clamp(10px, calc(100cqi / (var(--chars) * .74)), var(--fig-max)); }
+/* A strut: a zero-width box that gives the line a fixed height and a fixed baseline, whatever the font size on it.
+ Every figure in a row (the big one, the small ones, the plain "$0") therefore sits on one baseline. */
+.label::before, .figure::before { content: ""; display: inline-block; width: 0; height: var(--fig-line); vertical-align: calc(-1 * var(--fig-drop)); }
+/* NumberFlow pads its digits for the roll mask, which makes a rolling figure's line taller than a plain one (and drops
+ its baseline); take the padding back out of the layout, here and in the breakdown. This repeats the library's own
+ padding formula (number-flow 0.6.2, dist/ssr-*.mjs: `round(nearest, calc(var(--number-flow-mask-height, .25em) / 2), 1px)`,
+ applied twice): if an upgrade changes it, the Trades figure drops below its neighbours, so re-check the strip then. */
+.panel number-flow-react { margin-block: calc(round(nearest, calc(var(--number-flow-mask-height, .25em) / 2), 1px) * -2); }
+.note { display: none; }
+/* no sideways fade on the small exact figures: its overhang would stick out of a column sized to the figure */
+.breakdown { --number-flow-mask-width: 0px; padding: 0 16px; border-top: 1px solid var(--color-line); }
+/* the panel clips at its edge, so the summary's focus ring is drawn inside */
+.breakdown summary:focus-visible { outline-offset: -2px; }
+/* a label that wraps ("Robinhood Chain launches") must not push its value below its neighbours' */
+.breakdown dl > div { display: flex; flex-direction: column; justify-content: space-between; }
+
+/* phones: Trades keeps its row (label left, figure right); the other four are a 2x2 of label-over-figure cells, each pair borrowing
+ two rows so the figures of a pair line up. That is a third shorter than five rows. */
+@media (max-width: 639.98px) {
+ .readings { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .lead { grid-column: 1 / -1; }
+ .reading:not(.lead) { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: subgrid; grid-row: span 2; align-items: start; gap: 0; padding: 9px 16px 8px; }
+ .reading:not(.lead) .label { white-space: normal; line-height: 1.4; }
+ .reading:not(.lead) .label::before { content: none; }
+ .reading:not(.lead) .figure { margin-top: 4px; text-align: left; }
+}
+
+/* from 640px: columns. Each reading borrows the strip's three rows (label, figure, note), so they line up across readings
+ however a label or a note wraps. Trades takes the first row whole, the other four share the second. */
+@media (min-width: 640px) {
+ .readings { grid-template-columns: repeat(4, minmax(0, 1fr)); }
+ .reading { --fig-max: 24px; --fig-line: 30px; --fig-drop: 7px; display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: subgrid; grid-row: span 3; gap: 0; align-items: start; padding: 16px 16px 14px; }
+ .lead { --fig-max: 40px; --fig-line: 46px; --fig-drop: 10px; grid-column: 1 / -1; }
+ .label { white-space: normal; line-height: 1.4; }
+ .label::before { content: none; }
+ .figure { margin-top: 6px; text-align: left; }
+ .note { display: block; margin-top: 4px; font-size: 11px; line-height: 1.5; color: var(--color-muted); text-wrap: balance; }
+}
+/* the narrowest four-column cells: a little less padding and tracking keeps "Fees to recipients" on one line */
+@media (min-width: 640px) and (max-width: 767.98px) {
+ .reading, .breakdown { padding-inline: 12px; }
+ .label { letter-spacing: .06em; }
+}
+/* from 1024px the panel sits in the hero's left column, so it is a card: Trades is the big figure down its left side and the
+ other four share a 2x2 beside it. The notes drop out (each label says what its reading is, and the breakdown below says the
+ rest). A small reading borrows two of the card's four rows, so the figures of a pair still line up however a label wraps. */
+@media (min-width: 1024px) {
+ .readings { grid-template-columns: minmax(0, 1.5fr) repeat(2, minmax(0, 1fr)); }
+ .reading { --fig-max: 24px; --fig-line: 30px; --fig-drop: 7px; grid-row: span 2; padding: 12px 14px 10px; }
+ .lead { --fig-max: 46px; --fig-line: 52px; --fig-drop: 11px; grid-column: 1; grid-row: span 4; grid-template-rows: none; align-content: center; padding: 12px 18px; }
+ .note { display: none; }
+}
diff --git a/app/src/components/launchpad/LaunchMechanism.tsx b/app/src/components/launchpad/LaunchMechanism.tsx
index 864b2602..52edb0ab 100644
--- a/app/src/components/launchpad/LaunchMechanism.tsx
+++ b/app/src/components/launchpad/LaunchMechanism.tsx
@@ -1,7 +1,7 @@
"use client";
+import { useEffect, useState, type CSSProperties } from "react";
import { ArrowUpRight, ChevronDown } from "lucide-react";
-import { Popover } from "@base-ui/react/popover";
import { useLive } from "./LiveProvider";
import LiveNumber, { COMPACT_USD } from "../LiveNumber";
import { GitlawbMark } from "./GitlawbBadge";
@@ -10,78 +10,76 @@ import { fmtQuote, fmtUnitsExact, fmtUsd } from "@/lib/launchpad/math";
import { GITLAWB_DECIMALS, GITLAWB_SYMBOL } from "@/lib/launchpad/gitlawb";
import { TWIG_DECIMALS, TWIG_SYMBOL } from "@/lib/launchpad/twig";
import { BRAND_GITHUB } from "@/lib/brand";
-import { CHAIN_KEYS, CHAIN_SHORT, chainList } from "@/lib/chainPublic";
-import { VISIBLE_CHAINS } from "@/lib/launchpad/config";
-import { ChainLogoStack } from "./ChainLogo";
+import { CHAIN_KEYS, CHAIN_LABELS, chainList } from "@/lib/chainPublic";
+import styles from "./LaunchMechanism.module.css";
-/** The network's real totals as a live panel: the launch count large, four tiles under it. The full breakdown sits behind a toggle. */
+/** Characters in a grouped count such as "114,839". Counted by hand so the server and the browser always agree. */
+const countChars = (n: number) => { const digits = String(Math.trunc(Math.abs(n))).length; return digits + Math.floor((digits - 1) / 3); };
+/** The longest a compact dollar figure gets ("$999.9M"). A constant: Intl prints "$1K" or "$1.0K" depending on the runtime. */
+const USD_CHARS = 7;
+
+/**
+ * The network's real totals as one instrument strip: Trades, the page's one big live figure, then launches,
+ * volume and fees, ending on the reading that never moves ($0 to us). The per-chain breakdown sits behind a toggle.
+ */
export default function LaunchMechanism() {
- const { live } = useLive();
- const t = live.totals;
+ const { live, subscribe } = useLive();
+ // The dollar sums are raw quote totals times the price right now, so they drift between polls with no trade, and two
+ // servers with different cached prices make them alternate. Latch: what is shown is replaced only when something
+ // happened on-chain (a trade, a launch, a burn: raw amounts with no price in them) or a quote lost or regained its
+ // price. The counts still roll on every trade. A poll from an older machine carries no TWIG figure and is no news.
+ const [t, setT] = useState(live.totals);
+ useEffect(() => subscribe(({ totals }) => {
+ setT((shown) => (totals.trades !== shown.trades || totals.launches !== shown.launches || totals.usd_partial !== shown.usd_partial || totals.gitlawb_burned !== shown.gitlawb_burned || (totals.twig_burned ?? shown.twig_burned) !== shown.twig_burned ? totals : shown));
+ }), [subscribe]);
// totals roll to their new value when a launch or trade lands (static on first paint and under reduced motion)
const count = (value: number) => ;
// some quote has no price right now: the dollar sums undercount, so say "≈" instead of showing a confident smaller number
const usdNote = t.usd_partial ? "Some launches are quoted in an asset with no USD price right now; dollar totals exclude them until it returns." : undefined;
// `live` figures roll as they change (compact); the rest stay exact text. Both carry the same "≈" guard.
const usd = (v: number, live = false) => (live ? : `${t.usd_partial ? "≈" : ""}${fmtUsd(v)}`);
+ // the compact figure rounds, so its tooltip carries the exact sum and says what kind of number it is
+ const usdTitle = (v: number, what: string) => usdNote ?? `${fmtUsd(v)} · ${what} valued at current rates`;
+ const usdChars = USD_CHARS + (t.usd_partial ? 1 : 0);
const gitlawbBurnedExact = `${fmtUnitsExact(t.gitlawb_burned, GITLAWB_DECIMALS)} ${GITLAWB_SYMBOL}`; // every digit of the raw amount, no float
// TWIG burned: its own figure (the GITLAWB behind it stays in the TWIG contract), shown once there is any; null-safe for a poll from an older machine
const twigBurned = t.twig_burned ?? "0";
const twigBurnedExact = `${fmtUnitsExact(twigBurned, TWIG_DECIMALS)} ${TWIG_SYMBOL}`;
- return
-
-
- {/* the totals follow the shared live poll */}
-
- Live across
-
- All time
-
-
- {count(t.launches)}
- tokens launched
-
- {/* hairlines between the tiles come from the gap over a line-coloured grid */}
-
- {count(t.trades)}
- {usd(t.volume_usd, true)}
- {usd(t.fees_to_creators_usd, true)}
- $0
+ return
+
+ {count(t.trades)}
+ {count(t.launches)}
+ {usd(t.volume_usd, true)}
+ {usd(t.fees_to_creators_usd, true)}
+ {/* plain text, not a roller: this reading never changes, and the strut in the stylesheet already puts it on its neighbours' baseline */}
+ $0
- {/* the breakdown opens over the page, so the hero never shifts when it opens or closes */}
-
-
- The numbers across {chainList("&", CHAIN_SHORT)}
-
-
-
-
-
- The numbers across {chainList("&", CHAIN_SHORT)}
-
- {CHAIN_KEYS.map((k) => )}
-
-
-
-
-
- {BigInt(twigBurned) > 0n ? : null}
-
- Creators choose a 0%, 1% or 3% trading fee. It goes to their named recipients or is burned. The platform takes none: no fee address in the factory, no platform cut in the locker.
- Read the contracts
-
-
-
-
-
;
+
+ The numbers across {chainList("&", CHAIN_LABELS)}
+
+ {CHAIN_KEYS.map((k) => )}
+
+
+
+
+
+ {BigInt(twigBurned) > 0n ? : null}
+
+ {/* the "≈" explained in the open: a title alone cannot be reached by touch or keyboard */}
+ {usdNote ? ≈ {usdNote}
: null}
+ Creators choose a 0%, 1% or 3% trading fee. It goes to their named recipients or is burned. The platform takes none: no fee address in the factory, no platform cut in the locker.
+ Read the contracts
+
+ ;
}
-/** One total: the figure large, what it counts under it (the label stays first for assistive tech). */
-function Tile({ children, k, title, accent = false }: { children: React.ReactNode; k: string; title?: string; accent?: boolean }) {
- return (
-
-
- );
+/** One reading. `chars` is the figure's length: the stylesheet sizes it from that and the room it has, so it never clips. */
+function Reading({ label, note: text, chars, title, tone, lead = false, children }: { label: string; note: string; chars: number; title?: string; tone?: "up" | "brand"; lead?: boolean; children: React.ReactNode }) {
+ // a chain's two-word name stays on one line: a note that wraps after bare "Robinhood" reads like the broker
+ const note = text.replaceAll(" Chain", "\u00a0Chain");
+ return
+ ;
}
diff --git a/app/src/components/launchpad/LaunchPill.module.css b/app/src/components/launchpad/LaunchPill.module.css
new file mode 100644
index 00000000..29a8c42a
--- /dev/null
+++ b/app/src/components/launchpad/LaunchPill.module.css
@@ -0,0 +1,6 @@
+/* A filled pill at the bottom of a phone's screen, under the header (z 40) and its menu (z 50). Phones only. */
+.pill { position: fixed; z-index: 30; left: 50%; bottom: max(16px, env(safe-area-inset-bottom)); transform: translateX(-50%); display: inline-flex; align-items: center; gap: 10px; height: 48px; padding: 0 20px 0 8px; border-radius: 9999px; background: var(--color-brand); color: var(--color-inverse); font-size: 14px; font-weight: 600; white-space: nowrap; box-shadow: inset 0 1px 0 rgb(255 255 255 / .22), 0 12px 30px -10px var(--color-glow); animation: pill-in 220ms cubic-bezier(.2, .7, .3, 1) backwards; }
+.tile { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 12px; background: color-mix(in srgb, var(--color-inverse) 15%, transparent); }
+@keyframes pill-in { from { opacity: 0; transform: translate(-50%, 12px); } }
+@media (min-width: 640px) { .pill { display: none; } }
+@media (prefers-reduced-motion: reduce) { .pill { animation: none; } }
diff --git a/app/src/components/launchpad/LaunchPill.tsx b/app/src/components/launchpad/LaunchPill.tsx
new file mode 100644
index 00000000..7ef85655
--- /dev/null
+++ b/app/src/components/launchpad/LaunchPill.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState, useSyncExternalStore } from "react";
+import { Rocket } from "lucide-react";
+import { heroCtaOnScreen, subscribeHeroCta } from "@/lib/hero-cta";
+import styles from "./LaunchPill.module.css";
+
+/**
+ * A phone's way to launch once the hero's own button has scrolled away. The header has no Launch button on a phone (it lives
+ * in the menu), so without this the page has nothing to act on after the hero. The hero reports whether its button is on screen
+ * (HeroCtaLink), so the pill and that button are never on screen together: one filled blue per screen. It steps aside for the
+ * footer, so it never covers a footer link. From 640px the header carries the button and this is not drawn.
+ */
+export default function LaunchPill() {
+ const heroButton = useSyncExternalStore(subscribeHeroCta, heroCtaOnScreen, () => null);
+ const [atFooter, setAtFooter] = useState(false);
+ useEffect(() => {
+ const footer = document.getElementById("site-footer");
+ if (!footer) return;
+ const watch = new IntersectionObserver(([entry]) => setAtFooter(entry.isIntersecting));
+ watch.observe(footer);
+ return () => watch.disconnect();
+ }, []);
+ // `false`: the hero is mounted and its button has fully left the screen (null: no hero yet)
+ if (heroButton !== false || atFooter) return null;
+ return (
+
+
+ Launch a token
+
+ );
+}
diff --git a/app/src/components/launchpad/LaunchRow.tsx b/app/src/components/launchpad/LaunchRow.tsx
index 6c3e5be4..007eafc0 100644
--- a/app/src/components/launchpad/LaunchRow.tsx
+++ b/app/src/components/launchpad/LaunchRow.tsx
@@ -68,7 +68,8 @@ export default function LaunchRow({ l, rank, window = "all", hl = null, now, pop
className={`bb-market-row group block border-b border-line bg-paper px-4 py-2.5 md:pr-13 transition-colors hover:bg-card focus-visible:relative focus-visible:z-10 motion-reduce:transition-none ${flash}`}
title={l.description || `${l.name} (${l.symbol})`}
>
-
+ {/* the phone cap column is at most 9.25rem: a long quote figure ("2.61M 0xa69f…8792") truncates there instead of taking the row */}
+
{rank !== undefined ? {rank} : null}
@@ -78,7 +79,9 @@ export default function LaunchRow({ l, rank, window = "all", hl = null, now, pop
-
+ {/* below 1024px the badge drops under the name when the two do not fit, so a name is never squeezed to nothing
+ (an unlisted-pair row on a phone, the narrow name column of the tablet table) */}
+
{l.name}
diff --git a/app/src/components/launchpad/LiveBubbles.module.css b/app/src/components/launchpad/LiveBubbles.module.css
index 1cb3c27a..2b657f0e 100644
--- a/app/src/components/launchpad/LiveBubbles.module.css
+++ b/app/src/components/launchpad/LiveBubbles.module.css
@@ -5,7 +5,7 @@
*/
.field {
position: relative;
- height: 300px;
+ height: 250px;
overflow: hidden;
border-radius: 18px;
background:
diff --git a/app/src/components/launchpad/LiveRiver.tsx b/app/src/components/launchpad/LiveRiver.tsx
index 2872ef9d..6ad6c491 100644
--- a/app/src/components/launchpad/LiveRiver.tsx
+++ b/app/src/components/launchpad/LiveRiver.tsx
@@ -9,6 +9,7 @@ import TokenAvatar from "./TokenAvatar";
import { TapeList } from "./LaunchTape";
import RiverFeed from "./RiverFeed";
import LiveBubbles from "./LiveBubbles";
+import { usePauseOffscreen } from "./usePauseOffscreen";
import { ToggleGroup, ToggleGroupItem } from "@/components/vendor/toggle-group";
import type { FeedItem } from "@/lib/launchpad/queries";
import { RIVER_TICKS, RIVER_WINDOW_MS, feedKey, inRiverWindow, initialRiver, layoutRiver, mergeRiver, pruneRiver, riverFlow, riverSummary, type RiverMark } from "@/lib/launchpad/river";
@@ -49,6 +50,7 @@ export default function LiveRiver({ initial, recent: seed = initial, serverNow,
const [lastAt, setLastAt] = useState(() => newestAt(initial, lastActivityAt));
const [width, setWidth] = useState(null);
const [view, setView] = useState<"bubbles" | "river" | "list">("bubbles");
+ const section = usePauseOffscreen(view !== "river"); // the bubbles float and the border shines only while seen; the river's marks are a real-time clock
const [hover, setHover] = useState(null);
const field = useRef(null);
const lane = useRef(null);
@@ -112,13 +114,14 @@ export default function LiveRiver({ initial, recent: seed = initial, serverNow,
const sameToken = hovered ? entries.filter((e) => e.item.kind === "swap" && e.item.token === hovered.item.token && e.item.chain === hovered.item.chain).length : 0;
return (
-
+
- Live on {WHERE}
+ {/* phones read "Live now" (the full name wrapped to three lines); the heading keeps its full name for assistive tech */}
+ Live on {WHERE}
{view === "river" ? riverSummary(entries, complete) : bubbleSummary(tokens, windowMs, bubblesComplete)}
@@ -138,12 +141,12 @@ export default function LiveRiver({ initial, recent: seed = initial, serverNow,
{view === "bubbles" ? (
-
+
A bubble for each token traded or launched in the last {bubbleWindowLabel(windowMs)}, sized by the dollars traded. Choose Show as a list for the same events as links.
- Launch a token. - We take nothing. -
-- {/* the brand as a plain word, once, above the fold: the title and footer alone read as a domain */} - openlaunch is the free, open-source launchpad on Base, Robinhood Chain and Arc. One transaction: your token, a Uniswap v4 pool, and a liquidity position locked forever. 100% of the supply goes into the pool at launch. You only pay gas. -
--
-
- Launchpad contracts are not configured yet. Read-only until NEXT_PUBLIC_LAUNCH_FACTORY / _LOCKER are set. +
+ Launch a token.
+
+ We take nothing.
+
+ + {/* the brand as a plain word, once, above the fold: the title and footer alone read as a domain */} + openlaunch is the free, open-source launchpad on Base, Robinhood Chain and Arc. One transaction: your token, a Uniswap v4 pool, and a liquidity position locked forever. 100% of the supply goes into the pool at launch. You only pay gas.
- ) : null} --
+
+ Launchpad contracts are not configured yet. Read-only until NEXT_PUBLIC_LAUNCH_FACTORY / _LOCKER are set. +
+ ) : null} +
- {/* the totals follow the shared live poll */}
-
- Live across
- {count(t.launches)} - tokens launched -
- {/* hairlines between the tiles come from the gap over a line-coloured grid */} --
-
-
+
-
- {CHAIN_KEYS.map((k) =>
Creators choose a 0%, 1% or 3% trading fee. It goes to their named recipients or is burned. The platform takes none: no fee address in the factory, no platform cut in the locker.
- Read the contracts -The numbers across {chainList("&", CHAIN_LABELS)}
+-
+ {CHAIN_KEYS.map((k) =>
≈ {usdNote}
: null} +Creators choose a 0%, 1% or 3% trading fee. It goes to their named recipients or is burned. The platform takes none: no fee address in the factory, no platform cut in the locker.
+ Read the contracts +
- Live on {WHERE}
+ {/* phones read "Live now" (the full name wrapped to three lines); the heading keeps its full name for assistive tech */}
+ Live on {WHERE}
{view === "river" ? riverSummary(entries, complete) : bubbleSummary(tokens, windowMs, bubblesComplete)}
A bubble for each token traded or launched in the last {bubbleWindowLabel(windowMs)}, sized by the dollars traded. Choose Show as a list for the same events as links.