From e34becb6585bcd595647e720dce4ba35587b670a Mon Sep 17 00:00:00 2001 From: Vasanth T Date: Sun, 4 Oct 2026 23:57:32 +0530 Subject: [PATCH] ui: search everywhere, row-to-page morph, rolling live numbers Phase 1 of the UI overhaul: the shared pieces later pages build on. - Search from any page with Ctrl/Cmd+K, "/" or the new header button. Tokens by name, symbol or address, plus pages and actions (bridge, theme). Built on Base UI's Dialog and inline Autocomplete, so focus, keyboard and listbox semantics come from the component system we already use. - /api/launch/search takes an opt-in order=holders. The search used to break ties newest-first and return 20, which buried a token people hold under this week's copies of its name. Holders is an indexed fact. The list's own search keeps its newest-first order. - A token's mark and name morph from the market row into the token page header (React ). The token route sits under a loading boundary, so the row leaves a short-lived note and the loading header renders the same mark and name in the real header's frame; the morph pairs on the first frame. Off under reduced motion; browsers without view transitions navigate normally. - Home totals and the visits/online pulse roll to new values instead of flashing (@number-flow/react, MIT; respects reduced motion). - Moves of 2x or more read as a multiple ("19.4x" instead of "+1,840%"); smaller moves and falls stay signed percentages. - A raised surface token, one step above card, for floating layers: search, sheets, the bridge dialog and the notification popover. - Header: no arrow on the launch button, no middot in the X row; the bridge button drops its label while the strip floats so search fits. Tests cover the search model, the morph names and note, the holders ordering, multiples and the new token. Lint, typecheck, the full suite and a production build pass. --- app/package-lock.json | 30 +++ app/package.json | 1 + app/src/app/api/launch/search/route.ts | 8 +- app/src/app/globals.css | 24 +++ app/src/app/t/[chain]/[token]/loading.tsx | 11 +- app/src/app/t/[chain]/[token]/page.tsx | 10 +- app/src/components/CommandPalette.tsx | 195 ++++++++++++++++++ app/src/components/HeaderNav.tsx | 76 ++++++- app/src/components/LiveNumber.tsx | 16 ++ .../NotificationSettings.module.css | 4 +- app/src/components/Sheet.tsx | 2 +- .../components/bridge/BridgeDialog.module.css | 4 +- app/src/components/bridge/BridgeProvider.tsx | 10 +- app/src/components/bridge/bridge-ui.test.ts | 3 +- app/src/components/launchpad/ChangeChip.tsx | 5 +- .../components/launchpad/LaunchMechanism.tsx | 9 +- app/src/components/launchpad/LaunchRow.tsx | 18 +- app/src/components/launchpad/LivePulse.tsx | 24 +-- .../launchpad/PendingTokenHeader.tsx | 42 ++++ app/src/components/launchpad/TokenMorph.tsx | 26 +++ .../components/launchpad/change-chip.test.ts | 3 +- app/src/components/theme.test.ts | 12 +- app/src/lib/command-palette.test.ts | 71 +++++++ app/src/lib/command-palette.ts | 85 ++++++++ app/src/lib/launchpad/market-format.test.ts | 16 +- app/src/lib/launchpad/market-format.ts | 15 ++ app/src/lib/launchpad/queries.ts | 6 +- app/src/lib/launchpad/search.test.ts | 15 +- app/src/lib/launchpad/search.ts | 9 + .../lib/launchpad/token-transition.test.ts | 55 +++++ app/src/lib/launchpad/token-transition.ts | 65 ++++++ app/src/react-canary.d.ts | 2 + 32 files changed, 815 insertions(+), 57 deletions(-) create mode 100644 app/src/components/CommandPalette.tsx create mode 100644 app/src/components/LiveNumber.tsx create mode 100644 app/src/components/launchpad/PendingTokenHeader.tsx create mode 100644 app/src/components/launchpad/TokenMorph.tsx create mode 100644 app/src/lib/command-palette.test.ts create mode 100644 app/src/lib/command-palette.ts create mode 100644 app/src/lib/launchpad/token-transition.test.ts create mode 100644 app/src/lib/launchpad/token-transition.ts create mode 100644 app/src/react-canary.d.ts 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 bc60441..c2f6440 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"; @@ -120,9 +121,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 === "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 ( -