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
-
-
- {#each [0, 1, 2] as d}
-
- {/each}
-
-
-
-
-
+
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.