Fix joys regressions + self-host Work Sans for cards (Codex audit)

- Restore desktop/tablet two-up Small Joys (arrows rotate which two show); the
  swipe rail is now phone-only (≤520). One isNarrow flag drives both the DOM
  (2 vs 3 cards) and the layout, so they can't disagree.
- Contain the overflow that let the phone rail widen the whole page: min-width:0
  + max-width:100% down the chain (bento/rightcol/pair-wrap/joys-shelf/joys),
  mobile bento column to minmax(0,1fr), and overflow-x:clip on .page as a seatbelt.
- Read-time badge: overflow-wrap:anywhere + line-height so a long
  "1 min brief · ~N min full story" never causes width pressure.
- Self-host Work Sans (latin variable woff2, OFL) and apply it to the cards —
  the bolder/darker look from CD's mockup; headings stay Newsreader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-23 13:57:55 -04:00
parent 1c6c907f7f
commit 5657494988
3 changed files with 49 additions and 19 deletions
+47 -19
View File
@@ -39,15 +39,26 @@
// small-joys shelf: a single-row, swipeable rail (all 3 cards live in the row; the next // small-joys shelf: a single-row, swipeable rail (all 3 cards live in the row; the next
// peeks). Arrows scroll it; the counter/dots track the snapped card from scroll position. // peeks). Arrows scroll it; the counter/dots track the snapped card from scroll position.
// Phones get a swipeable rail (all 3 cards in a scroll row, the next peeking). Wider
// screens keep the original two-up view with the arrows rotating which two show — a
// rail there felt like a broken carousel. isNarrow drives BOTH the DOM and the layout
// so they never disagree (no "3 cards wrapping in a 2-col grid").
let railEl = $state(null); let railEl = $state(null);
let joyIdx = $state(0); let joyIdx = $state(0);
function goJoy(i) { let isNarrow = $state(false);
$effect(() => {
const mq = window.matchMedia('(max-width: 520px)');
const sync = (e) => { isNarrow = e.matches; };
sync(mq);
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
});
function railTo(i) {
joyIdx = Math.max(0, Math.min(2, i)); joyIdx = Math.max(0, Math.min(2, i));
const card = railEl?.children?.[joyIdx]; const card = railEl?.children?.[joyIdx];
if (card) railEl.scrollTo({ left: card.offsetLeft, behavior: 'smooth' }); if (card) railEl.scrollTo({ left: card.offsetLeft, behavior: 'smooth' });
} }
const prevJoy = () => goJoy(joyIdx - 1);
const nextJoy = () => goJoy(joyIdx + 1);
let scrollRAF = 0; let scrollRAF = 0;
function onRailScroll() { function onRailScroll() {
if (!railEl) return; if (!railEl) return;
@@ -62,6 +73,9 @@
joyIdx = best; joyIdx = best;
}); });
} }
// desktop = rotate which two are shown; mobile = scroll the rail
const prevJoy = () => (isNarrow ? railTo(joyIdx - 1) : (joyIdx = (joyIdx + 2) % 3));
const nextJoy = () => (isNarrow ? railTo(joyIdx + 1) : (joyIdx = (joyIdx + 1) % 3));
onMount(async () => { onMount(async () => {
try { try {
@@ -252,10 +266,15 @@
<button class="arrow" onclick={nextJoy} aria-label="Next small joy"></button> <button class="arrow" onclick={nextJoy} aria-label="Next small joy"></button>
</div> </div>
</div> </div>
<div class="joys" bind:this={railEl} onscroll={onRailScroll}> <div class="joys" class:rail={isNarrow} bind:this={railEl} onscroll={onRailScroll}>
{@render joyCard(0)} {#if isNarrow}
{@render joyCard(1)} {@render joyCard(0)}
{@render joyCard(2)} {@render joyCard(1)}
{@render joyCard(2)}
{:else}
{@render joyCard(joyIdx)}
{@render joyCard((joyIdx + 1) % 3)}
{/if}
</div> </div>
</div> </div>
</div> </div>
@@ -268,6 +287,7 @@
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; } @font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; } @font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-italic-var.woff2') format('woff2'); font-weight: 400 500; font-style: italic; font-display: swap; } @font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-italic-var.woff2') format('woff2'); font-weight: 400 500; font-style: italic; font-display: swap; }
@font-face { font-family: 'Work Sans'; src: url('/fonts/work-sans-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
.page { .page {
--ink: #1c1916; --body: #6b6256; --muted: #a89e8c; --teal: #0083ad; --ink: #1c1916; --body: #6b6256; --muted: #a89e8c; --teal: #0083ad;
@@ -275,6 +295,7 @@
min-height: 100vh; background: var(--canvas); color: #23201b; min-height: 100vh; background: var(--canvas); color: #23201b;
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif; font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
display: flex; flex-direction: column; display: flex; flex-direction: column;
overflow-x: clip; /* seatbelt: a wide child (e.g. the joys rail) can never scroll the page */
} }
.page :global(*) { box-sizing: border-box; } .page :global(*) { box-sizing: border-box; }
@@ -288,12 +309,17 @@
.hero h1 .b { color: #E0852C; } .hero h1 .b { color: #E0852C; }
.hero .sub { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: clamp(1rem, 2vw, 19px); color: #857b6c; margin: 14px 0 0; } .hero .sub { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: clamp(1rem, 2vw, 19px); color: #857b6c; margin: 14px 0 0; }
/* Bento grid */ /* Bento grid. The cards use Work Sans (per CD's mockup — a touch bolder/darker than our
body Hanken); headings stay Newsreader (set on h2,h3 below). */
.bento { .bento {
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box; max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
padding: 0 clamp(18px, 5vw, 44px) 16px; padding: 0 clamp(18px, 5vw, 44px) 16px;
display: grid; grid-template-columns: minmax(0, 1.18fr) minmax(0, 1.82fr); gap: 16px; display: grid; grid-template-columns: minmax(0, 1.18fr) minmax(0, 1.82fr); gap: 16px;
font-family: 'Work Sans', 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
} }
/* Containment: every link in the chain must be allowed to shrink below its content,
or the phone joys rail forces the whole page wider than the viewport. */
.bento, .rightcol, .pair-wrap, .joys-shelf, .joys { min-width: 0; max-width: 100%; }
/* right column matches the News height; Art stays pinned to the TOP and the Play/Moment /* right column matches the News height; Art stays pinned to the TOP and the Play/Moment
pair to the BOTTOM, with the extra space distributed BETWEEN them (FIX1). The cards pair to the BOTTOM, with the extra space distributed BETWEEN them (FIX1). The cards
themselves keep their natural size and never stretch. */ themselves keep their natural size and never stretch. */
@@ -356,7 +382,7 @@
} }
.summary { font-size: 15px; line-height: 1.5; color: #5a5346; margin: 12px 0 0; } .summary { font-size: 15px; line-height: 1.5; color: #5a5346; margin: 12px 0 0; }
.news-foot { display: flex; align-items: center; justify-content: flex-end; padding-top: 18px; } .news-foot { display: flex; align-items: center; justify-content: flex-end; padding-top: 18px; }
.meta { font-size: 12px; color: var(--muted); } .meta { font-size: 12px; color: var(--muted); line-height: 1.35; overflow-wrap: anywhere; }
/* divider sets the secondary "feed" link apart as its own thing */ /* divider sets the secondary "feed" link apart as its own thing */
.news-div { border: none; border-top: 1px solid #e6d9bf; margin: 14px 0 12px; } .news-div { border: none; border-top: 1px solid #e6d9bf; margin: 14px 0 12px; }
.news-more { display: inline-block; font-size: 13px; font-weight: 600; color: var(--teal); text-decoration: none; } .news-more { display: inline-block; font-size: 13px; font-weight: 600; color: var(--teal); text-decoration: none; }
@@ -441,19 +467,21 @@
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.arrow:hover { background: #fff; color: #9a7b3e; } .arrow:hover { background: #fff; color: #9a7b3e; }
/* one swipeable row; the next card peeks. Scrollbar hidden; snap so it settles per card. */ /* Desktop/tablet: the original two-up view (arrows rotate which two show). */
.joys { .joys { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
display: flex; gap: 16px; overflow-x: auto; scroll-snap-type: x mandatory;
padding-bottom: 6px; scrollbar-width: none; -webkit-overflow-scrolling: touch;
}
.joys::-webkit-scrollbar { display: none; }
.joy { .joy {
flex: none; width: clamp(280px, 86%, 344px); scroll-snap-align: start; min-width: 0; position: relative; overflow: hidden; border-radius: 20px; padding: 18px 22px;
position: relative; overflow: hidden; border-radius: 20px; padding: 18px 22px; min-height: 170px; min-height: 170px; box-sizing: border-box; display: block; text-decoration: none; color: inherit;
box-sizing: border-box; display: block; text-decoration: none; color: inherit;
transition: transform 0.16s ease, box-shadow 0.16s ease; transition: transform 0.16s ease, box-shadow 0.16s ease;
} }
.joy:hover { transform: translateY(-2px); } .joy:hover { transform: translateY(-2px); }
/* Phone: a single swipeable row; the next card peeks. Scrollbar hidden; snap per card. */
.joys.rail {
display: flex; gap: 16px; overflow-x: auto; scroll-snap-type: x mandatory;
padding-bottom: 6px; scrollbar-width: none; -webkit-overflow-scrolling: touch;
}
.joys.rail::-webkit-scrollbar { display: none; }
.joys.rail .joy { flex: 0 0 min(86%, 344px); scroll-snap-align: start; }
.joy-in { position: relative; } /* content sits above the watermark */ .joy-in { position: relative; } /* content sits above the watermark */
.wm { position: absolute; font-family: 'Newsreader', Georgia, serif; line-height: 1; pointer-events: none; } .wm { position: absolute; font-family: 'Newsreader', Georgia, serif; line-height: 1; pointer-events: none; }
@@ -492,7 +520,7 @@
/* responsive — collapse the bento on narrow screens */ /* responsive — collapse the bento on narrow screens */
@media (max-width: 860px) { @media (max-width: 860px) {
.bento { grid-template-columns: 1fr; } .bento { grid-template-columns: minmax(0, 1fr); } /* minmax(0,…) so a wide child can't widen the page */
.news { grid-row: auto; } .news { grid-row: auto; }
/* single column = natural card height, so the gist is never truncated; drop the /* single column = natural card height, so the gist is never truncated; drop the
bottom fade (it would otherwise dim the final line for no reason) */ bottom fade (it would otherwise dim the final line for no reason) */
+2
View File
@@ -5,3 +5,5 @@ Source: https://github.com/sharanda/manrope (via fontsource).
hanken-var.woff2 — "Hanken Grotesk" by Alfredo Marco Pradil. License: SIL Open Font License 1.1. hanken-var.woff2 — "Hanken Grotesk" by Alfredo Marco Pradil. License: SIL Open Font License 1.1.
newsreader-var.woff2 / newsreader-italic-var.woff2 — "Newsreader" by Production Type (Google Fonts). newsreader-var.woff2 / newsreader-italic-var.woff2 — "Newsreader" by Production Type (Google Fonts).
License: SIL Open Font License 1.1. All self-hosted (no Google hotlink) for the /home3 design direction. License: SIL Open Font License 1.1. All self-hosted (no Google hotlink) for the /home3 design direction.
Work Sans — Wei Huang, OFL 1.1 (Google Fonts). Latin subset, variable 400700.
Binary file not shown.