From a6e6effea94a1ec206c16517277a95520513bff4 Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Tue, 6 Oct 2026 10:48:35 +0530 Subject: [PATCH 01/12] ui: shared foundations: Geist type, chain marks, vendor kit Geist for text and figures, Geist Mono for addresses and code, chain logos, social marks, the token tint helpers, the vendored motion pieces, the section header chip, and a hydration-safe wallet hook. (cherry picked from commit 8c4f66f182b2bb387a0a175955e8affdae222870) --- app/src/app/fonts.ts | 10 +- app/src/app/global-error.tsx | 4 +- app/src/app/globals.css | 85 +++++++++++-- app/src/app/layout.tsx | 4 +- app/src/app/opengraph-image.tsx | 6 +- .../app/t/[chain]/[token]/opengraph-image.tsx | 12 +- app/src/components/WalletMenu.module.css | 6 +- app/src/components/launchpad/AdminQueue.tsx | 5 +- app/src/components/launchpad/BrandMarks.tsx | 8 ++ app/src/components/launchpad/ChainLogo.tsx | 44 +++++++ app/src/components/launchpad/LaunchTape.tsx | 2 +- app/src/components/launchpad/TokenAvatar.tsx | 10 +- app/src/components/sections/SectionIntro.tsx | 8 +- .../sections/SectionShell.module.css | 25 ++-- app/src/components/theme.test.ts | 10 +- app/src/components/ui.ts | 14 ++- app/src/components/vendor/LICENSES.md | 2 + app/src/components/vendor/animated-list.tsx | 33 +++++ .../components/vendor/animated-shiny-text.tsx | 21 ++++ app/src/components/vendor/border-beam.tsx | 41 +++++++ app/src/components/vendor/flickering-grid.tsx | 93 ++++++++++++++ app/src/components/vendor/magic-card.tsx | 28 +++++ app/src/components/vendor/marquee.tsx | 36 ++++++ app/src/components/vendor/shine-border.tsx | 25 ++++ app/src/components/vendor/tabs.tsx | 15 ++- app/src/components/vendor/toggle-group.tsx | 58 +++++++-- app/src/lib/launchpad/socials.test.ts | 17 +++ app/src/lib/launchpad/socials.ts | 26 ++++ app/src/lib/launchpad/tint.test.ts | 68 +++++++++++ app/src/lib/launchpad/tint.ts | 113 ++++++++++++++++++ app/src/lib/ogFonts.ts | 25 +--- app/src/lib/useHydratedAccount.ts | 24 ++++ 32 files changed, 785 insertions(+), 93 deletions(-) create mode 100644 app/src/components/launchpad/BrandMarks.tsx create mode 100644 app/src/components/launchpad/ChainLogo.tsx create mode 100644 app/src/components/vendor/animated-list.tsx create mode 100644 app/src/components/vendor/animated-shiny-text.tsx create mode 100644 app/src/components/vendor/border-beam.tsx create mode 100644 app/src/components/vendor/flickering-grid.tsx create mode 100644 app/src/components/vendor/magic-card.tsx create mode 100644 app/src/components/vendor/marquee.tsx create mode 100644 app/src/components/vendor/shine-border.tsx create mode 100644 app/src/lib/launchpad/socials.test.ts create mode 100644 app/src/lib/launchpad/socials.ts create mode 100644 app/src/lib/launchpad/tint.test.ts create mode 100644 app/src/lib/launchpad/tint.ts create mode 100644 app/src/lib/useHydratedAccount.ts diff --git a/app/src/app/fonts.ts b/app/src/app/fonts.ts index 60ba14b2..200b59d3 100644 --- a/app/src/app/fonts.ts +++ b/app/src/app/fonts.ts @@ -1,10 +1,12 @@ -import { Inter, Space_Mono, Unbounded } from "next/font/google"; +import { Geist, Geist_Mono } from "next/font/google"; /** * Font loaders shared by the root layout and global-error.tsx. * global-error replaces the root layout when active, so it cannot inherit * these variables — it imports them from here instead of duplicating them. + * + * One family for the interface, figures included (Geist, as most launchpads keep a single grotesk); Geist Mono only + * for addresses and transaction hashes, where every character must line up. */ -export const inter = Inter({ subsets: ["latin"], variable: "--font-inter", display: "swap" }); -export const spaceMono = Space_Mono({ subsets: ["latin"], weight: ["400", "700"], variable: "--font-space-mono", display: "swap" }); -export const unbounded = Unbounded({ subsets: ["latin"], weight: ["600", "700"], variable: "--font-unbounded", display: "swap" }); +export const geist = Geist({ subsets: ["latin"], variable: "--font-geist", display: "swap" }); +export const geistMono = Geist_Mono({ subsets: ["latin"], variable: "--font-geist-mono", display: "swap" }); diff --git a/app/src/app/global-error.tsx b/app/src/app/global-error.tsx index 57c1de95..4268c6a9 100644 --- a/app/src/app/global-error.tsx +++ b/app/src/app/global-error.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useEffect } from "react"; import "./globals.css"; -import { inter, spaceMono, unbounded } from "./fonts"; +import { geist, geistMono } from "./fonts"; import { errorBody, errorRegionLabel, sanitizeDigest } from "@/lib/error-copy"; /** @@ -24,7 +24,7 @@ export default function GlobalError({ }, [error]); const digest = sanitizeDigest(error.digest); return ( - +

500

diff --git a/app/src/app/globals.css b/app/src/app/globals.css index 11c15a93..d4fbc062 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -10,9 +10,13 @@ /* Chart code reads tokens at runtime; retain even tokens with no utility class. */ @theme static { - --font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - --font-mono: var(--font-space-mono), ui-monospace, SFMono-Regular, Menlo, monospace; - --font-display: var(--font-unbounded), var(--font-inter), ui-sans-serif, system-ui, sans-serif; + --font-sans: var(--font-geist), ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + /* figures: every price, amount and count. The same Geist as the words, with tabular digits (see .font-mono below), + the way other launchpads set their numbers; a typewriter face for money read as a terminal, not a market. */ + --font-mono: var(--font-geist), ui-sans-serif, system-ui, sans-serif; + /* addresses and transaction hashes: a true monospace, so 0x strings line up and read character by character */ + --font-code: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace; + --font-display: var(--font-geist), ui-sans-serif, system-ui, sans-serif; /* surfaces */ --color-paper: #fafaf8; @@ -53,16 +57,69 @@ --color-chart-grid: #f1f0ee; --color-chart-vol: #cbd5e1; + /* the dim second voice of a display headline: a neutral grey (no slate cast against the warm paper) */ + --color-dim: #737373; + /* decorative glows (card edges, hover halos, beams, the hero grid): neutral on light, brand blue on dark, + so blue on light stays the action colour alone */ + --color-glow: rgb(15 23 42 / 0.3); + --color-glow-soft: rgb(15 23 42 / 0.035); + /* segmented controls (components/vendor/toggle-group.tsx): a recessed track and a raised thumb that slides */ + --color-track: #f1f1ef; + --color-thumb: #ffffff; + --shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04), 0 4px 16px rgba(15, 23, 42, 0.04); --shadow-card-hover: 0 2px 4px rgba(15, 23, 42, 0.06), 0 10px 28px rgba(15, 23, 42, 0.09); --shadow-sheet: 0 -8px 40px rgba(15, 23, 42, 0.12); --shadow-dialog: 0 24px 64px rgba(15, 23, 42, 0.18); + --shadow-track: inset 0 0 0 1px rgba(15, 23, 42, 0.05), inset 0 1px 2px rgba(15, 23, 42, 0.06); + --shadow-thumb: 0 0 0 1px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.1), 0 3px 8px -3px rgba(15, 23, 42, 0.14); + + /* Magic UI motion (components/vendor, MIT): each one stops under reduced motion at its call site */ + --animate-marquee: marquee var(--duration) infinite linear; + --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; + --animate-shiny-text: shiny-text 8s infinite; + --animate-shine: shine var(--duration) infinite linear; +} + +@keyframes marquee { from { transform: translateX(0); } to { transform: translateX(calc(-100% - var(--gap))); } } +@keyframes marquee-vertical { from { transform: translateY(0); } to { transform: translateY(calc(-100% - var(--gap))); } } +@keyframes shiny-text { + 0%, 90%, 100% { background-position: calc(-100% - var(--shiny-width)) 0; } + 30%, 60% { background-position: calc(100% + var(--shiny-width)) 0; } +} +@keyframes shine { + 0% { background-position: 0% 0%; } + 50% { background-position: 100% 100%; } + to { background-position: 0% 0%; } } +/* A band of light that crosses a filled call to action now and then (and once, quickly, on hover). The host needs + relative + overflow-hidden. A shine, not a colour: white at low opacity over the button's own fill. */ +.bb-sheen::after { + content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none; + background: linear-gradient(105deg, transparent 38%, rgb(255 255 255 / 0.34) 50%, transparent 62%); + transform: translateX(-100%); + animation: bb-sheen 5s linear 1.2s infinite; /* a steady 1.2s crossing every 5s */ +} +@keyframes bb-sheen { 0%, 76% { transform: translateX(-100%); } 100% { transform: translateX(100%); } } +@media (prefers-reduced-motion: reduce) { .bb-sheen::after { display: none; } } + +/* The hero's first-paint entrance: pure CSS so the words are there before any script runs. `--d` staggers it. */ +.bb-rise { animation: bb-rise 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both; animation-delay: var(--d, 0ms); } +@keyframes bb-rise { from { opacity: 0; transform: translateY(12px); filter: blur(8px); } to { opacity: 1; transform: none; filter: none; } } +@media (prefers-reduced-motion: reduce) { .bb-rise { animation: none; } } + :root { color-scheme: light; } +/* The page column, boxed like other launchpads: 72rem by default, and 80rem on the market pages (home and token), + which hold a .bb-mid element, so the board fits four cards a row and the chart sits beside its trade column, with + margins showing on any laptop screen. The header's resting row lines up with whichever column the page uses. */ +:root { --page-max: 72rem; --page-pad: 1rem; } +:root:has(.bb-mid) { --page-max: 80rem; --page-pad: clamp(1rem, 2.5vw, 2rem); } +.bb-page { width: 100%; max-width: var(--page-max); margin-inline: auto; padding-inline: var(--page-pad); } + /* ── Dark: black ground, alpha ramp of white, near-zero chroma greys. The blue and green brighten because the light values drop under 3:1 on black. ── */ .dark { @@ -94,11 +151,18 @@ --color-holder-good-bg: #06241a; --color-chart-grid: #1a1a1a; --color-chart-vol: #333333; + --color-dim: #8c8c8c; + --color-glow: #4d8bff; + --color-glow-soft: rgb(77 139 255 / 0.09); + --color-track: #000000; + --color-thumb: #232323; --color-scrim: #000000; --shadow-card: 0 1px 2px #00000026, 0 4px 16px #00000026; --shadow-card-hover: 0 2px 4px #00000033, 0 10px 28px #00000040; --shadow-sheet: 0 -8px 40px #00000066; --shadow-dialog: 0 24px 64px #00000080; + --shadow-track: inset 0 0 0 1px #1f1f1f, inset 0 1px 2px #00000099; + --shadow-thumb: inset 0 1px 0 #ffffff17, 0 0 0 1px #ffffff0d, 0 2px 6px -1px #000000b3; } /* Black paper does not print: a class-based theme survives into the print @@ -132,11 +196,11 @@ body { overflow-x: clip; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; - font-feature-settings: "cv11", "ss01"; } -/* tabular numbers everywhere money shows */ -.tnum { +/* tabular numbers everywhere money shows; figures (font-mono) always line up in columns */ +.tnum, +.font-mono { font-variant-numeric: tabular-nums; } @@ -277,7 +341,14 @@ input[type="number"] { -moz-appearance: textfield; appearance: textfield; } @keyframes bb-row-sell { 0% { background-color: var(--color-down-soft); border-color: var(--color-down); } 100% { background-color: var(--color-card); border-color: var(--color-line); } } .bb-row-buy { animation: bb-row-buy 2.2s ease-out 1; } .bb-row-sell { animation: bb-row-sell 2.2s ease-out 1; } -@media (prefers-reduced-motion: reduce) { .bb-row-new, .bb-row-buy, .bb-row-sell { animation: none; } } +/* the card layout's version: a ring in the event's colour instead of a row fill */ +@keyframes bb-card-new { 0% { box-shadow: 0 0 0 2px var(--color-ink); } 100% { box-shadow: 0 0 0 2px transparent; } } +@keyframes bb-card-buy { 0% { box-shadow: 0 0 0 2px var(--color-up), 0 0 28px -6px var(--color-up); } 100% { box-shadow: 0 0 0 2px transparent, 0 0 28px -6px transparent; } } +@keyframes bb-card-sell { 0% { box-shadow: 0 0 0 2px var(--color-down), 0 0 28px -6px var(--color-down); } 100% { box-shadow: 0 0 0 2px transparent, 0 0 28px -6px transparent; } } +.bb-card-new { animation: bb-card-new 1.6s ease-out 1; } +.bb-card-buy { animation: bb-card-buy 2.2s ease-out 1; } +.bb-card-sell { animation: bb-card-sell 2.2s ease-out 1; } +@media (prefers-reduced-motion: reduce) { .bb-row-new, .bb-row-buy, .bb-row-sell, .bb-card-new, .bb-card-buy, .bb-card-sell { animation: none; } } /* ── loading system: route progress bar, skeleton shimmer, spinner ─────────── */ /* scaleX rather than width: the bar animates on the compositor instead of diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index 07146233..0571eeb9 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -14,7 +14,7 @@ import Footer from "@/components/Footer"; import RouteProgress from "@/components/RouteProgress"; import ThemeProvider from "@/components/ThemeProvider"; import { Suspense } from "react"; -import { inter, spaceMono, unbounded } from "./fonts"; +import { geist, geistMono } from "./fonts"; const TITLE = SITE_TITLE; const DESCRIPTION = SITE_DESCRIPTION; @@ -44,7 +44,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo // Organization + WebSite + WebApplication: the entity record search engines reconcile the brand word against. const siteJsonLd = siteJsonLdHtml({ siteUrl: SITE_URL, brand: BRAND, domain: BRAND_DOMAIN, description: SITE_DESCRIPTION, sameAs: [`https://x.com/${BRAND_X}`, BRAND_GITHUB] }); return ( - + &y=\'test\''; const escaped = "https://images.example/logo.png?x=" onerror="alert(1)"><script>alert(2)</script>&y='test'"; + // the preview loads the parsed URL, as the browser would request it: quotes, angle brackets and spaces percent-encoded + const parsed = "https://images.example/logo.png?x=%22%20onerror=%22alert(1)%22%3E%3Cscript%3Ealert(2)%3C/script%3E&y=%27test%27"; const [html] = renderPreviews([{ value }]); // Assert the actual serialized sink, not a duplicate sanitization function or - // a source-code pattern. React must keep the entire value in one src attribute. - assert.ok(html.includes(`"), false); From 2191f1ee4ef636e9af04cb1ed59ea1961375fc48 Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Tue, 6 Oct 2026 19:18:10 +0530 Subject: [PATCH 12/12] fix(ui): honor reduced motion in the phone menu and the magic card From the CodeRabbit review on #76. Phone menu: MotionConfig reducedMotion="user" only drops transforms, so the scrim and the route rows still faded in, the rows with a stagger, while the menu card itself already appeared at once. Under reduced motion both now take zero duration and delay, like the card. MagicCard: the gradient border is an inline style, so the class meant to give reduced motion the plain card never won and the glow still followed the pointer. The paper background is now marked important and the border takes the line color, the same card touch screens get. --- app/src/components/HeaderNav.tsx | 6 ++++-- app/src/components/vendor/magic-card.tsx | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/components/HeaderNav.tsx b/app/src/components/HeaderNav.tsx index 8f871f5d..27debaf4 100644 --- a/app/src/components/HeaderNav.tsx +++ b/app/src/components/HeaderNav.tsx @@ -282,6 +282,8 @@ const mobileTool = "h-9 w-9 inline-flex items-center justify-center rounded-full function Mobile({ visible = false, pulse, isActive, onSearch }: { visible?: boolean; pulse: Pulse; isActive: (href: string) => boolean; onSearch: () => void }) { const [open, setOpen] = useState(false); + // MotionConfig's reducedMotion only drops transforms, so the fades below are zeroed by hand, as the menu card's are + const reduced = useReducedMotion(); const menuToggle = useRef(null); const dismissMenu = useCallback(() => { setOpen(false); @@ -292,7 +294,7 @@ function Mobile({ visible = false, pulse, isActive, onSearch }: { visible?: bool <> {/* the page dims behind the open menu; a tap on it closes the menu */} - {open ? + setOpen(false)} diff --git a/app/src/components/vendor/magic-card.tsx b/app/src/components/vendor/magic-card.tsx index 382a6879..ae5db89e 100644 --- a/app/src/components/vendor/magic-card.tsx +++ b/app/src/components/vendor/magic-card.tsx @@ -2,7 +2,8 @@ // Adapted from Magic UI: https://magicui.design/r/magic-card.json (MIT, see ./LICENSES.md). // LOCAL PATCH (openlaunch): gradient mode only, painted with the theme tokens (paper surface, line border, the glow token); the -// border glow follows the pointer and the inner spotlight is optional. Touch and reduced motion get the plain card. +// border glow follows the pointer and the inner spotlight is optional. Touch and reduced motion get the plain card (the +// reduced-motion paper and line border are marked important, since the gradient is an inline style). import { useCallback } from "react"; import { motion, useMotionTemplate, useMotionValue } from "motion/react"; import { cn } from "@/lib/utils"; @@ -20,7 +21,7 @@ export function MagicCard({ children, className, gradientSize = 220, gradientFro const border = useMotionTemplate`linear-gradient(var(--color-paper) 0 0) padding-box, radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientFrom}, ${gradientTo}, var(--color-line) 100%) border-box`; const glow = useMotionTemplate`radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${spotlight ?? "transparent"}, transparent 100%)`; return ( - + {spotlight ?