From e25ccdd5a9cf19ce98b555b47d137a5d9be3e12e Mon Sep 17 00:00:00 2001 From: Maestro <41754216+TheMaestr-o@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:37:55 +0200 Subject: [PATCH] feat(web): fix the card heights, drop the section heading, point the book block at the songbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card heights become one number applied as a fixed height instead of a floor. min-height let a long verse push its card taller, and because the mobile columns are balanced by those heights (240 + 12 + 192 = 444 = 216 + 12 + 216) the two columns stopped ending level — 37px apart at 360px. It looked intermittent only because the verse rotates hourly and only the long ones did it, which is why an earlier attempt at this looked fixed and was not. Fixing the box exposed the other half: the fitter asked the CARD whether its content fit, and a fixed box reports its own clamped height at every size, so the first rung always passed while the text was clipped. It measures the card body's overflow now, and the ladder reaches 13px so the worst case in the pool — a 112-character verse in a 156px-wide card at 360px — still fits. The section heading is gone; the five cards carry it. HomeGridConfig drops eyebrow and title, the admin panel that edited them goes with it, and so do the message keys and the CSS. Block buttons take one-word labels and white-space: nowrap. A pill that wraps stops being a pill: the old multi-word labels broke over three lines inside the capsule at 360px and grew past the card edge. The book block becomes the songbook. It links to newsData.song.href, which already carried the songbook URL per environment, and shows only its label over the photo. The admin preview frames become real device sizes — 1280x900, 900x1180, 390x844. The mobile frame was 1900px tall, which is no phone that exists, and every vh rule on the page resolved against it, so the preview showed a layout nobody would ever get. The verse card's save-as-image button is temporarily behind a flag while the image it generates is still being designed. Columns verified level at 360/375/390/393/414/430/640/768/1280. --- .../app/domains/home-grid/HomeGridEditor.tsx | 30 ++--- apps/web/app/components/StatsGrid.tsx | 119 +++++++++++------- apps/web/app/styles/stats-grid.css | 99 +++++++-------- docs/frontend.md | 16 +++ packages/i18n/src/messages/de.json | 14 ++- packages/i18n/src/messages/en.json | 14 ++- packages/types/src/index.ts | 16 +-- 7 files changed, 169 insertions(+), 139 deletions(-) diff --git a/apps/admin/app/domains/home-grid/HomeGridEditor.tsx b/apps/admin/app/domains/home-grid/HomeGridEditor.tsx index 6a4fda0..4772e55 100644 --- a/apps/admin/app/domains/home-grid/HomeGridEditor.tsx +++ b/apps/admin/app/domains/home-grid/HomeGridEditor.tsx @@ -131,25 +131,10 @@ export default function HomeGridEditor() { return (
-
-

Section heading

-

- Leave a field empty to use the translation from the message files. Anything you type here overrides it for - that language only. -

- setConfig({ ...config, eyebrow: { ...config.eyebrow, [lang]: v } })} - /> - setConfig({ ...config, title: { ...config.title, [lang]: v } })} - /> -
+

+ Leave a text field empty to use the translation from the message files. Anything you type here overrides it + for that language only. +

{BLOCKS.map((meta) => { const b = config.blocks[meta.id]; @@ -346,8 +331,11 @@ function PreviewFrame({ }, [config, ready]); const frameWidth = width === 'desktop' ? 1280 : width === 'tablet' ? 900 : 390; - // Tall enough for the whole section once the page has scrolled to it. - const frameHeight = width === 'mobile' ? 1900 : 1300; + // Real device heights. The mobile frame used to be 1900px tall, which is no + // phone that exists — and every `vh` rule on the page then resolved against + // it, so the preview showed a hero and a spacing nobody would ever get. A + // preview whose viewport is invented previews an invented layout. + const frameHeight = width === 'mobile' ? 844 : width === 'tablet' ? 1180 : 900; // Scaled down so a 1280px page fits the panel while keeping the real // breakpoints — resizing the iframe instead would change which media query // applies and preview the wrong layout. diff --git a/apps/web/app/components/StatsGrid.tsx b/apps/web/app/components/StatsGrid.tsx index 8a93817..8a2906e 100644 --- a/apps/web/app/components/StatsGrid.tsx +++ b/apps/web/app/components/StatsGrid.tsx @@ -16,11 +16,24 @@ import { r2url, type NewsData } from '../lib/api'; const PLAN_URL = 'https://www.bible.com/organizations/3f885b9c-404e-48be-8ad7-3d4e399560e7?utm_source=yvapp&utm_medium=share&utm_content=partner-page'; +// TEMPORARY. The share image QuoteShareModal renders is still being designed, +// so the affordance that advertises it is off. Set to true to bring it back — +// the button and the modal are both untouched underneath. +const SHOW_VERSE_SAVE = false; + // Card headlines vary wildly in length — the verse alone runs 21 to 112 // characters, and a single long word like "Grundlagen" is wider than a narrow // card at the sketch's 64px. Largest rung first; the fitter takes the first // that fits both the height and the width of its card. -const HEADLINE_SIZES = [64, 56, 48, 42, 36, 32, 28, 24, 20, 17]; +// +// The ladder has to reach far enough for the WORST case, not the average one: +// it used to stop at 17px, and the 112-character verse in a 156px-wide card at +// 360px still needed 24px more room than that. The card then grew past its +// min-height, and because the mobile columns are balanced by those heights +// (240 + 12 + 192 = 444 = 216 + 12 + 216) the two columns stopped ending level. +// The misalignment only showed on the hours when a long verse was up, which is +// what made it look intermittent. +const HEADLINE_SIZES = [64, 56, 48, 42, 36, 32, 28, 24, 20, 17, 15, 14, 13]; /** The photo the reading-plan card ships with, used until one is uploaded. */ const PLAN_FALLBACK_PHOTO = '/youversion-plan.webp'; @@ -97,22 +110,30 @@ export default function StatsGrid({ newsData, grid }: { newsData?: NewsData; gri const card = text.closest('.stats__card'); if (!card || !text.textContent?.trim()) continue; - // Measure against the height the card is *supposed* to have, not the one - // it currently has — a card that already overflowed reports the grown - // height, and every size would then look like it fits. min-height is - // border-box (see the global box-sizing reset) while scrollHeight is not, - // so the borders come off. - const style = getComputedStyle(card); - const target = - parseFloat(style.minHeight) - parseFloat(style.borderTopWidth) - parseFloat(style.borderBottomWidth); - if (!Number.isFinite(target) || target <= 0) continue; + // The card is a fixed box (see --stats-card-h), so asking the CARD + // whether it fits is useless: it reports its own clamped height at every + // size, so the first rung always looks fine while the text is quietly + // clipped. + // + // The body's scrollHeight is no good either. `.stats__card-content` sits + // on `margin-top: auto`, and an auto margin in a fixed-height flex column + // leaves scrollHeight a few pixels above clientHeight whatever the type + // size — 4px on the songbook card at every rung from 64 down to 13, so + // the fitter walked the whole ladder and set 17px on a card with room for + // 56. Compare edges instead: the last child's bottom against the body's. + // That is exact, and blind to the auto margin. + const body = card.querySelector('.stats__card-body') ?? card; + const last = body.lastElementChild; + if (!last) continue; for (const size of HEADLINE_SIZES) { text.style.fontSize = `${size}px`; - // scrollHeight covers the whole card, so margins and siblings are - // accounted for without having to enumerate them. The width test - // catches a single long word that would run past the card edge. - if (card.scrollHeight <= target && text.scrollWidth <= text.clientWidth) break; + // The 1px slack absorbs sub-pixel rounding, which otherwise costs a + // whole rung of type for nothing. The width test catches a single long + // word that would run past the card edge. + const fitsHeight = last.getBoundingClientRect().bottom <= body.getBoundingClientRect().bottom + 1; + const fitsWidth = text.scrollWidth <= text.clientWidth + 1; + if (fitsHeight && fitsWidth) break; } } }, []); @@ -175,20 +196,12 @@ export default function StatsGrid({ newsData, grid }: { newsData?: NewsData; gri const faithPhoto = photoOf(blocks.faith); const versePhoto = photoOf(blocks.verse); - const eyebrow = pick(cfg.eyebrow[lang], t('eyebrow')); if (!Object.values(blocks).some((b) => b.visible)) return null; return ( <>
-
- {eyebrow &&

{eyebrow}

} -

- {cfg.title[lang].trim() !== '' ? cfg.title[lang] : t.rich('title', { em })} -

-
-
{blocks.plan.visible && ( @@ -236,21 +249,28 @@ export default function StatsGrid({ newsData, grid }: { newsData?: NewsData; gri aria-label={tr('quote.openShare')} > {versePhoto && photoLayer(blocks.verse, versePhoto, '')} - + {/* TEMPORARY: the save-as-image button is hidden while the + image QuoteShareModal generates is still being designed. + Restore by flipping this flag — nothing else was removed, + and the modal itself still works. Note the whole card is + clickable and opens the same modal. */} + {SHOW_VERSE_SAVE && ( + + )}
{blocks.verse.showLabel &&

{t('verse.label')}

} @@ -305,18 +325,31 @@ export default function StatsGrid({ newsData, grid }: { newsData?: NewsData; gri {blocks.book.visible && ( - {bookPhoto && photoLayer(blocks.book, bookPhoto, bookTitle)} + {bookPhoto && photoLayer(blocks.book, bookPhoto, t('songs.label'))}
{blocks.book.showLabel && ( -

{pick(blocks.book.text[lang].label, tr('book.label'))}

+

{pick(blocks.book.text[lang].label, t('songs.label'))}

)}
-

- {pick(blocks.book.text[lang].title, bookTitle)} -

+ {/* With a photo the picture says "songbooks" and a line of + type on top only competes with it, so the headline sits + out. Without one the card would be a label on an empty + rectangle — which is what the default config gives — so + the headline comes back. An override always wins. */} + {blocks.book.text[lang].title.trim() !== '' ? ( +

+ {blocks.book.text[lang].title} +

+ ) : ( + !bookPhoto && ( +

+ {t.rich('songs.title', { em, br })} +

+ ) + )} {blocks.book.showButton && blocks.book.text[lang].button && ( {blocks.book.text[lang].button} )} diff --git a/apps/web/app/styles/stats-grid.css b/apps/web/app/styles/stats-grid.css index 4b08c7c..5426f61 100644 --- a/apps/web/app/styles/stats-grid.css +++ b/apps/web/app/styles/stats-grid.css @@ -43,35 +43,6 @@ margin: 0 auto; } -.stats__head { - max-width: 800px; - margin: 0 auto 64px; - text-align: center; -} - -.stats__eyebrow { - margin: 0 0 24px; - font: - 600 12px/1 var(--stats-sans); - letter-spacing: 0.15em; - text-transform: uppercase; - color: var(--stats-muted); -} - -.stats__title { - margin: 0; - font: - 400 56px/1.1 var(--stats-serif); - color: var(--stats-text); - letter-spacing: -0.02em; - text-wrap: balance; -} - -.stats__title em { - color: var(--stats-accent); - font-style: italic; -} - /* ── Grid ── */ .stats__grid { display: grid; @@ -113,18 +84,27 @@ border-color: var(--stats-line-hover); } -/* Height modifiers — see the arithmetic in the header comment */ +/* Height modifiers — see the arithmetic in the header comment. + Each height is one number applied as both bounds, so a card is a container + the text must fit into rather than a floor the text can push past. A card + that grows drags its column out of line with the other two. */ .stats__card--tall { - min-height: 724px; + --stats-card-h: 724px; } .stats__card--media { - min-height: 420px; + --stats-card-h: 420px; } .stats__card--short { - min-height: 280px; + --stats-card-h: 280px; +} +.stats__card--mid { + --stats-card-h: 350px; } +.stats__card--tall, +.stats__card--media, +.stats__card--short, .stats__card--mid { - min-height: 350px; + height: var(--stats-card-h); } .stats__card-body { @@ -246,6 +226,11 @@ gap: 0.6em; margin-top: 28px; padding: 13px 24px; + /* A pill that wraps stops being a pill. With multi-word labels the text + broke over three lines inside the capsule on a 360px screen and the whole + shape grew past the card edge. The label stays on one line; keeping it + short enough to fit is the label's job, not the layout's. */ + white-space: nowrap; border: 1px solid var(--stats-line); border-radius: 999px; font: @@ -388,17 +373,16 @@ .stats__col:nth-child(3) { grid-column: auto; } - /* Banner needs to read as solid as the cards below it */ + /* Banner needs to read as solid as the cards below it. Sets the variable, + not min-height — a stray min-height outranks the fixed height whenever it + is the larger of the two and quietly puts the card back to variable. */ .stats__card--tall { - min-height: 350px; + --stats-card-h: 350px; } /* Wide and short — the phone in the photo needs to stay in frame */ .stats__card-photo { object-position: 58% 30%; } - .stats__title { - font-size: 48px; - } } /* ── Responsive — mobile: banner on top, two columns beneath ── */ @@ -406,6 +390,14 @@ .stats { padding: 48px 16px; } + /* Two cards sit side by side here, so a pill has roughly 140px to live in. + The desktop padding leaves no room for the label and the arrow. */ + .stats__btn { + margin-top: 18px; + padding: 10px 16px; + font-size: 13px; + gap: 0.45em; + } .stats__grid { grid-template-columns: repeat(2, 1fr); gap: 12px; @@ -421,22 +413,34 @@ grid-column: span 1; } - .stats__card--tall { - min-height: 280px; - } /* 240 + 12 + 192 = 444 = 216 + 12 + 216. The short card is 192 rather than the 180 it started at: at 180 column 2 came to 432 against column 3's 444, the grid stretched it to match, and its last card ended 12px above the neighbouring one. The same arithmetic that aligns the desktop columns has - to hold at every breakpoint. */ + to hold at every breakpoint. + + Each height is one number on --stats-card-h. min-height alone was a floor: + content that outgrew it — a 112-character verse in a 156px card — pushed + the card taller and the two columns stopped ending level. A fixed height + makes the card a container the text has to fit into, which is what the + fitter in StatsGrid is for. */ + .stats__card--tall { + --stats-card-h: 280px; + } .stats__card--media { - min-height: 240px; + --stats-card-h: 240px; } .stats__card--short { - min-height: 192px; + --stats-card-h: 192px; } .stats__card--mid { - min-height: 216px; + --stats-card-h: 216px; + } + .stats__card--tall, + .stats__card--media, + .stats__card--short, + .stats__card--mid { + height: var(--stats-card-h); } .stats__card { @@ -444,9 +448,6 @@ gap: 24px; } - .stats__title { - font-size: 32px; - } .stats__card-label { font-size: 11px; } diff --git a/docs/frontend.md b/docs/frontend.md index 95985f8..536e842 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -67,6 +67,18 @@ col 3 350 + 24 + 350 = 724 **Verse emphasis.** `splitVerse()` in [lib/verses.ts](../apps/web/app/lib/verses.ts) picks one word to mark: a concept (`Liebe`, `Gnade`, `Hirte`, `erquicken`, …) first, the divine name only if the verse carries no concept, and the longest word as a last resort. Concepts come first because `Gott` appears in most verses — marking it every hour marks nothing. Across the 32 German verses this picks 25 distinct words. The mark is a hand-drawn SVG stroke, not a rectangle. +**No section heading.** The five cards speak for themselves; there is no eyebrow and no `

` above them. `HomeGridConfig` therefore carries `blocks` only. + +**Card heights are fixed, not floors.** Each is one number on `--stats-card-h` applied as `height`. As a `min-height` a long verse pushed its card taller, and because the mobile columns are balanced by those heights (240 + 12 + 192 = 444 = 216 + 12 + 216) the two columns stopped ending level — 37px apart at 360px. It looked intermittent only because the verse rotates hourly and only the long ones did it. + +**The fitter compares edges, not heights.** A fixed box reports its own clamped height at every size, so asking the *card* whether it fits always says yes while the text is quietly clipped. The body's `scrollHeight` is no better: `.stats__card-content` sits on `margin-top: auto`, and an auto margin in a fixed-height flex column leaves `scrollHeight` a few pixels above `clientHeight` whatever the type size — 4px on the songbook card at every rung from 64 down to 13, which set 17px type on a card with room for 56. It compares the last child's bottom edge against the body's instead, which is exact and blind to the auto margin. `HEADLINE_SIZES` reaches 13px so the worst case in the pool — a 112-character verse in a 156px-wide card at 360px — still fits. + +**Button labels are one word** (`Öffnen` · `Besuchen` · `Nachlesen`) and `.stats__btn` carries `white-space: nowrap`. A pill that wraps stops being a pill: with the old multi-word labels the text broke over three lines inside the capsule at 360px and grew past the card edge. + +**The `book` block is the songbook.** It links to `newsData.song.href`, which already carries the songbook URL per environment. With a photo it shows only its label — the picture says "songbooks" and a headline on top competes with it. Without one it falls back to the headline, because the default config has no image and the card would otherwise be a label on an empty rectangle. An editor's override always wins. + +**`SHOW_VERSE_SAVE` is temporarily `false`** — the save-as-image affordance on the verse card is hidden while the image `QuoteShareModal` produces is still being designed. The button and the modal are untouched underneath. Note the whole verse card is still clickable and opens the same modal. + **Config.** `HomeGridConfig` decides visibility, clickability, link, label/button visibility, per-locale text overrides and the image (key, crop, scrim, text colour). Empty text means "use the translation", so copy stays in the message files unless an editor overrides it. **Preview.** With `?gridPreview=1` the section listens for a `sdarm:grid-preview` `postMessage` and renders that draft instead, and scrolls itself into view. The admin's preview is this page in an iframe, so it cannot drift from what visitors see. @@ -471,8 +483,12 @@ Settings panel on the left, preview on the right. Route `/home-grid`, domain fol **Per block:** show/hide · clickable · link (empty = built-in destination) · open in new tab · show label · show button · label / headline / button text per locale · image with crop, scrim strength and text colour. +The section has no heading of its own — the five cards carry it. There is no eyebrow or title to configure. + **Preview is the real site.** Each pane is an `