From 1c6c907f7f8208a30d970c45dd0bc0725b60acdb Mon Sep 17 00:00:00 2001 From: jay Date: Tue, 23 Jun 2026 13:41:04 -0400 Subject: [PATCH] home3 + play: CD-guided card polish, scrollable Small Joys, consistent Back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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 --- frontend/src/routes/home3/+page.svelte | 101 +++++++++++++++---------- frontend/src/routes/play/+page.svelte | 20 +++-- 2 files changed, 74 insertions(+), 47 deletions(-) diff --git a/frontend/src/routes/home3/+page.svelte b/frontend/src/routes/home3/+page.svelte index 7fb92c8..baff20c 100644 --- a/frontend/src/routes/home3/+page.svelte +++ b/frontend/src/routes/home3/+page.svelte @@ -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 @@ - +
-
Small joys for today· {joyIdx + 1} of 3
-
- -
- - -
+ Small joys for today + + {joyIdx + 1} / 3 +
+ +
-
- {@render joyCard(joyIdx)} - {@render joyCard((joyIdx + 1) % 3)} +
+ {@render joyCard(0)} + {@render joyCard(1)} + {@render joyCard(2)}
@@ -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; } diff --git a/frontend/src/routes/play/+page.svelte b/frontend/src/routes/play/+page.svelte index 3c5c125..fb43544 100644 --- a/frontend/src/routes/play/+page.svelte +++ b/frontend/src/routes/play/+page.svelte @@ -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(); }); @@ -274,13 +279,12 @@
- {#if view !== 'hub'} - - - {/if} + + {#if view === 'hub'}

Play

A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.