From 4f1b1ac6f772c44b175c8f665533be4cc6ff4538 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Tue, 6 Oct 2026 21:38:38 +0800 Subject: [PATCH 01/10] fix(home): token names on phones and tablets, one tab stop per star Two leftovers of the home redesign that are already on main: - Launch list: the token name shared one line with its quote badge, so a badged name got 2px at 360px wide, 32px at 390px and 66px on the tablet table (production shows it whole). Below 1024px the badge now drops under the name when the two do not fit. The phone grid's Buys / sells value stays on one line. - Star button: the tap gesture on its inner span made motion add tabindex="0", a second tab stop inside each of the 40 buttons. The attribute was absent under reduced motion, which logged a hydration mismatch, and a click left focus on the span. The press feedback is now the button's own :active state in CSS. Also corrects the comment on /rules: the walk-through opens the home page again. --- app/src/app/rules/page.tsx | 2 +- app/src/components/launchpad/LaunchRow.tsx | 7 ++- app/src/components/launchpad/WatchButton.tsx | 16 ++--- .../launchpad/home-leftovers.test.ts | 58 +++++++++++++++++++ 4 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 app/src/components/launchpad/home-leftovers.test.ts diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx index 53b733e1..ebb88eea 100644 --- a/app/src/app/rules/page.tsx +++ b/app/src/app/rules/page.tsx @@ -74,7 +74,7 @@ export default function RulesPage() {

One signature.
Four things happen.

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

- {/* the animated walk-through that used to open the home page: token, pool, lock */} + {/* the animated walk-through that also opens the home page: token, pool, lock */}
    {STEPS.map(({ title, description, value, label }, i) => ( diff --git a/app/src/components/launchpad/LaunchRow.tsx b/app/src/components/launchpad/LaunchRow.tsx index 6c3e5be4..007eafc0 100644 --- a/app/src/components/launchpad/LaunchRow.tsx +++ b/app/src/components/launchpad/LaunchRow.tsx @@ -68,7 +68,8 @@ export default function LaunchRow({ l, rank, window = "all", hl = null, now, pop className={`bb-market-row group block border-b border-line bg-paper px-4 py-2.5 md:pr-13 transition-colors hover:bg-card focus-visible:relative focus-visible:z-10 motion-reduce:transition-none ${flash}`} title={l.description || `${l.name} (${l.symbol})`} > -
    + {/* the phone cap column is at most 9.25rem: a long quote figure ("2.61M 0xa69f…8792") truncates there instead of taking the row */} +
    {rank !== undefined ? {rank} : null} @@ -78,7 +79,9 @@ export default function LaunchRow({ l, rank, window = "all", hl = null, now, pop
    -
    + {/* below 1024px the badge drops under the name when the two do not fit, so a name is never squeezed to nothing + (an unlisted-pair row on a phone, the narrow name column of the tablet table) */} +
    {l.name} diff --git a/app/src/components/launchpad/WatchButton.tsx b/app/src/components/launchpad/WatchButton.tsx index ad826bfc..9e0ce47c 100644 --- a/app/src/components/launchpad/WatchButton.tsx +++ b/app/src/components/launchpad/WatchButton.tsx @@ -17,25 +17,25 @@ export default function WatchButton({ token, labelled = false, overlay = false, const saved = savedKeys.includes(watchlistKey(token)); const label = `${saved ? "Remove" : "Save"} ${token.name} ${saved ? "from" : "to"} watchlist`; const star = ( - + // the press feedback is the button's own :active state, in CSS. A tap gesture in Motion makes the element focusable + // (tabindex="0"), and under reduced motion the server and the client then disagree about that attribute + ); return ( - {/* the press scale sits on the button itself: on a child span, Motion would make that span a second tab stop */} - { const result = toggle(token); setMessage(result === "limit" ? "Your watchlist is full. Remove a token to save another (50 maximum)." : result === "invalid" ? "This token could not be saved." : ""); if (result === "removed") onRemoved?.(event.currentTarget); }} className={overlay - ? "inline-flex size-8 items-center justify-center rounded-full bg-black/55 text-white ring-1 ring-white/15 backdrop-blur-md transition-colors hover:bg-black/70 disabled:opacity-40 motion-reduce:transition-none" + ? "group/star inline-flex size-8 items-center justify-center rounded-full bg-black/55 text-white ring-1 ring-white/15 backdrop-blur-md transition-colors hover:bg-black/70 disabled:opacity-40 motion-reduce:transition-none" : labelled - ? `inline-flex h-10 items-center justify-center rounded-xl border px-3 text-xs font-medium transition-colors disabled:opacity-40 motion-reduce:transition-none sm:h-9 ${saved ? "border-brand/40 bg-brand-soft text-brand" : "border-line bg-card text-body hover:border-line-strong hover:text-ink"}` - : `inline-flex min-h-11 w-11 items-center justify-center rounded-xl transition-colors disabled:opacity-40 motion-reduce:transition-none ${saved ? "text-brand" : "text-muted hover:text-ink"}`}> + ? `group/star inline-flex h-10 items-center justify-center rounded-xl border px-3 text-xs font-medium transition-colors disabled:opacity-40 motion-reduce:transition-none sm:h-9 ${saved ? "border-brand/40 bg-brand-soft text-brand" : "border-line bg-card text-body hover:border-line-strong hover:text-ink"}` + : `group/star inline-flex min-h-11 w-11 items-center justify-center rounded-xl transition-colors disabled:opacity-40 motion-reduce:transition-none ${saved ? "text-brand" : "text-muted hover:text-ink"}`}> {/* the wider word sizes the button, so the bar beside it never shifts; the star and word stay centred together */} {labelled ? ( @@ -43,7 +43,7 @@ export default function WatchButton({ token, labelled = false, overlay = false, ) : star} - + {message ? {message} : null} {storageError && labelled ? Browser storage unavailable. Saved for this session only. : null} diff --git a/app/src/components/launchpad/home-leftovers.test.ts b/app/src/components/launchpad/home-leftovers.test.ts new file mode 100644 index 00000000..c0ee0e25 --- /dev/null +++ b/app/src/components/launchpad/home-leftovers.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +/** + * Source contracts for three leftovers of the home redesign: token names that collapsed beside their quote badge on + * phones and tablets, the star button's hidden second tab stop (and the hydration warning it caused), and a stale comment on /rules. + * Browser QA measures the widths and the tab order; these keep the fixes from being edited away. + */ +const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8").replaceAll("\r\n", "\n"); +const row = read("./LaunchRow.tsx"); +const watch = read("./WatchButton.tsx"); +const rules = read("../../app/rules/page.tsx"); + +test("below 1024px the quote badge drops under the token name instead of squeezing it", () => { + // wraps only where the name column is narrow (phones, and the tablet table from 768 to about 850px); unchanged from 1024px up + assert.match(row, /
    \n\s*\n\s*\{l\.name\}<\/span>/); + assert.match(row, /\n\s*\{l\.quote_key === "other" \? : null\}/); +}); + +test("on phones a long quote figure truncates in its own column instead of taking the whole row", () => { + // `auto` let "2.61M 0xa69f…8792" claim the row and push an unlisted pair's name to 0px at 360 and 390px + assert.match(row, /grid-cols-\[minmax\(0,1fr\)_fit-content\(9\.25rem\)\] items-center gap-x-3 gap-y-1\.5/); + assert.doesNotMatch(row, /grid-cols-\[minmax\(0,1fr\)_auto\]/); +}); + +test("buys and sells are shown compact, on one line, in the desktop column and in the phone line", () => { + // count() compacts a busy token's tens of thousands, so the two figures fit their column; the phone line is one truncating row + const shown = row.match(/\{count\(l\.buys\)\}<\/span>/g) ?? []; + assert.equal(shown.length, 2, "one value for the desktop column, one for the phone line"); + assert.equal((row.match(/\{count\(l\.sells\)\}<\/span>/g) ?? []).length, 2); + assert.match(row, /Buys <\/span>\{count\(l\.buys\)\}/, "the desktop value stays on one line"); + assert.match(row, /

    /, "the phone line truncates as a whole"); +}); + +test("the star button is one tab stop, with or without reduced motion", () => { + // motion makes an element with a tap gesture focusable (tabindex="0"); the gesture was off under reduced motion, so + // the attribute also differed between the server and the client, and a click left focus on the span. No gesture on + // the span at all: the press feedback is the button's own :active state. + const spans = watch.match(/]*>/g) ?? []; + assert.equal(spans.length, 1); + assert.doesNotMatch(watch, /whileTap|whileHover|whileFocus|onTap/, "nothing that makes motion add a tab stop"); + assert.doesNotMatch(watch, /tabIndex=/, "the button's native tab stop is the only one, and nothing inside it can take focus"); + assert.match(spans[0], /group-active\/star:scale-\[\.88\]/, "the press feedback stays"); + assert.match(spans[0], /motion-reduce:group-active\/star:scale-100/, "and is off under reduced motion"); + assert.match(watch, / - -

    - ) : null} +
)} + {/* said once for the board, not inside every card's link, where it was read again on each focus */} + {first ? : null} ); } -/** Whether the rail sits at its start, and at its end. */ -function edgesOf(el: HTMLElement): { start: boolean; end: boolean } { - return { start: el.scrollLeft <= 4, end: el.scrollLeft + el.clientWidth >= el.scrollWidth - 4 }; +/** A token's wallets against the most on the board. Decorative for assistive tech: the wallet count beside it is the value. */ +function Bar({ pct, className }: { pct: number; className: string }) { + return