Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 9 additions & 21 deletions apps/admin/app/domains/home-grid/HomeGridEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,25 +131,10 @@ export default function HomeGridEditor() {
return (
<div className="grid-editor">
<div className="grid-editor__panel">
<section className="card">
<h2 className="card-h">Section heading</h2>
<p className="muted grid-editor__hint">
Leave a field empty to use the translation from the message files. Anything you type here overrides it for
that language only.
</p>
<Field
label={`Eyebrow (${lang.toUpperCase()})`}
value={config.eyebrow[lang]}
placeholder="Unsere Gemeinschaft"
onChange={(v) => setConfig({ ...config, eyebrow: { ...config.eyebrow, [lang]: v } })}
/>
<Field
label={`Title (${lang.toUpperCase()})`}
value={config.title[lang]}
placeholder="Ein Werk, das Grenzen überschreitet"
onChange={(v) => setConfig({ ...config, title: { ...config.title, [lang]: v } })}
/>
</section>
<p className="muted grid-editor__hint">
Leave a text field empty to use the translation from the message files. Anything you type here overrides it
for that language only.
</p>

{BLOCKS.map((meta) => {
const b = config.blocks[meta.id];
Expand Down Expand Up @@ -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.
Expand Down
119 changes: 76 additions & 43 deletions apps/web/app/components/StatsGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -97,22 +110,30 @@ export default function StatsGrid({ newsData, grid }: { newsData?: NewsData; gri
const card = text.closest<HTMLElement>('.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<HTMLElement>('.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;
}
}
}, []);
Expand Down Expand Up @@ -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 (
<>
<section className="stats" id="neuigkeiten" ref={sectionRef}>
<div className="stats__inner">
<header className="stats__head">
{eyebrow && <p className="stats__eyebrow">{eyebrow}</p>}
<h2 className="stats__title">
{cfg.title[lang].trim() !== '' ? cfg.title[lang] : t.rich('title', { em })}
</h2>
</header>

<div className="stats__grid">
<div className="stats__col">
{blocks.plan.visible && (
Expand Down Expand Up @@ -236,21 +249,28 @@ export default function StatsGrid({ newsData, grid }: { newsData?: NewsData; gri
aria-label={tr('quote.openShare')}
>
{versePhoto && photoLayer(blocks.verse, versePhoto, '')}
<button
className="quote-save-btn"
onClick={(e) => {
e.stopPropagation();
setModalOpen(true);
}}
title={tr('quote.saveImage')}
aria-label={tr('quote.saveImage')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
{/* 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 && (
<button
className="quote-save-btn"
onClick={(e) => {
e.stopPropagation();
setModalOpen(true);
}}
title={tr('quote.saveImage')}
aria-label={tr('quote.saveImage')}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
)}

<div className="stats__card-body">
{blocks.verse.showLabel && <p className="stats__card-label">{t('verse.label')}</p>}
Expand Down Expand Up @@ -305,18 +325,31 @@ export default function StatsGrid({ newsData, grid }: { newsData?: NewsData; gri
{blocks.book.visible && (
<CardShell
block={blocks.book}
defaultHref={withTheme(newsData?.book?.href ?? '#', theme)}
defaultHref={withTheme(newsData?.song?.href ?? '#', theme)}
className={`stats__card stats__card--mid${imageClasses(blocks.book, !!bookPhoto)}`}
>
{bookPhoto && photoLayer(blocks.book, bookPhoto, bookTitle)}
{bookPhoto && photoLayer(blocks.book, bookPhoto, t('songs.label'))}
<div className="stats__card-body">
{blocks.book.showLabel && (
<p className="stats__card-label">{pick(blocks.book.text[lang].label, tr('book.label'))}</p>
<p className="stats__card-label">{pick(blocks.book.text[lang].label, t('songs.label'))}</p>
)}
<div className="stats__card-content">
<p className="stats__card-big" data-fit>
{pick(blocks.book.text[lang].title, bookTitle)}
</p>
{/* 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() !== '' ? (
<p className="stats__card-big" data-fit>
{blocks.book.text[lang].title}
</p>
) : (
!bookPhoto && (
<p className="stats__card-big" data-fit>
{t.rich('songs.title', { em, br })}
</p>
)
)}
{blocks.book.showButton && blocks.book.text[lang].button && (
<span className="stats__btn">{blocks.book.text[lang].button}</span>
)}
Expand Down
99 changes: 50 additions & 49 deletions apps/web/app/styles/stats-grid.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -388,24 +373,31 @@
.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 ── */
@media (max-width: 640px) {
.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;
Expand All @@ -421,32 +413,41 @@
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 {
padding: 28px 20px;
gap: 24px;
}

.stats__title {
font-size: 32px;
}
.stats__card-label {
font-size: 11px;
}
Expand Down
Loading
Loading