From ecf1603a8d75deb68d0d33564d130d1715f9d832 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 7 Oct 2026 15:38:09 +0800 Subject: [PATCH] ui(home): on laptops the live line is the locker's caption The hero's closing "Latest" line was a bar across the whole content width, its label on the far left and its age on the far right, under both columns. From 1024px it now sits right under the locker frame, as wide as the frame, and the frame and the line center together in the locker's column. Phones and tablets are unchanged to the pixel (the line is still the grid's last row, with the same spacing). - LaunchHero: the stage is wrapped in a column holding it and a second rendering of the line; one rendering is shown per size (display:none on the other, so nothing reads or tabs through it twice) - the grid loses its third row from 1024px, so the hero is 60px shorter on laptops and the Trending heading starts 60px higher - the loading skeleton mirrors it; the hero-height parity with the skeleton is unchanged (it was always 1-2px short) - tests: contracts for both renderings and the grid areas per size --- app/src/app/(home)/loading.tsx | 5 ++++- .../components/launchpad/LaunchHero.module.css | 18 ++++++++++++++---- app/src/components/launchpad/LaunchHero.tsx | 15 ++++++++++----- .../components/launchpad/hero-layout.test.ts | 4 ++-- app/src/components/launchpad/hero-layout.ts | 2 -- .../components/launchpad/live-ticker.test.ts | 17 +++++++++++++++-- 6 files changed, 45 insertions(+), 16 deletions(-) diff --git a/app/src/app/(home)/loading.tsx b/app/src/app/(home)/loading.tsx index 008c98d6..47252222 100644 --- a/app/src/app/(home)/loading.tsx +++ b/app/src/app/(home)/loading.tsx @@ -46,6 +46,7 @@ export default function Loading() {
{["w-[106px] sm:w-[122px]", "w-[96px] sm:w-[170px]", "w-[133px] sm:w-[152px]"].map((w) => )}
+
@@ -56,6 +57,8 @@ export default function Loading() {
+
+
@@ -70,8 +73,8 @@ export default function Loading() {
+
-
{/* Trending (TrendingStrip.tsx): a row to swipe below 1024px, a leader over four runners from there. The row's own stylesheet class is used, so its padding, gap and card width are the real ones. */} diff --git a/app/src/components/launchpad/LaunchHero.module.css b/app/src/components/launchpad/LaunchHero.module.css index b8004b9d..671d07f9 100644 --- a/app/src/components/launchpad/LaunchHero.module.css +++ b/app/src/components/launchpad/LaunchHero.module.css @@ -1,16 +1,26 @@ /* The hero's grid, in three arrangements. Phones: copy, the locker, then the totals. From 768px the text sits left of the locker and the totals run under both. From 1024px the totals move up into the left column under the text, and the locker takes the whole height beside them: copy hugs the bottom of its row and the totals the top of theirs, so the pair stays - together however tall the locker is. */ -.layout { display: grid; align-items: center; column-gap: 2.5rem; row-gap: 1.25rem; grid-template-columns: minmax(0, 1fr); grid-template-areas: "copy" "stage" "totals"; } + together however tall the locker is. The live line is the grid's last row up to 1023px (full width). From 1024px it moves + into the locker's column, right under the frame as its caption, and the pair centers together (two renderings of the + line, one shown at each size: the hidden one is display:none, so nothing reads or tabs through it twice). */ +.layout { display: grid; align-items: center; column-gap: 2.5rem; row-gap: 1.25rem; grid-template-columns: minmax(0, 1fr); grid-template-areas: "copy" "stage" "totals" "ticker"; } .copyArea { grid-area: copy; min-width: 0; } .totalsArea { grid-area: totals; min-width: 0; } -@media (min-width: 640px) { .layout { row-gap: 2rem; } } -@media (min-width: 768px) { .layout { grid-template-columns: minmax(0, 1.06fr) minmax(0, 1fr); grid-template-areas: "copy stage" "totals totals"; } } +/* the line used to sit outside the grid, 16px (phones) or 20px under it: the negative margin keeps that spacing against the row gap */ +.tickerArea { grid-area: ticker; min-width: 0; margin-top: -0.25rem; } +/* the locker's column: below 1024px it only wraps the stage (display: contents leaves the stage a grid item, as before) */ +.stageCol { display: contents; } +.layout .tickerStage { display: none; } +@media (min-width: 640px) { .layout { row-gap: 2rem; } .tickerArea { margin-top: -0.75rem; } } +@media (min-width: 768px) { .layout { grid-template-columns: minmax(0, 1.06fr) minmax(0, 1fr); grid-template-areas: "copy stage" "totals totals" "ticker ticker"; } } @media (min-width: 1024px) { .layout { grid-template-areas: "copy stage" "totals stage"; row-gap: 1.25rem; } .copyArea { align-self: end; } .totalsArea { align-self: start; } + .layout .tickerArea { display: none; } + .stageCol { display: flex; flex-direction: column; gap: 0.875rem; grid-area: stage; align-self: center; min-width: 0; } + .layout .tickerStage { display: flex; } } @media (min-width: 1280px) { .layout { column-gap: 3.5rem; } } diff --git a/app/src/components/launchpad/LaunchHero.tsx b/app/src/components/launchpad/LaunchHero.tsx index f19e9fc9..217cab32 100644 --- a/app/src/components/launchpad/LaunchHero.tsx +++ b/app/src/components/launchpad/LaunchHero.tsx @@ -74,16 +74,21 @@ export default function LaunchHero({ configured }: { configured: boolean }) { ) : null} {/* the stage: a faint plotting grid and four corner marks frame the mechanism like a drawing on a bench */} -
-
- ); } diff --git a/app/src/components/launchpad/hero-layout.test.ts b/app/src/components/launchpad/hero-layout.test.ts index 863bdbea..f8d1e410 100644 --- a/app/src/components/launchpad/hero-layout.test.ts +++ b/app/src/components/launchpad/hero-layout.test.ts @@ -27,8 +27,8 @@ test("the headline is stepped so it stays two lines, and the hero grid has three assert.ok(HERO.section.split(" ").includes("lg:pt-4"), "tight rhythm from 1024px"); // phones: copy, the locker, then the totals. 768px: text beside the locker, totals under both. 1024px: the totals // move up under the text and the locker spans both rows, with copy hugging its row's bottom and the totals its top - assert.match(heroCss, /\.layout \{[^}]*align-items: center;[^}]*grid-template-columns: minmax\(0, 1fr\); grid-template-areas: "copy" "stage" "totals"; \}/); - assert.match(heroCss, /@media \(min-width: 768px\) \{ \.layout \{ grid-template-columns: minmax\(0, 1\.06fr\) minmax\(0, 1fr\); grid-template-areas: "copy stage" "totals totals"; \} \}/); + assert.match(heroCss, /\.layout \{[^}]*align-items: center;[^}]*grid-template-columns: minmax\(0, 1fr\); grid-template-areas: "copy" "stage" "totals" "ticker"; \}/); + assert.match(heroCss, /@media \(min-width: 768px\) \{ \.layout \{ grid-template-columns: minmax\(0, 1\.06fr\) minmax\(0, 1fr\); grid-template-areas: "copy stage" "totals totals" "ticker ticker"; \} \}/); assert.match(heroCss, /@media \(min-width: 1024px\) \{\n\s*\.layout \{ grid-template-areas: "copy stage" "totals stage";[^}]*\}\n\s*\.copyArea \{ align-self: end; \}\n\s*\.totalsArea \{ align-self: start; \}/); assert.match(heroCss, /\.stage \{ grid-area: stage;/); }); diff --git a/app/src/components/launchpad/hero-layout.ts b/app/src/components/launchpad/hero-layout.ts index eed7f957..38636545 100644 --- a/app/src/components/launchpad/hero-layout.ts +++ b/app/src/components/launchpad/hero-layout.ts @@ -19,6 +19,4 @@ export const HERO = { the page. From 640px the row never wraps, as before: the two-column hero at tablet widths is laid out around one row. */ actions: "mt-6 flex flex-wrap items-center gap-2.5 sm:mt-8 sm:flex-nowrap sm:gap-4 lg:mt-6", proofs: "mt-5 flex flex-wrap gap-2 lg:mt-4", - /** the live ticker that closes the hero */ - ticker: "mt-4 sm:mt-5", } as const; diff --git a/app/src/components/launchpad/live-ticker.test.ts b/app/src/components/launchpad/live-ticker.test.ts index f56a144b..1b24cab2 100644 --- a/app/src/components/launchpad/live-ticker.test.ts +++ b/app/src/components/launchpad/live-ticker.test.ts @@ -34,6 +34,19 @@ test("the poll is the clock: no timer, no request, and a new event replays its e assert.doesNotMatch(css, /infinite/, "no ambient animation"); }); -test("the hero ends on the ticker", () => { - assert.match(hero, /\n\s*<\/section>/); +test("the live line is the grid's last row up to 1023px and the locker's caption from 1024px", () => { + // two renderings of the same one-link component, one shown per size: the hidden one is display:none, so nothing reads or tabs twice + assert.match(hero, /\n\s*<\/div>\n\s*
/, "inside the locker's column, right after the stage frame"); + assert.match(hero, /\n\s*<\/div>\n\s*<\/section>/, "and the last child of the grid, which is the last thing in the hero"); + const grid = read("./LaunchHero.module.css"); + assert.match(grid, /grid-template-areas: "copy" "stage" "totals" "ticker";/); + assert.match(grid, /grid-template-areas: "copy stage" "totals totals" "ticker ticker";/, "tablets: under both columns, as it always was"); + assert.match(grid, /@media \(min-width: 1024px\) \{\n\s*\.layout \{ grid-template-areas: "copy stage" "totals stage";/, "laptops: no row of its own, so the hero is a row shorter"); + // below 1024px the line sits 16px (phones) or 20px under the grid, as it did when it was outside it: negative margins against the 20px and 32px row gaps + assert.match(grid, /\.tickerArea \{ grid-area: ticker; min-width: 0; margin-top: -0\.25rem; \}/); + assert.match(grid, /\.tickerArea \{ margin-top: -0\.75rem; \}/); + assert.match(grid, /\.stageCol \{ display: contents; \}/, "below 1024px the column only wraps the stage"); + assert.match(grid, /\.layout \.tickerStage \{ display: none; \}/); + assert.match(grid, /\.layout \.tickerArea \{ display: none; \}\n\s*\.stageCol \{ display: flex; flex-direction: column; gap: 0\.875rem; grid-area: stage; align-self: center; min-width: 0; \}\n\s*\.layout \.tickerStage \{ display: flex; \}/); + assert.doesNotMatch(read("./hero-layout.ts"), /ticker:/, "no spacing constant outside the grid any more"); });