home3 + play: CD-guided card polish, scrollable Small Joys, consistent Back

- /play: hub view now shows a "Back" row under HubBar too (leaves Play to where
  you came from), so every hub page has the same bar+back rhythm — no shift.
- Eyelash on every card: each .label now opens with a short accent dash (via
  currentColor, so each label's colour drives it) + tighter tracking (.18em, 600),
  on desktop and mobile alike.
- Card headings to Newsreader 500 (calmer), body to 15px/1.5 #5a5346 (per CD).
- Small Joys is now a single swipeable row (all 3 cards in a scroll-snap rail, the
  next peeking; arrows scroll, counter tracks the snapped card) instead of a
  2-up rotation — more elegant, and the 1/3 ‹ › affordance is honest.
- Daily Art card: more breathing room before "View today".

Note: used our self-hosted Hanken Grotesk for the card sans (CD's mockup used
Google Work Sans) — same metrics applied; can self-host Work Sans if preferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-23 13:41:04 -04:00
parent f0e02b40e5
commit 1c6c907f7f
2 changed files with 74 additions and 47 deletions
+62 -39
View File
@@ -37,11 +37,31 @@
return news.source_read_minutes ? `${brief} · ~${news.source_read_minutes} min full story` : brief;
});
// small-joys shelf: 3 cells shown two at a time, rotated by the reader (no auto-motion)
const JOY_ACCENTS = ['#4f7da8', '#b06a86', '#b06a45'];
// 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.
let railEl = $state(null);
let joyIdx = $state(0);
const prevJoy = () => (joyIdx = (joyIdx + 2) % 3);
const nextJoy = () => (joyIdx = (joyIdx + 1) % 3);
function goJoy(i) {
joyIdx = Math.max(0, Math.min(2, i));
const card = railEl?.children?.[joyIdx];
if (card) railEl.scrollTo({ left: card.offsetLeft, behavior: 'smooth' });
}
const prevJoy = () => goJoy(joyIdx - 1);
const nextJoy = () => goJoy(joyIdx + 1);
let scrollRAF = 0;
function onRailScroll() {
if (!railEl) return;
cancelAnimationFrame(scrollRAF);
scrollRAF = requestAnimationFrame(() => {
const x = railEl.scrollLeft;
let best = 0, bestD = Infinity;
for (let i = 0; i < railEl.children.length; i++) {
const d = Math.abs(railEl.children[i].offsetLeft - x);
if (d < bestD) { bestD = d; best = i; }
}
joyIdx = best;
});
}
onMount(async () => {
try {
@@ -221,25 +241,21 @@
</div>
</div>
<!-- "small joys" — two jewels at a time, rotated by the reader (3 cells total) -->
<!-- "small joys" — a single swipeable row; all three live in the rail, the next peeks -->
<div class="joys-shelf">
<div class="joys-head">
<div class="joys-title"><span class="jt-label">Small joys for today</span><span class="jt-count">· {joyIdx + 1} of 3</span></div>
<div class="joys-nav">
<div class="joys-dots" aria-hidden="true">
{#each [0, 1, 2] as d}
<span class="dot" class:on={d === joyIdx} style={d === joyIdx ? `background:${JOY_ACCENTS[joyIdx]}` : ''}></span>
{/each}
</div>
<div class="joys-arrows">
<button class="arrow" onclick={prevJoy} aria-label="Previous small joys"></button>
<button class="arrow" onclick={nextJoy} aria-label="More small joys"></button>
</div>
<span class="jt-label">Small joys for today</span>
<span class="jt-line"></span>
<span class="jt-count">{joyIdx + 1} / 3</span>
<div class="joys-arrows">
<button class="arrow" onclick={prevJoy} aria-label="Previous small joy"></button>
<button class="arrow" onclick={nextJoy} aria-label="Next small joy"></button>
</div>
</div>
<div class="joys">
{@render joyCard(joyIdx)}
{@render joyCard((joyIdx + 1) % 3)}
<div class="joys" bind:this={railEl} onscroll={onRailScroll}>
{@render joyCard(0)}
{@render joyCard(1)}
{@render joyCard(2)}
</div>
</div>
</div>
@@ -289,10 +305,16 @@
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
a.card:hover { transform: translateY(-2px); }
.label { font-size: 11px; font-weight: 700; letter-spacing: 0.16em; }
/* every card opens with the same "eyelash" — a short dash in the card's accent (via
currentColor, so each label's inline colour drives it) + a tracked uppercase label */
.label {
display: inline-flex; align-items: center; gap: 9px;
font-size: 11px; font-weight: 600; letter-spacing: 0.18em; line-height: 1;
}
.label::before { content: ''; width: 20px; height: 2px; border-radius: 2px; background: currentColor; flex: none; }
.link { font-size: 14px; font-weight: 600; padding-bottom: 2px; align-self: flex-start; }
/* card titles a touch larger + bolder so they jump on hover/scan */
h2, h3 { font-family: 'Newsreader', Georgia, serif; font-weight: 600; letter-spacing: -0.01em; color: var(--ink); }
/* card titles: Newsreader, a calmer medium weight (per CD's mockup) */
h2, h3 { font-family: 'Newsreader', Georgia, serif; font-weight: 500; letter-spacing: -0.01em; color: var(--ink); }
/* Good News — tall, photo on top */
.news {
@@ -332,7 +354,7 @@
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 2.1em), transparent);
mask-image: linear-gradient(to bottom, #000 calc(100% - 2.1em), transparent);
}
.summary { font-size: 14.5px; line-height: 1.55; color: var(--body); 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; }
.meta { font-size: 12px; color: var(--muted); }
/* divider sets the secondary "feed" link apart as its own thing */
@@ -345,9 +367,10 @@
.art { background: #F3EEF9; border: 1px solid #e4d8f1; display: flex; min-height: 188px; }
.art-body { flex: 1; padding: 24px 26px; display: flex; flex-direction: column; }
.art h3 { font-size: clamp(1.35rem, 2.1vw, 25px); line-height: 1.16; margin: 10px 0 0; color: #2a1c3d; }
.art-today { font-size: 13.5px; line-height: 1.5; color: #6f6280; margin: 9px 0 0; }
.art-today { font-size: 14.5px; line-height: 1.5; color: #6f6280; margin: 11px 0 0; }
.ital { font-style: italic; font-family: 'Newsreader', Georgia, serif; }
.art-link { margin-top: auto; color: #8857C2; border-bottom: 2px solid #c9aef0; }
/* a little breathing room before the link (per CD), not pinned tight under the caption */
.art-link { margin-top: 20px; color: #8857C2; border-bottom: 2px solid #c9aef0; }
/* swatch crops a few px off every edge (::after inset) so scanned paintings don't show
their ragged/black canvas edge at the top */
.art-swatch {
@@ -404,25 +427,28 @@
/* "small joys" rail — little jewels: one big focal point per card, a faint oversized
watermark glyph, an accent-tag label, soft diagonal gradient + long low shadow. */
.joys-shelf { flex: none; }
.joys-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.joys-title { display: flex; align-items: baseline; gap: 8px; }
.jt-label { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 17px; color: #7a6f5b; }
.jt-count { font-size: 12px; color: #b3a890; }
.joys-nav { display: flex; align-items: center; gap: 14px; }
.joys-dots { display: flex; align-items: center; gap: 6px; }
.dot { width: 6px; height: 6px; border-radius: 50%; background: #d9cdb8; transition: width 0.2s ease, background 0.2s ease; }
.dot.on { width: 18px; border-radius: 3px; }
/* header: italic title · hairline rule · counter · arrows (the gesture is genuinely
horizontal now, so the 1/3 + affordance is honest) */
.joys-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.jt-label { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 18px; color: #3a342b; }
.jt-line { flex: 1; height: 1px; background: #e6dcc8; }
.jt-count { font-size: 12px; color: #b0a690; white-space: nowrap; }
.joys-arrows { display: flex; gap: 8px; }
.arrow {
width: 30px; height: 30px; border-radius: 50%; border: 1px solid #e0d3b8; background: transparent;
color: #b09a6e; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center;
padding: 0; line-height: 1; transition: background 0.15s ease, color 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.arrow:hover { background: #fff; color: #9a7b3e; }
.joys { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
/* all cells share one compact height (tight, not crowded) so every rotation matches */
/* clamp every card to the WORD card's natural height (its tallest) so rotations never jump */
/* one swipeable row; the next card peeks. Scrollbar hidden; snap so it settles per card. */
.joys {
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 {
flex: none; width: clamp(280px, 86%, 344px); scroll-snap-align: start;
position: relative; overflow: hidden; border-radius: 20px; padding: 18px 22px; min-height: 170px;
box-sizing: border-box; display: block; text-decoration: none; color: inherit;
transition: transform 0.16s ease, box-shadow 0.16s ease;
@@ -479,9 +505,6 @@
.art { flex-direction: column; min-height: 0; }
.art-swatch { width: 100%; min-width: 0; order: -1; aspect-ratio: 3 / 2; }
.pair { grid-template-columns: 1fr; }
.joys { grid-template-columns: 1fr; }
/* tighten the joys header so the title + dots/arrows never collide on a phone */
.joys-head { flex-wrap: wrap; gap: 8px 12px; }
/* Entertainment: when stacked it loses the height it borrowed from Play on desktop,
so the content felt crowded. Give it room to breathe (not as tall as desktop). */
.moment { padding: 30px 24px; gap: 6px; }
+12 -8
View File
@@ -207,6 +207,11 @@
// (no in-app history) it navigates to the parent screen instead of leaving the
// site. Device Back stays browser-native either way.
let appNavDepth = 0;
let cameFromApp = $state(false); // arrived via in-app nav (e.g. the hub) → Back returns there
function leavePlay() {
if (cameFromApp && typeof history !== 'undefined') history.back();
else goto('/home3', { replaceState: true });
}
function openGame(g) { appNavDepth++; goto('/play?game=' + g); }
function pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); }
function back() {
@@ -260,7 +265,7 @@
syncAllGames(); // signed-in: pull cross-device status into the cards + upload local progress
});
// Refresh hub/selection statuses whenever we land on a screen (incl. Back).
afterNavigate(() => refreshStatus());
afterNavigate(({ from }) => { if (from) cameFromApp = true; refreshStatus(); });
</script>
<svelte:head>
@@ -274,13 +279,12 @@
<HubBar active="games" />
<main class="container page" class:gameview={view === 'play'}>
{#if view !== 'hub'}
<!-- in-game step-back (selection / hub); the global nav lives in HubBar above -->
<button class="gameback" onclick={back}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
{view === 'play' ? 'Game Selection' : 'Play Hub'}
</button>
{/if}
<!-- Back row under HubBar, consistent with the other hub pages. In a game it steps back
(selection / hub); on the games landing it leaves Play (to where you came from). -->
<button class="gameback" onclick={view === 'hub' ? leavePlay : back}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
{view === 'hub' ? 'Back' : (view === 'play' ? 'Game Selection' : 'Play Hub')}
</button>
{#if view === 'hub'}
<h1>Play</h1>
<p class="sub">A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.</p>