Homepage hub: /home2 prototype — sections-as-data + bigger shell + hybrid room cards

- New sections registry (lib/rooms.js): each room is one data entry (title/blurb/href/cta/
  size/preview/icon) — add or resize by editing the list.
- Reusable RoomCard (lib/components/RoomCard.svelte) with size variants and hybrid previews:
  Art shows today's live thumbnail, News shows today's top headline, others are blurb+CTA.
- /home2 hidden prototype (noindex, unlinked) with a bigger top bar (logo 50px, larger nav
  labels) and a reflowing grid hub of the four rooms (News/Art/Play/Daily Moment). Iterate
  the look here, then promote to / and remove the clone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-21 19:26:31 -04:00
parent dd8706e2fc
commit 5a8e178f51
3 changed files with 240 additions and 0 deletions
@@ -0,0 +1,72 @@
<script>
// A single hub "room" card. Presentational — the page passes any live preview data in.
let { room, artImg = null, newsHeadline = '' } = $props();
</script>
<a class="card card--{room.size}" class:live={room.preview !== 'static'} href={room.href}>
{#if room.preview === 'art' && artImg}
<div class="thumb" style="background-image:url({artImg})"></div>
{/if}
<div class="body">
<div class="eyebrow"><span class="ic">{room.icon}</span>{room.title}</div>
{#if room.preview === 'news' && newsHeadline}
<p class="headline">{newsHeadline}</p>
{:else}
<p class="blurb">{room.blurb}</p>
{/if}
<span class="cta">{room.cta} <span class="arr"></span></span>
</div>
</a>
<style>
.card {
position: relative; display: flex; flex-direction: column; justify-content: flex-end;
overflow: hidden; text-decoration: none; color: var(--ink);
background: var(--surface); border: 1px solid var(--line); border-radius: 18px;
padding: clamp(18px, 2.2vw, 26px); min-height: 160px;
box-shadow: 0 10px 30px rgba(20, 30, 45, 0.06);
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.card:hover { transform: translateY(-4px); box-shadow: 0 20px 46px rgba(20, 30, 45, 0.12); border-color: #ddd4c4; }
/* size variants (grid spans set by the page) */
.card--large { min-height: 340px; }
.card--wide { min-height: 200px; }
.card--tall { min-height: 340px; }
.card--small { min-height: 160px; }
/* live art preview fills behind the body */
.thumb {
position: absolute; inset: 0; background-size: cover; background-position: center;
}
.thumb::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(to top, rgba(20, 24, 30, 0.78) 6%, rgba(20, 24, 30, 0.20) 46%, rgba(20, 24, 30, 0) 70%);
}
.body { position: relative; z-index: 1; }
.card.live:has(.thumb) .body { color: #fff; }
.eyebrow {
display: flex; align-items: center; gap: 9px;
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
font-size: clamp(1.15rem, 2vw, 1.6rem); font-weight: 600; letter-spacing: -0.01em;
}
.ic { font-size: 1.1em; line-height: 1; }
.card--large .eyebrow { font-size: clamp(1.5rem, 3vw, 2.1rem); }
.blurb { margin: 10px 0 16px; color: var(--muted); font-size: 1rem; line-height: 1.45; max-width: 42ch; }
.headline {
margin: 12px 0 16px; font-family: Georgia, serif; font-style: italic;
font-size: clamp(1.05rem, 1.8vw, 1.35rem); line-height: 1.35; max-width: 40ch;
}
.card.live:has(.thumb) .blurb { color: rgba(255, 255, 255, 0.86); }
.cta { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; color: var(--accent); font-size: 0.98rem; }
.card.live:has(.thumb) .cta { color: #fff; }
.arr { transition: transform 0.18s ease; }
.card:hover .arr { transform: translateX(4px); }
</style>
+46
View File
@@ -0,0 +1,46 @@
// The homepage hub as data. Each "room" is one entry — add, reorder, or resize by
// editing this list. `size` drives the grid footprint (large | wide | tall | small);
// `preview` picks how the card shows itself (news/art = live snippet, static = blurb).
// Copy here is placeholder — owner-written final wording lands later.
export const ROOMS = [
{
id: 'news',
title: 'Good News',
blurb: "The world's quiet good, gathered each morning.",
href: '/', // becomes /news once the Brief is split out
cta: 'Read the Brief',
size: 'large',
preview: 'news',
icon: '📰',
},
{
id: 'art',
title: 'Daily Art',
blurb: 'A masterwork a day, beautifully framed.',
href: '/art',
cta: 'View today',
size: 'wide',
preview: 'art',
icon: '🖼️',
},
{
id: 'play',
title: 'Play',
blurb: 'Daily Word, Word Search, Bloom, Memory Match.',
href: '/play',
cta: 'Enter',
size: 'small',
preview: 'static',
icon: '🎲',
},
{
id: 'moment',
title: 'Daily Moment',
blurb: 'A small calm to carry with you.',
href: '#',
cta: 'Soon',
size: 'small',
preview: 'static',
icon: '🌿',
},
];
+122
View File
@@ -0,0 +1,122 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import { ROOMS } from '$lib/rooms.js';
import RoomCard from '$lib/components/RoomCard.svelte';
// Hidden prototype of the new homepage hub (/home2). Iterate the look here, then
// promote to / and remove this clone — same approach we used for /art.
let artImg = $state(null);
let newsHeadline = $state('');
onMount(async () => {
try { artImg = (await getJSON('/api/art/today'))?.image_url ?? null; } catch { /* card falls back to blurb */ }
try {
const b = await getJSON('/api/brief?limit=1');
newsHeadline = b?.items?.[0]?.title ?? '';
} catch { /* card falls back to blurb */ }
});
</script>
<svelte:head>
<title>upbeatBytes — a calmer corner of the internet</title>
<meta name="robots" content="noindex" />
<meta name="description" content="A calmer corner of the internet: good news, daily art, small games, and little resets." />
</svelte:head>
<div class="room">
<header class="bar">
<a class="brand" href="/home2" aria-label="upbeatBytes home">
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
</a>
<nav class="nav">
<a href="/">News</a>
<a href="/play">Games</a>
<a href="/art">Art</a>
<a class="acct" href="/account" aria-label="Your account">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="8" r="3.3" />
<path d="M5.5 19.2a6.5 6.5 0 0 1 13 0" />
</svg>
</a>
</nav>
</header>
<main class="hub">
<div class="intro">
<h1>A calmer corner of the internet.</h1>
<p>Good news, daily art, small games, and little resets.</p>
</div>
<div class="grid">
{#each ROOMS as r (r.id)}
<RoomCard room={r} {artImg} {newsHeadline} />
{/each}
</div>
</main>
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
</div>
<style>
.room {
--canvas: #faf6ee; --surface: #ffffff; --ink: #232a31; --muted: #707b86;
--line: #ece5d8; --accent: #0a93c0; --accent-deep: #066c8e;
min-height: 100vh; background: var(--canvas); color: var(--ink);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
display: flex; flex-direction: column;
}
/* Bigger, more present top bar — logo + nav labels scaled up. */
.bar {
display: flex; align-items: center; justify-content: space-between;
padding: 20px clamp(18px, 5vw, 64px);
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
}
.brand { display: block; line-height: 0; }
.brand img { height: 50px; width: auto; display: block; }
.nav { display: flex; align-items: center; gap: clamp(18px, 3vw, 38px); }
.nav a { color: var(--ink); text-decoration: none; font-weight: 600; font-size: 1.12rem; }
.nav a:hover { color: var(--accent); }
.acct {
display: inline-flex; align-items: center; justify-content: center;
width: 44px; height: 44px; border-radius: 50%; color: var(--muted);
}
.acct:hover { color: var(--accent); background: #eef6f9; }
.hub {
flex: 1; width: 100%; max-width: 1180px; margin: 0 auto; box-sizing: border-box;
padding: clamp(16px, 3vw, 40px) clamp(18px, 5vw, 64px) clamp(40px, 6vw, 72px);
}
.intro { text-align: center; margin: clamp(8px, 2vw, 22px) 0 clamp(28px, 4vw, 44px); }
.intro h1 {
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
font-size: clamp(1.9rem, 4.5vw, 3rem); margin: 0; letter-spacing: -0.015em; line-height: 1.08;
}
.intro p { color: var(--muted); margin: 12px 0 0; font-size: clamp(1rem, 2vw, 1.2rem); }
.grid {
display: grid; gap: clamp(14px, 1.8vw, 22px);
grid-template-columns: repeat(4, 1fr); grid-auto-flow: dense;
}
/* size variants → grid spans (RoomCard owns min-heights) */
:global(.grid .card--large) { grid-column: span 2; grid-row: span 2; }
:global(.grid .card--wide) { grid-column: span 2; }
:global(.grid .card--tall) { grid-row: span 2; }
@media (max-width: 860px) {
.grid { grid-template-columns: repeat(2, 1fr); }
:global(.grid .card--large), :global(.grid .card--wide) { grid-column: span 2; }
:global(.grid .card--large), :global(.grid .card--tall) { grid-row: auto; }
}
@media (max-width: 540px) {
.grid { grid-template-columns: 1fr; }
:global(.grid .card--large), :global(.grid .card--wide), :global(.grid .card--tall) { grid-column: span 1; }
}
.foot {
text-align: center; color: var(--muted); font-size: 0.86rem;
padding: 28px 16px 40px; border-top: 1px solid var(--line); margin-top: auto;
}
</style>