diff --git a/app/package-lock.json b/app/package-lock.json index 90bd637..f6ee7dd 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -11,6 +11,7 @@ "@aws-sdk/client-s3": "^3.1127.0", "@base-ui/react": "^1.8.0", "@coinbase/wallet-sdk": "^4.3.7", + "@number-flow/react": "^0.6.2", "@tanstack/react-query": "^5.91.3", "clsx": "^2.1.1", "lightweight-charts": "^5.2.1", @@ -2340,6 +2341,20 @@ "node": ">=12.4.0" } }, + "node_modules/@number-flow/react": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@number-flow/react/-/react-0.6.2.tgz", + "integrity": "sha512-WjZuV4aA+vhRCgCF+adGLgFVVAJ8vdvq6EchRT2FiBIgElTXtDOA3YgkcMr9UHpvpS9v4H70AB5wZv0D9jc3QA==", + "license": "MIT", + "dependencies": { + "esm-env": "^1.1.4", + "number-flow": "0.6.2" + }, + "peerDependencies": { + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -4838,6 +4853,12 @@ "dev": true, "license": "MIT" }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -6700,6 +6721,15 @@ "node": ">=18" } }, + "node_modules/number-flow": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/number-flow/-/number-flow-0.6.2.tgz", + "integrity": "sha512-MCnImG4Q5vPwhSXnov56nOuyyKn6LC+Qd7II1UiKc+ACRtug5iAtn0+CwXNxM38AC5lSowEY+oYEtZX2qMnUyw==", + "license": "MIT", + "dependencies": { + "esm-env": "^1.1.4" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/app/package.json b/app/package.json index 1dc5102..fc1afe1 100644 --- a/app/package.json +++ b/app/package.json @@ -15,6 +15,7 @@ "@aws-sdk/client-s3": "^3.1127.0", "@base-ui/react": "^1.8.0", "@coinbase/wallet-sdk": "^4.3.7", + "@number-flow/react": "^0.6.2", "@tanstack/react-query": "^5.91.3", "clsx": "^2.1.1", "lightweight-charts": "^5.2.1", diff --git a/app/src/app/api/launch/search/route.ts b/app/src/app/api/launch/search/route.ts index 08174a9..ecfce96 100644 --- a/app/src/app/api/launch/search/route.ts +++ b/app/src/app/api/launch/search/route.ts @@ -5,14 +5,18 @@ import { ethUsd } from "@/lib/launchpad/ethPrice"; export const dynamic = "force-dynamic"; -/** GET /api/launch/search?q=&chain= → { q, launches } (name / symbol substring, or exact address). */ +/** + * GET /api/launch/search?q=&chain=&order= → { q, launches } (name / symbol substring, or exact address). + * Relevance first; ties go newest-first, or to the most-held token with `order=holders` (the ⌘K search). + */ export async function GET(req: Request) { const u = new URL(req.url); const q = (u.searchParams.get("q") ?? "").slice(0, 80); const c = u.searchParams.get("chain"); + const order = u.searchParams.get("order") === "holders" ? "holders" : "newest"; try { const usd = await ethUsd(); - const launches = await searchLaunches(q, { chain: isChainKey(c) ? c : null, limit: 20, ethUsd: usd }); + const launches = await searchLaunches(q, { chain: isChainKey(c) ? c : null, limit: 20, ethUsd: usd, order }); return NextResponse.json({ q, launches }, { headers: { "cache-control": "no-store" } }); } catch (err) { console.error("[launch] search failed:", err instanceof Error ? err.message : err); diff --git a/app/src/app/globals.css b/app/src/app/globals.css index 6d03e78..11c15a9 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -17,6 +17,7 @@ /* surfaces */ --color-paper: #fafaf8; --color-card: #ffffff; + --color-raised: #ffffff; /* floating layers: search, menus, sheets, dialogs */ --color-line: #e7e5e4; --color-line-strong: #d6d3d1; @@ -68,6 +69,7 @@ color-scheme: dark; --color-paper: #000000; --color-card: #0a0a0a; + --color-raised: #141414; /* one step up from card, so a floating layer reads without a shadow */ --color-line: #1f1f1f; --color-line-strong: #333333; --color-ink: #ffffff; @@ -106,6 +108,7 @@ color-scheme: light; --color-paper: #ffffff; --color-card: #ffffff; + --color-raised: #ffffff; --color-line: #e7e5e4; --color-line-strong: #d6d3d1; --color-ink: #0f172a; @@ -333,3 +336,24 @@ input[type="number"] { -moz-appearance: textfield; appearance: textfield; } animation: none !important; } } + +/* ── shared-element morphs between pages (React ) ── + A token's mark and name travel from the market row into the token page header. + The page root does not cross-fade (see above), so only the token moves. A short + blur mid-flight hides the scaling of text snapshots. */ +::view-transition-group(.morph) { + animation-duration: 360ms; + animation-timing-function: cubic-bezier(0.2, 0.7, 0.2, 1); +} +::view-transition-image-pair(.morph) { + animation-name: bb-via-blur; +} +@keyframes bb-via-blur { 40% { filter: blur(2px); } } +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(.morph), + ::view-transition-image-pair(.morph), + ::view-transition-old(.morph), + ::view-transition-new(.morph) { + animation: none !important; + } +} diff --git a/app/src/app/t/[chain]/[token]/loading.tsx b/app/src/app/t/[chain]/[token]/loading.tsx index 387209f..c395ea8 100644 --- a/app/src/app/t/[chain]/[token]/loading.tsx +++ b/app/src/app/t/[chain]/[token]/loading.tsx @@ -1,10 +1,13 @@ import { Sk } from "@/components/Skeleton"; +import PendingTokenHeader from "@/components/launchpad/PendingTokenHeader"; +/* Same frame as page.tsx (main padding, breadcrumb row, header): the token header that the market-row + morph lands on must sit exactly where the real one renders, or the second hop would jump. */ export default function Loading() { - return
- -
-
+ return
+
+ +
{Array.from({ length: 4 }, (_, i) =>
)}
diff --git a/app/src/app/t/[chain]/[token]/page.tsx b/app/src/app/t/[chain]/[token]/page.tsx index cc82467..53d98a5 100644 --- a/app/src/app/t/[chain]/[token]/page.tsx +++ b/app/src/app/t/[chain]/[token]/page.tsx @@ -4,6 +4,7 @@ import { notFound } from "next/navigation"; import { isAddress, type Address } from "viem"; import { ArrowLeft, ArrowUpRight, Globe, Share2 } from "lucide-react"; import TokenAvatar from "@/components/launchpad/TokenAvatar"; +import { MorphAvatar, MorphName } from "@/components/launchpad/TokenMorph"; import { feeModeOf } from "@/components/launchpad/FeeChip"; import TradePanel from "@/components/launchpad/TradePanel"; import CollectPanel from "@/components/launchpad/CollectPanel"; @@ -122,9 +123,14 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s
- + {/* the mark and name the market row morphs into (see TokenMorph); PendingTokenHeader holds the same frame while loading */} + + +
-

{l.name}

+ +

{l.name}

+
${l.symbol}·{quote.key === "twig" ? : quote.key === "gitlawb" ? : quote.key === "museworld" ? : Paired with {quote.symbol}}{unlisted ? : null}·Launched {ago(l.block_time, now)} ago
diff --git a/app/src/components/CommandPalette.tsx b/app/src/components/CommandPalette.tsx new file mode 100644 index 0000000..f14e155 --- /dev/null +++ b/app/src/components/CommandPalette.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useEffect, useId, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTheme } from "next-themes"; +import { Dialog } from "@base-ui/react/dialog"; +import { Autocomplete } from "@base-ui/react/autocomplete"; +import { ArrowLeftRight, CornerDownLeft, FileText, Moon, Search, Sun } from "lucide-react"; +import TokenAvatar from "@/components/launchpad/TokenAvatar"; +import { CHAIN_SHORT } from "@/lib/chainPublic"; +import { marketChange, marketUsd } from "@/lib/launchpad/market-format"; +import { setPendingToken } from "@/lib/launchpad/token-transition"; +import { buildPaletteGroups, isSearchableQuery, paletteTokenFrom, type PaletteGroup, type PaletteItem, type PaletteToken } from "@/lib/command-palette"; +import { useOpenBridge } from "./bridge/BridgeProvider"; + +type LaunchesResponse = { launches?: Parameters[0][] }; + +/** + * ⌘K search: tokens (by name, symbol or address), pages and a few actions, from any screen. + * Base UI's Dialog + inline Autocomplete own focus, keyboard navigation and the listbox semantics. + */ +export default function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const router = useRouter(); + const { resolvedTheme, setTheme } = useTheme(); + const openBridge = useOpenBridge(); + const hintId = useId(); + const [query, setQuery] = useState(""); + const [trending, setTrending] = useState(null); + const [results, setResults] = useState<{ q: string; tokens: PaletteToken[]; failed: boolean } | null>(null); + + // What is trending fills the list before anything is typed. Fetched once, on the first open. + useEffect(() => { + if (!open || trending !== null) return; + const ac = new AbortController(); + fetch("/api/launch/list?sort=trending&limit=6", { signal: ac.signal, cache: "no-store" }) + .then((r) => (r.ok ? (r.json() as Promise) : null)) + .then((j) => setTrending((j?.launches ?? []).map(paletteTokenFrom))) + .catch(() => { + if (!ac.signal.aborted) setTrending([]); + }); + return () => ac.abort(); + }, [open, trending]); + + // Token search, debounced; a late answer for an older query is dropped. + const q = query.trim(); + useEffect(() => { + if (!open || !isSearchableQuery(q)) return; + const ac = new AbortController(); + const timer = setTimeout(() => { + // order=holders: among equally relevant names, the token people actually hold comes first + fetch(`/api/launch/search?q=${encodeURIComponent(q)}&order=holders`, { signal: ac.signal, cache: "no-store" }) + .then((r) => (r.ok ? (r.json() as Promise) : Promise.reject(new Error(String(r.status))))) + .then((j) => setResults({ q, tokens: (j.launches ?? []).slice(0, 8).map(paletteTokenFrom), failed: false })) + .catch(() => { + if (!ac.signal.aborted) setResults({ q, tokens: [], failed: true }); + }); + }, 160); + return () => { + clearTimeout(timer); + ac.abort(); + }; + }, [open, q]); + + const current = results && results.q === q ? results : null; + const searching = isSearchableQuery(q) && !current; + const theme = resolvedTheme === "dark" ? "dark" : "light"; + const groups = useMemo(() => buildPaletteGroups({ query, tokens: current?.tokens ?? [], trending: trending ?? [], theme }), [query, current, trending, theme]); + const status = searching ? "Searching tokens…" : current?.failed ? "Token search is unavailable right now. Pages and actions still work." : ""; + + const close = () => { + onOpenChange(false); + setQuery(""); + }; + + const run = (item: PaletteItem) => { + close(); + if (item.kind === "token") { + const t = item.token; + setPendingToken({ chain: t.chain, token: t.token, name: t.name, symbol: t.symbol, image: t.image }); + router.push(`/t/${t.chain}/${t.token}`); + } else if (item.kind === "page") { + router.push(item.href); + } else if (item.action === "bridge") { + // let this dialog finish closing first, so focus lands in the bridge panel + requestAnimationFrame(() => openBridge?.()); + } else { + setTheme(theme === "dark" ? "light" : "dark"); + } + }; + + return ( + (next ? onOpenChange(true) : close())}> + + + + + { + // choosing an item writes its label into the field; the dialog is closing, so keep the query as typed + if (details.reason !== "item-press") setQuery(next); + }} + itemToStringValue={(item: PaletteItem) => item.label} + autoHighlight="always" + keepHighlight + > + {/* focus shows as the field's underline turning blue (the global ring would box the whole header row) */} + + + + Esc + + +
+ {status} + +
+ {searching ? "Searching tokens…" : <>Nothing matches “{q}”. Try a token symbol or paste its contract address.} +
+
+ + {(group: PaletteGroup) => ( + + {group.value} + + {(item: PaletteItem) => ( + run(item)} + className="group flex min-h-11 cursor-pointer items-center gap-3 rounded-xl px-2 text-sm text-ink outline-none select-none data-[highlighted]:bg-line" + > + + + )} + + + )} + +
+ +
+ Use the arrow keys to move and Enter to open. + Open +
+
+
+
+
+
+ ); +} + +function PaletteRow({ item }: { item: PaletteItem }) { + if (item.kind === "token") { + const t = item.token; + const change = marketChange(t.change); + return ( + <> + + + {t.name} + + {t.symbol} + {CHAIN_SHORT[t.chain as keyof typeof CHAIN_SHORT] ?? t.chain} + + + + {t.capUsd !== null ? marketUsd(t.capUsd) : "—"} + {change.label} + + + ); + } + const Icon = item.kind === "page" ? FileText : item.action === "bridge" ? ArrowLeftRight : item.label.includes("light") ? Sun : Moon; + return ( + <> + + {item.label} + {item.kind === "page" ? "Page" : "Action"} + + ); +} diff --git a/app/src/components/HeaderNav.tsx b/app/src/components/HeaderNav.tsx index 6b07a44..c577165 100644 --- a/app/src/components/HeaderNav.tsx +++ b/app/src/components/HeaderNav.tsx @@ -2,10 +2,12 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { MotionConfig, motion, useReducedMotion } from "motion/react"; -import { ArrowRight, Menu, X } from "lucide-react"; +import { Menu, Search, X } from "lucide-react"; import { cn } from "@/lib/utils"; +import { isTypingTarget } from "@/lib/command-palette"; +import CommandPalette from "./CommandPalette"; import { Navbar, NavBody, MobileNav, MobileNavHeader, MobileNavMenu } from "./navigation-shell"; import Mark, { Wordmark } from "./launchpad/Mark"; import { BRAND_X } from "@/lib/brand"; @@ -37,17 +39,59 @@ export default function HeaderNav({ pulse }: { pulse: Pulse }) { const pathname = usePathname(); const isActive = (href: string) => (href === "/" ? pathname === "/" || pathname.startsWith("/t/") : pathname.startsWith(href)); const heroCtaOnScreen = useHeroCtaOnScreen(pathname); + const [searchOpen, setSearchOpen] = useState(false); + const openSearch = useCallback(() => setSearchOpen(true), []); + useSearchShortcut(setSearchOpen); return ( - - + + + {/* outside Navbar: it injects `visible` into its direct children */} + ); } +/** ⌘K or Ctrl+K toggles search from anywhere; "/" opens it unless the visitor is typing in a field. */ +function useSearchShortcut(setOpen: React.Dispatch>) { + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "k") { + e.preventDefault(); + setOpen((v) => !v); + } else if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isTypingTarget(e.target as HTMLElement | null)) { + e.preventDefault(); + setOpen(true); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [setOpen]); +} + +const noSubscribe = () => () => {}; +/** "⌘K" on Apple devices, "Ctrl K" elsewhere; the server renders the Apple form and the client corrects it after hydration. */ +function useShortcutLabel(): string { + return useSyncExternalStore(noSubscribe, () => (/Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? "⌘K" : "Ctrl K"), () => "⌘K"); +} + +/** + * The header's way into search: one icon button that names its shortcut. The strip is already full at + * rest (pulse, four links, five controls), so a labelled field would push it past the page column; + * the home redesign gives search a proper field. + */ +function SearchTrigger({ onClick }: { onClick: () => void }) { + const shortcut = useShortcutLabel(); + return ( + + ); +} + /** * One filled blue per screen: while the home hero's own "Launch a token" is in * view, the header's copy of it stays a hairline utility. Watches `#hero-cta`; @@ -72,7 +116,7 @@ function useHeroCtaOnScreen(pathname: string) { // ── desktop ────────────────────────────────────────────────────────────────── -function Desktop({ visible = false, pulse, isActive, quietCta }: { visible?: boolean; pulse: Pulse; isActive: (href: string) => boolean; quietCta: boolean }) { +function Desktop({ visible = false, pulse, isActive, quietCta, onSearch }: { visible?: boolean; pulse: Pulse; isActive: (href: string) => boolean; quietCta: boolean; onSearch: () => void }) { return (
- + + {/* the floating pill is 800px wide: search costs a button, so the bridge gives up its label */} + @@ -176,7 +222,6 @@ function LaunchCta({ compact = false, block = false, quiet = false, onNavigate } Launch a token )} - ); } @@ -192,7 +237,7 @@ function XLink({ block = false }: { block?: boolean }) { if (block) { return ( - @{BRAND_X} · on X + @{BRAND_X} on X {icon} ); @@ -206,7 +251,7 @@ function XLink({ block = false }: { block?: boolean }) { // ── mobile ─────────────────────────────────────────────────────────────────── -function Mobile({ visible = false, pulse, isActive }: { visible?: boolean; pulse: Pulse; isActive: (href: string) => boolean }) { +function Mobile({ visible = false, pulse, isActive, onSearch }: { visible?: boolean; pulse: Pulse; isActive: (href: string) => boolean; onSearch: () => void }) { const [open, setOpen] = useState(false); const menuToggle = useRef(null); const dismissMenu = useCallback(() => { @@ -231,6 +276,18 @@ function Mobile({ visible = false, pulse, isActive }: { visible?: boolean; pulse {/* self-hides below sm; on tablets it rides beside the wordmark as on desktop */}
+
+ +
diff --git a/app/src/components/LiveNumber.tsx b/app/src/components/LiveNumber.tsx new file mode 100644 index 0000000..08c491c --- /dev/null +++ b/app/src/components/LiveNumber.tsx @@ -0,0 +1,16 @@ +"use client"; + +import NumberFlow, { type Format } from "@number-flow/react"; + +/** Whole-dollar figures that grow past a thousand read as "$14.3M"; below that, "$412". */ +export const COMPACT_USD: Format = { style: "currency", currency: "USD", notation: "compact", maximumFractionDigits: 1 }; + +/** + * A live figure whose digits roll to the new value when it changes, so an update reads as + * movement rather than a flash. The first paint is static, and visitors who prefer reduced + * motion get the new value in place (NumberFlow honours `prefers-reduced-motion`). + */ +export default function LiveNumber({ value, format, prefix, suffix, className }: { value: number; format?: Format; prefix?: string; suffix?: string; className?: string }) { + if (!Number.isFinite(value)) return —; + return ; +} diff --git a/app/src/components/NotificationSettings.module.css b/app/src/components/NotificationSettings.module.css index 17463c3..9d36330 100644 --- a/app/src/components/NotificationSettings.module.css +++ b/app/src/components/NotificationSettings.module.css @@ -29,7 +29,7 @@ overflow-y: auto; overscroll-behavior: contain; padding: 8px 20px 0; - background: var(--color-card); + background: var(--color-raised); color: var(--color-ink); border: 1px solid var(--color-line-strong); border-radius: 18px; @@ -45,7 +45,7 @@ .close:hover { background: var(--color-line); color: var(--color-ink); } .description { color: var(--color-body); font-size: 12px; line-height: 1.6; padding-bottom: 20px; } .preference { display: flex; width: calc(100% + 16px); margin: 0 -8px; padding: 16px 8px; align-items: center; gap: 16px; border-top: 1px solid var(--color-line); text-align: left; cursor: pointer; border-radius: 8px; transition: background 160ms ease; } -.preference:hover { background: var(--color-paper); } +.preference:hover { background: var(--color-line); } .preferenceCopy { min-width: 0; flex: 1; } .label { display: block; color: var(--color-ink); font-size: 13px; font-weight: 500; line-height: 1.5; } .help { display: block; margin-top: 4px; font-size: 12px; line-height: 1.65; color: var(--color-body); text-wrap: pretty; } diff --git a/app/src/components/Sheet.tsx b/app/src/components/Sheet.tsx index 692d34e..ca96c1c 100644 --- a/app/src/components/Sheet.tsx +++ b/app/src/components/Sheet.tsx @@ -54,7 +54,7 @@ export default function Sheet({ aria-label={title} >
e.stopPropagation()} >
diff --git a/app/src/components/bridge/BridgeDialog.module.css b/app/src/components/bridge/BridgeDialog.module.css index a179c3f..82161cd 100644 --- a/app/src/components/bridge/BridgeDialog.module.css +++ b/app/src/components/bridge/BridgeDialog.module.css @@ -1,9 +1,11 @@ .trigger { display: inline-flex; align-items: center; justify-content: center; gap: 7px; min-height: 36px; padding: 0 11px; border: 1px solid var(--color-line); border-radius: 999px; color: var(--color-body); background: var(--color-card); font-size: 13px; font-weight: 500; cursor: pointer; transition: background 160ms, color 160ms, transform 160ms; } +.compactTrigger { width: 36px; padding: 0; } +.compactTrigger .triggerLabel { display: none; } .blockTrigger { width: 100%; justify-content: flex-start; min-height: 48px; padding: 0 12px; border: 0; border-radius: 12px; color: var(--color-ink); font-size: 16px; } .triggerHint { margin-left: auto; font-size: 12px; color: var(--color-muted); } .backdrop { position: fixed; inset: 0; z-index: 80; background: color-mix(in srgb, var(--color-scrim) 45%, transparent); opacity: 1; transition: opacity 180ms ease-out; } .backdrop[data-starting-style], .backdrop[data-ending-style] { opacity: 0; } -.panel { position: fixed; z-index: 81; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(480px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; padding: 26px 28px 20px; border: 1px solid var(--color-line); border-radius: 24px; background: var(--color-card); color: var(--color-ink); box-shadow: var(--shadow-dialog); outline: none; scrollbar-width: thin; scrollbar-color: var(--color-line-strong) transparent; transition: opacity 180ms ease-out, transform 220ms cubic-bezier(.16, 1, .3, 1); } +.panel { position: fixed; z-index: 81; top: 50%; left: 50%; transform: translate(-50%, -50%); width: min(480px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow-y: auto; overscroll-behavior: contain; padding: 26px 28px 20px; border: 1px solid var(--color-line); border-radius: 24px; background: var(--color-raised); color: var(--color-ink); box-shadow: var(--shadow-dialog); outline: none; scrollbar-width: thin; scrollbar-color: var(--color-line-strong) transparent; transition: opacity 180ms ease-out, transform 220ms cubic-bezier(.16, 1, .3, 1); } .panel[data-starting-style], .panel[data-ending-style] { opacity: 0; transform: translate(-50%, calc(-50% + 8px)) scale(.98); } .panel ::selection { background: var(--color-brand-soft); color: var(--color-brand); } .heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } diff --git a/app/src/components/bridge/BridgeProvider.tsx b/app/src/components/bridge/BridgeProvider.tsx index 9f110bc..4b6d99d 100644 --- a/app/src/components/bridge/BridgeProvider.tsx +++ b/app/src/components/bridge/BridgeProvider.tsx @@ -30,10 +30,16 @@ export function BridgeProvider({ children }: { children: React.ReactNode }) { ); } -export function BridgeButton({ block = false, onOpen }: { block?: boolean; onOpen?: () => void }) { +/** Opens the bridge panel from anywhere inside the provider (the ⌘K search uses it). Null outside it. */ +export function useOpenBridge(): (() => void) | null { + return useContext(BridgeContext); +} + +/** `compact` drops the label (icon only) where the header has no room, as in the floating pill. */ +export function BridgeButton({ block = false, compact = false, onOpen }: { block?: boolean; compact?: boolean; onOpen?: () => void }) { const show = useContext(BridgeContext); return ( -
; } -function Metric({ label, value, title, accent = false }: { label: string; value: string; title?: string; accent?: boolean }) { +function Metric({ label, value, title, accent = false }: { label: string; value: React.ReactNode; title?: string; accent?: boolean }) { return
{label}
{value}
; } diff --git a/app/src/components/launchpad/LaunchRow.tsx b/app/src/components/launchpad/LaunchRow.tsx index 1d6658e..bcb1228 100644 --- a/app/src/components/launchpad/LaunchRow.tsx +++ b/app/src/components/launchpad/LaunchRow.tsx @@ -13,6 +13,8 @@ import UnlistedPairBadge from "./UnlistedPairBadge"; import { marketUsd } from "@/lib/launchpad/market-format"; import { capDisplay } from "@/lib/launchpad/market-cap"; import type { LiveTier } from "@/lib/launchpad/ranking"; +import { MorphAvatar, MorphName } from "./TokenMorph"; +import { setPendingToken } from "@/lib/launchpad/token-transition"; const columns = "md:grid-cols-[minmax(0,1fr)_6.5rem_5.5rem_6rem_5rem_2.5rem]"; @@ -47,14 +49,24 @@ export default function LaunchRow({ l, rank, window = "all", hl = null, now, pop const age = ; return ( - + setPendingToken({ chain: l.chain, token: l.token, name: l.name, symbol: l.symbol, image: l.image_url })} + className={`bb-market-row group block border-b border-line bg-paper px-4 py-3.5 transition-colors hover:bg-card focus-visible:relative focus-visible:z-10 motion-reduce:transition-none ${flash}`} + title={l.description || `${l.name} (${l.symbol})`} + >
{rank !== undefined ? {rank} : null} - + + +
- {l.name} + + {l.name} + {l.quote_key === "other" ? : null} {hl?.kind === "new" ? New : null} diff --git a/app/src/components/launchpad/LivePulse.tsx b/app/src/components/launchpad/LivePulse.tsx index f94fffe..f5c62ad 100644 --- a/app/src/components/launchpad/LivePulse.tsx +++ b/app/src/components/launchpad/LivePulse.tsx @@ -1,14 +1,12 @@ "use client"; import { useEffect, useState } from "react"; +import LiveNumber from "../LiveNumber"; type Pulse = { visits: number; online: number }; -function compact(n: number): string { - if (n < 10_000) return n.toLocaleString("en-US"); - if (n < 1_000_000) return `${(n / 1_000).toFixed(n < 100_000 ? 1 : 0).replace(/\.0$/, "")}k`; - return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}m`; -} +/** "56.9K" in the narrow pill; the full count from md up. */ +const COMPACT = { notation: "compact", maximumFractionDigits: 1 } as const; /** * "12,480 visits · 3 online". Sends a cookie-free beacon on mount and every 30s @@ -17,7 +15,6 @@ function compact(n: number): string { */ export default function LivePulse({ initial, block = false }: { initial: Pulse; block?: boolean }) { const [p, setP] = useState(initial); - const [armed, setArmed] = useState(false); // no pop on first paint, only on later changes useEffect(() => { let alive = true; @@ -31,13 +28,11 @@ export default function LivePulse({ initial, block = false }: { initial: Pulse; } }; void beat(); - const arm = setTimeout(() => setArmed(true), 1_000); const t = setInterval(() => void beat(), 30_000); const onVis = () => void beat(); document.addEventListener("visibilitychange", onVis); return () => { alive = false; - clearTimeout(arm); clearInterval(t); document.removeEventListener("visibilitychange", onVis); }; @@ -48,11 +43,8 @@ export default function LivePulse({ initial, block = false }: { initial: Pulse; {/* beacon: solid dot + an expanding, fading ring (only while someone is here; static under reduced motion) */} - {/* remount on change → one short scale pop, like the market caps in the list */} - - {p.online.toLocaleString("en-US")} - {" "} - online + {/* the count rolls to its new value when people arrive or leave */} + online ); @@ -60,7 +52,7 @@ export default function LivePulse({ initial, block = false }: { initial: Pulse; return (
- {p.visits.toLocaleString("en-US")} visits + visits {online}
@@ -73,8 +65,8 @@ export default function LivePulse({ initial, block = false }: { initial: Pulse; > - {compact(p.visits)} - {p.visits.toLocaleString("en-US")} + + {" "} visits diff --git a/app/src/components/launchpad/PendingTokenHeader.tsx b/app/src/components/launchpad/PendingTokenHeader.tsx new file mode 100644 index 0000000..a467af9 --- /dev/null +++ b/app/src/components/launchpad/PendingTokenHeader.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import { useSyncExternalStore } from "react"; +import { Sk } from "@/components/Skeleton"; +import TokenAvatar from "./TokenAvatar"; +import { MorphAvatar, MorphName } from "./TokenMorph"; +import { getPendingToken, pendingTokenFor, subscribePendingToken } from "@/lib/launchpad/token-transition"; + +/** + * The token page's loading header. When the visitor arrived by tapping a market row, it already + * shows that token's mark and name, at the same size and place as the real header, so the morph + * from the row lands here and the page fills in around it. Otherwise it is a plain skeleton. + */ +export default function PendingTokenHeader() { + const pathname = usePathname(); + const note = useSyncExternalStore(subscribePendingToken, getPendingToken, () => null); + const p = pendingTokenFor(pathname, note); + return ( +
+
+ {p ? ( + + + + ) : ( + + )} +
+ {p ? ( + +

{p.name}

+
+ ) : ( + + )} + +
+
+
+ ); +} diff --git a/app/src/components/launchpad/TokenMorph.tsx b/app/src/components/launchpad/TokenMorph.tsx new file mode 100644 index 0000000..403dd31 --- /dev/null +++ b/app/src/components/launchpad/TokenMorph.tsx @@ -0,0 +1,26 @@ +import { ViewTransition } from "react"; +import { tokenMorphNames } from "@/lib/launchpad/token-transition"; + +/* + * The token's mark and name travel from the market row into the token page header as one object. + * `share="morph"` animates the pair; `default="none"` keeps these names still during unrelated + * transitions (a list re-sort, a filter change). Browsers without view transitions navigate normally. + * Only one surface per page may carry a given name, so the market list wears it and the trending + * strip, tape and search do not. + */ + +export function MorphAvatar({ chain, token, children }: { chain: string; token: string; children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function MorphName({ chain, token, children }: { chain: string; token: string; children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/src/components/launchpad/change-chip.test.ts b/app/src/components/launchpad/change-chip.test.ts index 77bd48a..b437d8e 100644 --- a/app/src/components/launchpad/change-chip.test.ts +++ b/app/src/components/launchpad/change-chip.test.ts @@ -47,7 +47,8 @@ for (const plain of [false, true]) { [0.014, "+1.4%", "text-up"], [-0.034, "-3.4%", "text-down-ink"], [0.12, "+12%", "text-up"], - [10, "+1K%", "text-up"], + [10, "11.0×", "text-up"], + [18.7, "19.7×", "text-up"], [-10, "-1K%", "text-down-ink"], ] as const) { const chip = render({ v, plain, context: "in 24h", className: "custom-chip" }); diff --git a/app/src/components/theme.test.ts b/app/src/components/theme.test.ts index c3309f7..30099aa 100644 --- a/app/src/components/theme.test.ts +++ b/app/src/components/theme.test.ts @@ -84,7 +84,8 @@ test("dark greys are near-achromatic", () => { test("the dark surface ramp steps away from the ground monotonically (light: white cards on off-white paper, by design)", () => { for (const [name, palette] of THEMES.filter(([n]) => n === "dark")) { - const steps = ["paper", "card", "line", "line-strong"].map((n) => luminance(palette[n])); + // raised sits between card and the hairlines: a floating layer must read as lifted, never as a border + const steps = ["paper", "card", "raised", "line", "line-strong"].map((n) => luminance(palette[n])); const rising = steps[1] > steps[0]; for (let i = 1; i < steps.length; i++) { assert.equal(steps[i] > steps[i - 1], rising, `${name}: surface ramp reverses at index ${i}`); @@ -94,7 +95,7 @@ test("the dark surface ramp steps away from the ground monotonically (light: whi test("dark text, semantic labels, and filled controls meet AA contrast (light is the shipped palette; it is checked for regressions, not re-tuned)", () => { const pairs = [ - ...["paper", "card"].flatMap((surface) => ["ink", "body", "muted", "brand", "up", "down-ink", "warm-ink"].map((text) => [text, surface])), + ...["paper", "card", "raised"].flatMap((surface) => ["ink", "body", "muted", "brand", "up", "down-ink", "warm-ink"].map((text) => [text, surface])), ...["ink", "brand", "brand-strong", "up", "down", "warm-ink"].map((fill) => ["inverse", fill]), ["brand", "brand-soft"], ["up", "up-soft"], ["down-ink", "down-soft"], ["warm-ink", "warm-soft"], ["up", "holder-good-bg"], ]; @@ -130,9 +131,16 @@ test("the view-transition wipe is disabled under reduced motion", () => { test("print falls back to ink on white", () => { const print = colors(printBlock); assert.equal(print.paper, "#ffffff", "black paper does not print"); + assert.equal(print.raised, "#ffffff", "floating layers print on white too"); assert.equal(print.ink, light.ink); }); +test("page morphs are named transitions, and they stop under reduced motion", () => { + assert.match(css, /::view-transition-group\(\.morph\)/, "the token morph needs its own transition CSS"); + const reduced = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n\}\n?/g)?.join("\n") ?? ""; + assert.match(reduced, /::view-transition-group\(\.morph\)/, "reduced motion must also stop the token morph"); +}); + test("runtime chart tokens survive Tailwind tree-shaking (@theme static) and exist in both themes", () => { assert.match(css, /@theme static\s*\{/); for (const t of ["chart-grid", "chart-vol"]) { diff --git a/app/src/lib/command-palette.test.ts b/app/src/lib/command-palette.test.ts new file mode 100644 index 0000000..0cc5f52 --- /dev/null +++ b/app/src/lib/command-palette.test.ts @@ -0,0 +1,71 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { PALETTE_PAGES, buildPaletteGroups, isSearchableQuery, isTypingTarget, matchesQuery, paletteTokenFrom, type PaletteToken } from "./command-palette.ts"; + +const waifu: PaletteToken = { chain: "base", token: "0xf3018d16d3c75f86df76bdeed12027c813f4e4a0", name: "WAIFU", symbol: "WAIFU", image: null, capUsd: 53471, change: 18.7, holders: 196 }; +const dottie: PaletteToken = { chain: "robinhood", token: "0x5b3c2cd87083ea5c4436525dca6213740405b69e", name: "DottieLand", symbol: "DOTTIE", image: null, capUsd: 53205, change: 9.54, holders: 285 }; + +test("before anything is typed: trending tokens, every page, both actions", () => { + const groups = buildPaletteGroups({ query: "", tokens: [], trending: [waifu, dottie], theme: "dark" }); + assert.deepEqual(groups.map((g) => g.value), ["Trending now", "Pages", "Actions"]); + assert.deepEqual(groups[0].items.map((i) => i.label), ["WAIFU", "DottieLand"]); + assert.equal(groups[1].items.length, PALETTE_PAGES.length); + assert.deepEqual(groups[2].items.map((i) => i.label), ["Bridge funds between chains", "Switch to the light theme"]); +}); + +test("a query narrows pages and actions and shows search hits under Tokens", () => { + const groups = buildPaletteGroups({ query: "launch", tokens: [waifu], trending: [dottie], theme: "light" }); + assert.equal(groups[0].value, "Tokens"); + assert.deepEqual(groups[0].items.map((i) => i.label), ["WAIFU"]); + const pages = groups.find((g) => g.value === "Pages")?.items.map((i) => i.label) ?? []; + assert.ok(pages.includes("Launch a token")); + assert.ok(!pages.includes("Posts")); + assert.equal(groups.find((g) => g.value === "Actions"), undefined, "no action mentions launch"); +}); + +test("one character never searches tokens and never shows trending as if it matched", () => { + const groups = buildPaletteGroups({ query: "w", tokens: [waifu], trending: [dottie], theme: "dark" }); + assert.equal(groups.find((g) => g.value === "Tokens" || g.value === "Trending now"), undefined); + assert.equal(isSearchableQuery("w"), false); + assert.equal(isSearchableQuery(" wa "), true); +}); + +test("the theme action always offers the other theme, and keywords find it", () => { + const dark = buildPaletteGroups({ query: "theme", tokens: [], trending: [], theme: "dark" }); + assert.deepEqual(dark.map((g) => g.items.map((i) => i.label)), [["Switch to the light theme"]]); + const light = buildPaletteGroups({ query: "dark mode", tokens: [], trending: [], theme: "light" }); + assert.deepEqual(light.map((g) => g.items.map((i) => i.label)), [["Switch to the dark theme"]]); +}); + +test("every query word must appear, in any order, case-insensitively", () => { + assert.equal(matchesQuery("chain robinhood", "Launches on Robinhood Chain"), true); + assert.equal(matchesQuery("ROBIN", "launches on robinhood chain"), true); + assert.equal(matchesQuery("robinhood base", "Launches on Robinhood Chain"), false); + assert.equal(matchesQuery(" ", "anything"), true); +}); + +test("launch rows map to search tokens without inventing a price", () => { + const t = paletteTokenFrom({ chain: "arc", token: "0x648A5382BDCF286E7FF5122D01A983DB314E620A", name: "Pacharan", symbol: "PANCHU", image_url: "https://openlaunch.lol/api/launch/image/t/x.webp", fdv_usd: 21381, change_from_launch: 3.2, holders: 1480 }); + assert.deepEqual(t, { chain: "arc", token: "0x648a5382bdcf286e7ff5122d01a983db314e620a", name: "Pacharan", symbol: "PANCHU", image: "https://openlaunch.lol/api/launch/image/t/x.webp", capUsd: 21381, change: 3.2, holders: 1480 }); + const unpriced = paletteTokenFrom({ chain: "base", token: "0xabc", name: "X", symbol: "X", fdv_usd: null, change_from_launch: Number.NaN }); + assert.equal(unpriced.capUsd, null); + assert.equal(unpriced.change, 0); + assert.equal(unpriced.image, null); + assert.equal(unpriced.holders, 0); +}); + +test("item ids are unique across groups, so the list can key on them", () => { + const groups = buildPaletteGroups({ query: "", tokens: [], trending: [waifu, dottie], theme: "dark" }); + const ids = groups.flatMap((g) => g.items.map((i) => i.id)); + assert.equal(new Set(ids).size, ids.length); +}); + +test("the slash shortcut types a slash inside fields and opens search everywhere else", () => { + assert.equal(isTypingTarget({ tagName: "input" }), true); + assert.equal(isTypingTarget({ tagName: "TEXTAREA" }), true); + assert.equal(isTypingTarget({ tagName: "SELECT" }), true); + assert.equal(isTypingTarget({ tagName: "DIV", isContentEditable: true }), true); + assert.equal(isTypingTarget({ tagName: "BODY" }), false); + assert.equal(isTypingTarget({ tagName: "BUTTON" }), false); + assert.equal(isTypingTarget(null), false); +}); diff --git a/app/src/lib/command-palette.ts b/app/src/lib/command-palette.ts new file mode 100644 index 0000000..096be4a --- /dev/null +++ b/app/src/lib/command-palette.ts @@ -0,0 +1,85 @@ +/** + * The model behind ⌘K search: what it can open, how a query narrows it, and how results group. + * Pure on purpose (node --test loads it directly); the dialog in components/CommandPalette.tsx + * only renders these groups and runs the chosen item. + */ + +export type PaletteToken = { chain: string; token: string; name: string; symbol: string; image: string | null; capUsd: number | null; change: number; holders: number }; + +export type PaletteItem = + | { kind: "token"; id: string; label: string; token: PaletteToken } + | { kind: "page"; id: string; label: string; href: string; keywords: string } + | { kind: "action"; id: string; label: string; action: "bridge" | "theme"; keywords: string }; + +export type PaletteGroup = { value: string; items: PaletteItem[] }; + +export const PALETTE_PAGES: ReadonlyArray<{ id: string; label: string; href: string; keywords: string }> = [ + { id: "page:home", label: "Launchpad", href: "/", keywords: "home market tokens list trending new top" }, + { id: "page:launch", label: "Launch a token", href: "/launch", keywords: "create deploy new token mint start" }, + { id: "page:feed", label: "Posts", href: "/feed", keywords: "community comments social feed conversation" }, + { id: "page:rules", label: "How it works", href: "/rules", keywords: "rules learn fees locker liquidity contracts risks faq" }, + { id: "page:agents", label: "Agents and API", href: "/agents", keywords: "api developers llms json bots code" }, + { id: "page:me", label: "Your dashboard", href: "/me", keywords: "me wallet portfolio fees collect launches holdings" }, + { id: "page:about", label: "About openlaunch", href: "/about", keywords: "about open source mit" }, + { id: "page:base", label: "Launches on Base", href: "/base", keywords: "base chain network" }, + { id: "page:robinhood", label: "Launches on Robinhood Chain", href: "/robinhood", keywords: "robinhood chain network stocks" }, + { id: "page:arc", label: "Launches on Arc", href: "/arc", keywords: "arc chain network usdc" }, +]; + +/** Token search starts at two characters; one letter matches half the market. */ +export function isSearchableQuery(query: string): boolean { + return query.trim().length >= 2; +} + +/** Every word of the query has to appear somewhere in the text, in any order. */ +export function matchesQuery(query: string, text: string): boolean { + const haystack = text.toLowerCase(); + return query.toLowerCase().split(/\s+/).filter(Boolean).every((word) => haystack.includes(word)); +} + +type LaunchLike = { chain: string; token: string; name: string; symbol: string; image_url?: string | null; fdv_usd?: number | null; change_from_launch?: number | null; holders?: number | null }; + +const finite = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); + +/** The fields search needs from a launch row returned by /api/launch/search or /api/launch/list. */ +export function paletteTokenFrom(l: LaunchLike): PaletteToken { + return { + chain: l.chain, + token: l.token.toLowerCase(), + name: l.name, + symbol: l.symbol, + image: l.image_url ?? null, + capUsd: finite(l.fdv_usd) ? l.fdv_usd : null, + change: finite(l.change_from_launch) ? l.change_from_launch : 0, + holders: finite(l.holders) ? l.holders : 0, + }; +} + +/** + * Results in reading order: tokens first (search hits, or what is trending before anything is + * typed), then pages, then actions. Empty groups are dropped. + */ +export function buildPaletteGroups({ query, tokens, trending, theme }: { query: string; tokens: PaletteToken[]; trending: PaletteToken[]; theme: "light" | "dark" }): PaletteGroup[] { + const q = query.trim(); + const tokenList = q ? (isSearchableQuery(q) ? tokens : []) : trending; + const tokenItems: PaletteItem[] = tokenList.map((t) => ({ kind: "token", id: `token:${t.chain}:${t.token}`, label: t.name, token: t })); + const pages: PaletteItem[] = PALETTE_PAGES.filter((p) => !q || matchesQuery(q, `${p.label} ${p.keywords}`)).map((p) => ({ kind: "page", ...p })); + const next = theme === "dark" ? "light" : "dark"; + const actions: PaletteItem[] = [ + { kind: "action" as const, id: "action:bridge", label: "Bridge funds between chains", action: "bridge" as const, keywords: "bridge relay move deposit base robinhood arc" }, + { kind: "action" as const, id: "action:theme", label: `Switch to the ${next} theme`, action: "theme" as const, keywords: `theme ${next} mode appearance colors` }, + ].filter((a) => !q || matchesQuery(q, `${a.label} ${a.keywords}`)); + return [ + { value: q ? "Tokens" : "Trending now", items: tokenItems }, + { value: "Pages", items: pages }, + { value: "Actions", items: actions }, + ].filter((g) => g.items.length > 0); +} + +/** Typing "/" in a field types a slash; everywhere else it opens search. */ +export function isTypingTarget(target: { tagName?: string; isContentEditable?: boolean } | null | undefined): boolean { + if (!target) return false; + if (target.isContentEditable) return true; + const tag = (target.tagName ?? "").toUpperCase(); + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} diff --git a/app/src/lib/launchpad/market-format.test.ts b/app/src/lib/launchpad/market-format.test.ts index b551baa..5c96dd9 100644 --- a/app/src/lib/launchpad/market-format.test.ts +++ b/app/src/lib/launchpad/market-format.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { marketChange, marketUsd } from "./market-format.ts"; +import { launchMultiple, marketChange, marketUsd } from "./market-format.ts"; test("ledger money fits its column without rounding dust to zero", () => { assert.equal(marketUsd(0), "$0"); @@ -25,6 +25,18 @@ test("non-finite changes and finite values that overflow percentages are neutral } assert.deepEqual(marketChange(0), { label: "0.0%", direction: "flat" }); assert.deepEqual(marketChange(-0), { label: "0.0%", direction: "flat" }); - assert.deepEqual(marketChange(1e300), { label: "+1.0e+302%", direction: "up" }); + assert.deepEqual(marketChange(1e300), { label: "1.0e+300×", direction: "up" }); assert.deepEqual(marketChange(-1e300), { label: "-1.0e+302%", direction: "down" }); }); + +test("rises of 100% or more read as a multiple of the launch price; smaller moves and falls stay percentages", () => { + assert.deepEqual(marketChange(0.99), { label: "+99%", direction: "up" }); + assert.deepEqual(marketChange(1), { label: "2.0×", direction: "up" }); + assert.deepEqual(marketChange(18.7), { label: "19.7×", direction: "up" }); + assert.deepEqual(marketChange(244), { label: "245×", direction: "up" }); + assert.deepEqual(marketChange(1233), { label: "1.2K×", direction: "up" }); + assert.deepEqual(marketChange(-0.5), { label: "-50%", direction: "down" }); + assert.equal(launchMultiple(0.5), null); + assert.equal(launchMultiple(-0.9), null); + assert.equal(launchMultiple(Number.NaN), null); +}); diff --git a/app/src/lib/launchpad/market-format.ts b/app/src/lib/launchpad/market-format.ts index f995b62..ee0a1ec 100644 --- a/app/src/lib/launchpad/market-format.ts +++ b/app/src/lib/launchpad/market-format.ts @@ -6,11 +6,26 @@ export function marketUsd(value: number): string { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: Math.abs(value) >= 1_000 ? "compact" : "standard", maximumFractionDigits: Math.abs(value) >= 1_000 ? 1 : 2, minimumFractionDigits: 0 }).format(value); } +/** + * A change since launch. A token trading at twice its launch price or more reads as a multiple + * ("19.7×" says more than "+1,870%"); smaller rises and every fall stay a signed percentage. + */ export function marketChange(value: number): { label: string; direction: "up" | "down" | "flat" } { const pct = value * 100; if (!Number.isFinite(pct)) return { label: "—", direction: "flat" }; const rounded = Number(pct.toFixed(Math.abs(pct) >= 10 ? 0 : 1)); if (rounded === 0) return { label: "0.0%", direction: "flat" }; + const multiple = launchMultiple(value); + if (multiple) return { label: multiple, direction: "up" }; const amount = Math.abs(pct) >= 1e15 ? pct.toExponential(1) : new Intl.NumberFormat("en-US", { notation: Math.abs(pct) >= 1_000 ? "compact" : "standard", maximumFractionDigits: Math.abs(pct) >= 10 ? 0 : 1 }).format(pct); return { label: `${pct > 0 ? "+" : ""}${amount}%`, direction: pct > 0 ? "up" : "down" }; } + +/** "2.0×", "19.7×", "245×", "1.2K×" once the price is at least double the launch price; null below that. */ +export function launchMultiple(change: number): string | null { + const x = 1 + change; + if (!Number.isFinite(x) || x < 2) return null; + if (x >= 1e15) return `${x.toExponential(1)}×`; + const options: Intl.NumberFormatOptions = x >= 1_000 ? { notation: "compact", maximumFractionDigits: 1 } : x >= 100 ? { maximumFractionDigits: 0 } : { minimumFractionDigits: 1, maximumFractionDigits: 1 }; + return `${new Intl.NumberFormat("en-US", options).format(x)}×`; +} diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index 239bfa1..d0a9a39 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -17,7 +17,7 @@ import { imagePublicBase } from "./imageStore"; import { collectNonDust } from "./feed-dust"; import { fdvQuote, quotePerToken, tickToTokensPerQuote, units } from "./math"; import type { RawCandle } from "./candles"; -import { normalizeQuery, isAddressQuery, escapeLike, compareSearchHit, type LaunchFilter } from "./search"; +import { normalizeQuery, isAddressQuery, escapeLike, compareSearchHit, compareSearchHitByHolders, type LaunchFilter } from "./search"; /** * Read model for the UI. Every money field comes in two flavours: raw quote @@ -562,7 +562,7 @@ export async function launchSyncCursors(): Promise { } /** Search by name / symbol (substring) or exact address, across chains. Ranked: symbol exact, symbol prefix, name prefix, substring. */ -export async function searchLaunches(q: string, opts: { chain?: ChainKey | null; limit?: number; ethUsd?: number | null } = {}): Promise { +export async function searchLaunches(q: string, opts: { chain?: ChainKey | null; limit?: number; ethUsd?: number | null; order?: "newest" | "holders" } = {}): Promise { const db = maybeDb(); const n = normalizeQuery(q); if (!db || !n) return []; @@ -577,7 +577,7 @@ export async function searchLaunches(q: string, opts: { chain?: ChainKey | null; // Robinhood's, so the old ORDER BY hid exact matches on the low chain). : await db`${db.unsafe(SELECT)} WHERE (l.name ILIKE ${"%" + escapeLike(n) + "%"} ESCAPE '\\' OR l.symbol ILIKE ${"%" + escapeLike(n) + "%"} ESCAPE '\\') ${chainCond} ORDER BY l.block_time DESC, l.chain_id DESC, l.block_number DESC LIMIT 200`; const shaped = rows.map((r) => shape(r, opts.ethUsd ?? null)); - shaped.sort((a, b) => compareSearchHit(a, b, n)); + shaped.sort((a, b) => (opts.order === "holders" ? compareSearchHitByHolders(a, b, n) : compareSearchHit(a, b, n))); return shaped.slice(0, limit); } diff --git a/app/src/lib/launchpad/search.test.ts b/app/src/lib/launchpad/search.test.ts index 274042e..0fed0cf 100644 --- a/app/src/lib/launchpad/search.test.ts +++ b/app/src/lib/launchpad/search.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { compareSearchHit, escapeLike, isAddressQuery, matchesFilter, matchesQuery, normalizeQuery, rankHit, type RankedHit } from "./search.ts"; +import { compareSearchHit, compareSearchHitByHolders, escapeLike, isAddressQuery, matchesFilter, matchesQuery, normalizeQuery, rankHit, type RankedHit } from "./search.ts"; const row = (o: Partial[0]> = {}) => ({ name: "Clear Sky", @@ -91,3 +91,16 @@ test("compareSearchHit: rank dominates time; ties break deterministically", () = const b = hit({ symbol: "FOO", block_time: "2026-09-12T00:00:00.000Z", chain_id: 8453, block_number: 7 }); assert.equal(compareSearchHit(a, b, "foo"), 0); }); + +test("compareSearchHitByHolders: relevance first, then the token more wallets hold, then newest-first", () => { + const popularOld = { ...hit({ symbol: "WAIFU", name: "WAIFU", block_time: "2026-09-06T10:56:57.000Z" }), holders: 196 }; + const copyNew = { ...hit({ symbol: "WAIFU", name: "wifu", block_time: "2026-10-04T16:00:00.000Z" }), holders: 0 }; + const prefixBusy = { ...hit({ symbol: "WAIFUX", name: "Waifu X", block_time: "2026-10-01T00:00:00.000Z" }), holders: 5_000 }; + // newest-first (the list's search order) would put this week's copy on top + assert.ok(compareSearchHit(copyNew, popularOld, "waifu") < 0); + assert.ok(compareSearchHitByHolders(popularOld, copyNew, "waifu") < 0, "the held token outranks its new copy"); + assert.ok(compareSearchHitByHolders(copyNew, prefixBusy, "waifu") < 0, "relevance still comes before holders"); + const tieA = { ...hit({ symbol: "FOO", block_time: "2026-09-12T00:00:00.000Z" }), holders: 3 }; + const tieB = { ...hit({ symbol: "FOO", block_time: "2026-09-11T00:00:00.000Z" }), holders: 3 }; + assert.ok(compareSearchHitByHolders(tieA, tieB, "foo") < 0, "equal holders fall back to newest-first"); +}); diff --git a/app/src/lib/launchpad/search.ts b/app/src/lib/launchpad/search.ts index d3ba03d..5853ba5 100644 --- a/app/src/lib/launchpad/search.ts +++ b/app/src/lib/launchpad/search.ts @@ -84,3 +84,12 @@ export type RankedHit = Matchable & { chain_id: number; block_number: number }; export function compareSearchHit(a: RankedHit, b: RankedHit, q: string): number { return rankHit(a, q) - rankHit(b, q) || newestFirst(a, b); } + +/** + * Ordering for quick jumps (the ⌘K search): relevance rank first, then the token held by more + * wallets, then the same cross-chain newest-first. A token people actually hold outranks this + * week's copies of its name, which newest-first would put on top. Holders is an indexed fact. + */ +export function compareSearchHitByHolders(a: RankedHit & { holders: number }, b: RankedHit & { holders: number }, q: string): number { + return rankHit(a, q) - rankHit(b, q) || (b.holders || 0) - (a.holders || 0) || newestFirst(a, b); +} diff --git a/app/src/lib/launchpad/token-transition.test.ts b/app/src/lib/launchpad/token-transition.test.ts new file mode 100644 index 0000000..51cfcc0 --- /dev/null +++ b/app/src/lib/launchpad/token-transition.test.ts @@ -0,0 +1,55 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { clearPendingToken, getPendingToken, matchTokenPath, pendingTokenFor, setPendingToken, subscribePendingToken, tokenMorphNames } from "./token-transition.ts"; + +const PANCHU = { chain: "arc", token: "0x648A5382BDCF286E7FF5122D01A983DB314E620A", name: "Pacharan", symbol: "PANCHU", image: null }; + +test("morph names are the same for any address casing, distinct per chain, and valid CSS identifiers", () => { + const a = tokenMorphNames("arc", PANCHU.token); + const b = tokenMorphNames("arc", PANCHU.token.toLowerCase()); + assert.deepEqual(a, b); + assert.notEqual(tokenMorphNames("base", PANCHU.token).avatar, a.avatar); + assert.notEqual(a.avatar, a.name); + for (const name of [a.avatar, a.name]) assert.match(name, /^[a-z][a-z0-9-]*$/); +}); + +test("only token routes match, with the address normalised", () => { + assert.deepEqual(matchTokenPath("/t/arc/0x648A5382BDCF286E7FF5122D01A983DB314E620A"), { chain: "arc", token: PANCHU.token.toLowerCase() }); + assert.deepEqual(matchTokenPath("/t/base/0x648a5382bdcf286e7ff5122d01a983db314e620a/"), { chain: "base", token: PANCHU.token.toLowerCase() }); + assert.equal(matchTokenPath("/t/arc"), null); + assert.equal(matchTokenPath("/t/arc/0x123"), null); + assert.equal(matchTokenPath("/launch"), null); + assert.equal(matchTokenPath(null), null); +}); + +test("a pending note counts only on the route it was left for", () => { + setPendingToken(PANCHU); + try { + assert.equal(getPendingToken()?.token, PANCHU.token.toLowerCase()); + assert.equal(pendingTokenFor(`/t/arc/${PANCHU.token}`)?.name, "Pacharan"); + assert.equal(pendingTokenFor(`/t/base/${PANCHU.token}`), null, "same address on another chain is another token"); + assert.equal(pendingTokenFor("/"), null); + } finally { + clearPendingToken(); + } + assert.equal(getPendingToken(), null); +}); + +test("listeners hear a new note and its clearing; unsubscribing stops them", () => { + let calls = 0; + const off = subscribePendingToken(() => calls++); + setPendingToken(PANCHU); + clearPendingToken(); + clearPendingToken(); // nothing left to clear: no extra notification + off(); + setPendingToken(PANCHU); + clearPendingToken(); + assert.equal(calls, 2); +}); + +test("a note expires on its own so it never leaks into a later visit", async () => { + setPendingToken(PANCHU, 5); + assert.notEqual(getPendingToken(), null); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.equal(getPendingToken(), null); +}); diff --git a/app/src/lib/launchpad/token-transition.ts b/app/src/lib/launchpad/token-transition.ts new file mode 100644 index 0000000..ec64db2 --- /dev/null +++ b/app/src/lib/launchpad/token-transition.ts @@ -0,0 +1,65 @@ +/** + * Identity for the market-row → token-page morph. + * + * Both ends render the token's mark and name inside a React with the same name, so + * the browser animates one object moving instead of a page swap. The token route has a loading + * boundary, so the first frame after a click is the loading screen, which knows nothing about the + * token yet. The row therefore leaves a short-lived note of what was tapped; the loading header reads + * it and renders the same mark and name, which lets the morph pair form on that first frame. + */ + +export type PendingToken = { chain: string; token: string; name: string; symbol: string; image: string | null }; + +/** View-transition names must be unique on a page and valid CSS identifiers. */ +export function tokenMorphNames(chain: string, token: string): { avatar: string; name: string } { + const id = `${chain}-${token.toLowerCase()}`.replace(/[^a-z0-9-]/g, ""); + return { avatar: `tok-av-${id}`, name: `tok-nm-${id}` }; +} + +const TOKEN_PATH = /^\/t\/([a-z]+)\/(0x[0-9a-f]{40})(?:\/|$)/i; + +/** The chain and token a /t// path points at, or null for any other route. */ +export function matchTokenPath(pathname: string | null | undefined): { chain: string; token: string } | null { + const m = TOKEN_PATH.exec(pathname ?? ""); + return m ? { chain: m[1].toLowerCase(), token: m[2].toLowerCase() } : null; +} + +let pending: PendingToken | null = null; +let expiry: ReturnType | null = null; +const listeners = new Set<() => void>(); +const emit = () => listeners.forEach((listener) => listener()); + +/** Remember the token a visitor just opened. The note expires on its own, so a stale one never leaks into a later visit. */ +export function setPendingToken(token: PendingToken, ttlMs = 15_000): void { + pending = { ...token, chain: token.chain.toLowerCase(), token: token.token.toLowerCase() }; + if (expiry) clearTimeout(expiry); + expiry = setTimeout(clearPendingToken, ttlMs); + (expiry as { unref?: () => void }).unref?.(); + emit(); +} + +export function clearPendingToken(): void { + if (expiry) clearTimeout(expiry); + expiry = null; + if (pending === null) return; + pending = null; + emit(); +} + +export function getPendingToken(): PendingToken | null { + return pending; +} + +/** The note only counts on the route it was left for. */ +export function pendingTokenFor(pathname: string | null | undefined, note: PendingToken | null = pending): PendingToken | null { + const target = matchTokenPath(pathname); + if (!target || !note) return null; + return note.chain === target.chain && note.token === target.token ? note : null; +} + +export function subscribePendingToken(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/app/src/react-canary.d.ts b/app/src/react-canary.d.ts new file mode 100644 index 0000000..67f0c67 --- /dev/null +++ b/app/src/react-canary.d.ts @@ -0,0 +1,2 @@ +// The App Router runs React canary, which exports ; its types live in the canary entry. +///