Art page round 2: virtual frames, real logo, hi-res zoom, spacing/affordance polish

- Virtual frames (Walnut/Gold/Silver/None), selectable + remembered in localStorage,
  built as a beveled moulding around a cream museum mat.
- Header uses the real /logo.svg wordmark; the "No ads" pill is replaced by an
  account icon (the pill doesn't need to follow every page).
- Lightbox now opens a full-resolution copy that fills the screen: art._download_image
  caches a hi-res {id}-full copy alongside the web-large display copy, served via
  /api/art/image/{id}?size=full (image_url_large in /api/art/today).
- Centered the placard bullet separators (explicit .sep spans, equal margins).
- Image no longer shifts on hover; a quiet "Click to expand" affordance sits on the art.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-21 16:25:31 -04:00
parent 9bfec573e2
commit 27788ba2a8
4 changed files with 198 additions and 58 deletions
+146 -31
View File
@@ -2,11 +2,28 @@
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
// Virtual frames the viewer can switch between — remembered locally, no account needed.
const FRAMES = [
{ id: 'walnut', label: 'Walnut' },
{ id: 'gold', label: 'Gold' },
{ id: 'silver', label: 'Silver' },
{ id: 'none', label: 'No frame' },
];
let art = $state(null);
let state = $state('loading'); // loading | ready | empty
let zoom = $state(false);
let frame = $state('walnut');
let who = $derived(
art ? [art.artist || 'Unknown artist', art.date_text].filter(Boolean) : []
);
onMount(async () => {
try {
const saved = localStorage.getItem('ub_art_frame');
if (saved && FRAMES.some((f) => f.id === saved)) frame = saved;
} catch { /* private mode — default frame is fine */ }
try {
art = await getJSON('/api/art/today');
state = art?.image_url ? 'ready' : 'empty';
@@ -14,6 +31,11 @@
state = 'empty';
}
});
function setFrame(id) {
frame = id;
try { localStorage.setItem('ub_art_frame', id); } catch { /* ignore */ }
}
</script>
<svelte:head>
@@ -23,12 +45,20 @@
<div class="room">
<header class="bar">
<a class="brand" href="/">upbeat<span>Bytes</span></a>
<a class="brand" href="/" 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" aria-current="page">Art</a>
<span class="pill">No ads · No paywalls</span>
<a class="acct" href="/account" aria-label="Your account">
<svg viewBox="0 0 24 24" width="22" height="22" 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>
@@ -40,17 +70,39 @@
{#if state === 'ready'}
<figure class="piece">
<button class="frame" onclick={() => (zoom = true)} aria-label="View larger">
<img src={art.image_url} alt={art.title} />
<button class="frame frame--{frame}" onclick={() => (zoom = true)} aria-label="Expand artwork">
<span class="mat">
<img src={art.image_url} alt={art.title} />
<span class="hint">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" />
</svg>
Click to expand
</span>
</span>
</button>
<figcaption class="placard">
<h2 class="title">{art.title}</h2>
<p class="who">{art.artist || 'Unknown artist'}{#if art.date_text} · {art.date_text}{/if}</p>
<p class="who">
{#each who as part, i}{#if i > 0}<span class="sep">·</span>{/if}{part}{/each}
</p>
{#if art.medium}<p class="medium">{art.medium}</p>{/if}
<p class="credit">
from {art.museum}{#if art.license} · {art.license}{/if}
from {art.museum}{#if art.license}<span class="sep">·</span>{art.license}{/if}
{#if art.source_url}<a class="more" href={art.source_url} target="_blank" rel="noopener">View at {art.museum}</a>{/if}
</p>
<div class="frames">
<span class="frames-label">Frame</span>
{#each FRAMES as f}
<button class="swatch swatch--{f.id}" class:on={frame === f.id}
onclick={() => setFrame(f.id)} aria-pressed={frame === f.id} title={f.label}>
<span class="sr">{f.label}</span>
</button>
{/each}
</div>
</figcaption>
</figure>
{:else if state === 'empty'}
@@ -65,8 +117,8 @@
{#if zoom && art}
<button class="lightbox" onclick={() => (zoom = false)} aria-label="Close">
<img src={art.image_url} alt={art.title} />
<span class="lb-cap">{art.title} · {art.artist || ''}</span>
<img src={art.image_url_large || art.image_url} alt={art.title} />
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span>
</button>
{/if}
@@ -90,19 +142,20 @@
.bar {
display: flex; align-items: center; justify-content: space-between;
padding: 18px clamp(16px, 5vw, 56px);
padding: 14px clamp(16px, 5vw, 56px);
max-width: 1100px; width: 100%; margin: 0 auto; box-sizing: border-box;
}
.brand { font-weight: 800; font-size: 1.25rem; letter-spacing: -0.02em; color: var(--ink); text-decoration: none; }
.brand span { color: var(--accent); }
.brand { display: block; line-height: 0; }
.brand img { height: 34px; width: auto; display: block; }
.nav { display: flex; align-items: center; gap: clamp(12px, 2.5vw, 26px); }
.nav a { color: var(--muted); text-decoration: none; font-weight: 600; font-size: 0.95rem; }
.nav a:hover { color: var(--ink); }
.nav a[aria-current="page"] { color: var(--accent); }
.pill {
font-size: 0.76rem; font-weight: 600; color: var(--accent-deep);
background: #e7f4f9; border: 1px solid #cdeaf3; border-radius: 999px; padding: 5px 12px;
.acct {
display: inline-flex; align-items: center; justify-content: center;
width: 38px; height: 38px; border-radius: 50%; color: var(--muted);
}
.acct:hover { color: var(--accent); background: #eef6f9; }
.gallery {
flex: 1; width: 100%; max-width: 1100px; margin: 0 auto;
@@ -116,17 +169,65 @@
}
.intro p { color: var(--muted); margin: 10px 0 0; font-size: 1.05rem; }
.piece { margin: 0; display: flex; flex-direction: column; align-items: center; max-width: 820px; }
.frame {
border: none; padding: 0; background: var(--surface); cursor: zoom-in;
border-radius: 18px; overflow: hidden; line-height: 0;
box-shadow: 0 18px 50px rgba(20, 30, 45, 0.14), 0 2px 8px rgba(20, 30, 45, 0.06);
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.frame:hover { transform: translateY(-3px); box-shadow: 0 26px 64px rgba(20, 30, 45, 0.18); }
.frame img { display: block; width: 100%; height: auto; max-height: 74vh; object-fit: contain; }
.piece { margin: 0; display: flex; flex-direction: column; align-items: center; max-width: 860px; }
.placard { text-align: center; margin-top: clamp(20px, 4vw, 34px); max-width: 640px; }
/* The frame: a beveled moulding (wood/metal) around a cream mat around the art. */
.frame {
border: none; cursor: zoom-in; padding: 0; background: none;
position: relative; line-height: 0; display: inline-block; max-width: 100%;
border-radius: 5px;
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.20), 0 3px 10px rgba(20, 30, 45, 0.10);
}
.frame--walnut, .frame--gold, .frame--silver { padding: clamp(11px, 2.3vw, 22px); border-radius: 4px; }
.frame--walnut {
background: linear-gradient(135deg, #5c3d26, #87592f 38%, #4d3220 68%, #76512f);
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.24),
inset 0 0 0 1px rgba(255, 255, 255, 0.10),
inset 0 2px 3px rgba(255, 226, 190, 0.30),
inset 0 -3px 9px rgba(0, 0, 0, 0.48);
}
.frame--gold {
background: linear-gradient(135deg, #b88c3d, #ecd293 42%, #a9772f 60%, #dcbd71);
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.20),
inset 0 0 0 1px rgba(255, 255, 255, 0.30),
inset 0 2px 3px rgba(255, 249, 226, 0.60),
inset 0 -3px 9px rgba(92, 62, 12, 0.48);
}
.frame--silver {
background: linear-gradient(135deg, #a9b0ba, #edf0f3 45%, #98a0ab 62%, #d2d7de);
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.18),
inset 0 0 0 1px rgba(255, 255, 255, 0.50),
inset 0 2px 3px rgba(255, 255, 255, 0.75),
inset 0 -3px 9px rgba(60, 70, 85, 0.42);
}
.mat {
display: block; position: relative; background: #fbf8f1; border-radius: 1px;
}
.frame--walnut .mat, .frame--gold .mat, .frame--silver .mat {
padding: clamp(10px, 2.4vw, 22px);
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.14), inset 0 0 0 1px rgba(0, 0, 0, 0.05);
}
.frame img { display: block; width: 100%; height: auto; max-height: 72vh; object-fit: contain; border-radius: 1px; }
/* Plain mode = the clean soft-shadowed card, no moulding. */
.frame--none { background: var(--surface); border-radius: 14px; overflow: hidden; }
.frame--none .mat { background: none; }
.frame--none img { border-radius: 14px; }
/* A quiet, always-there affordance — no hover wobble. */
.hint {
position: absolute; right: 12px; bottom: 12px;
display: inline-flex; align-items: center; gap: 6px;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 0.76rem; font-weight: 600; line-height: 1; color: #fff;
background: rgba(20, 26, 33, 0.50); border-radius: 999px; padding: 7px 11px;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
pointer-events: none; transition: background 0.2s ease;
}
.frame:hover .hint, .frame:focus-visible .hint { background: rgba(20, 26, 33, 0.72); }
.placard { text-align: center; margin-top: clamp(22px, 4vw, 36px); max-width: 640px; }
.title {
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
font-size: clamp(1.4rem, 3.5vw, 2rem); margin: 0; line-height: 1.2;
@@ -134,9 +235,23 @@
.who { margin: 8px 0 0; font-size: 1.05rem; color: var(--ink); }
.medium { margin: 4px 0 0; color: var(--muted); font-size: 0.95rem; font-style: italic; }
.credit { margin: 16px 0 0; color: var(--muted); font-size: 0.88rem; }
.sep { display: inline-block; margin: 0 0.5em; color: var(--muted); }
.more { display: inline-block; margin-left: 8px; color: var(--accent); font-weight: 600; text-decoration: none; }
.more:hover { color: var(--accent-deep); }
.frames { display: flex; align-items: center; justify-content: center; gap: 9px; margin-top: 22px; }
.frames-label { font-size: 0.74rem; font-weight: 600; color: var(--muted); margin-right: 4px; text-transform: uppercase; letter-spacing: 0.06em; }
.swatch {
width: 26px; height: 26px; border-radius: 50%; border: 2px solid transparent;
cursor: pointer; padding: 0; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
.swatch.on { box-shadow: 0 0 0 2px var(--canvas), 0 0 0 4px var(--accent); }
.swatch--walnut { background: linear-gradient(135deg, #5c3d26, #87592f); }
.swatch--gold { background: linear-gradient(135deg, #b88c3d, #ecd293); }
.swatch--silver { background: linear-gradient(135deg, #a9b0ba, #edf0f3); }
.swatch--none { background: var(--surface); border: 2px solid var(--line); box-shadow: inset 0 0 0 1px rgba(0,0,0,0.03); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
.foot {
@@ -146,13 +261,13 @@
.lightbox {
position: fixed; inset: 0; z-index: 50; border: none; cursor: zoom-out;
background: rgba(18, 22, 28, 0.92); display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 14px; padding: 4vmin;
background: rgba(18, 22, 28, 0.94); display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 14px; padding: 3vmin;
}
.lightbox img {
max-width: 97vw; max-height: 92vh; width: auto; height: auto;
object-fit: contain; border-radius: 4px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.lightbox img { max-width: 96vw; max-height: 88vh; border-radius: 8px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
.lb-cap { color: #e9e2d6; font-size: 0.9rem; }
@media (max-width: 560px) {
.pill { display: none; }
}
.lb-cap .sep { color: #9aa0a6; }
</style>
+6 -2
View File
@@ -2276,11 +2276,15 @@ def create_app() -> FastAPI:
"is_public_domain": bool(a["is_public_domain"]),
"license": "Public Domain (CC0)" if a["is_public_domain"] else None,
"image_url": f"/api/art/image/{a['object_id']}",
"image_url_large": f"/api/art/image/{a['object_id']}?size=full",
}
@app.get("/api/art/image/{object_id}")
def art_image(object_id: int) -> FileResponse:
matches = sorted(art.cache_dir().glob(f"{object_id}.*"))
def art_image(object_id: int, size: str = Query("")) -> FileResponse:
cdir = art.cache_dir()
matches = sorted(cdir.glob(f"{object_id}-full.*")) if size == "full" else []
if not matches: # fall back to the web-large copy
matches = sorted(cdir.glob(f"{object_id}.*"))
if not matches:
raise HTTPException(status_code=404, detail="Not cached.")
# Cached museum image: immutable for a given object id.
+36 -25
View File
@@ -91,34 +91,45 @@ def _object(object_id: int) -> dict:
return _http_json(f"{MET_BASE}/objects/{object_id}")
def _download_image(obj: dict, object_id: int) -> str | None:
"""Download the web-large (then full) image to our cache; return the filename or None.
def _fetch_to_cache(url: str | None, stem: str) -> str | None:
"""Download one image URL to cache as {stem}.{ext}; return the filename or None.
Writes to a temp file then atomically renames, so a reader never sees a half-file."""
for key in ("primaryImageSmall", "primaryImage"):
url = obj.get(key)
if not url:
continue
if not url:
return None
try:
data, ctype = _http_bytes(url)
except Exception: # noqa: BLE001
return None
if not ctype.startswith("image/") or len(data) < _MIN_IMAGE_BYTES:
return None
ext = ".png" if "png" in ctype else ".jpg"
fname = f"{stem}{ext}"
cdir = cache_dir()
tmp = cdir / f".{stem}.tmp"
try:
tmp.write_bytes(data)
os.replace(tmp, cdir / fname) # atomic
except OSError:
try:
data, ctype = _http_bytes(url)
except Exception: # noqa: BLE001
continue
if not ctype.startswith("image/") or len(data) < _MIN_IMAGE_BYTES:
continue
ext = ".png" if "png" in ctype else ".jpg"
fname = f"{object_id}{ext}"
cdir = cache_dir()
tmp = cdir / f".{object_id}.tmp"
try:
tmp.write_bytes(data)
os.replace(tmp, cdir / fname) # atomic
tmp.unlink()
except OSError:
try:
tmp.unlink()
except OSError:
pass
return None
return fname
return None
pass
return None
return fname
def _download_image(obj: dict, object_id: int) -> str | None:
"""Cache the day's images to our origin. Stores the web-large display copy as
{id}.{ext} (what the page shows) and, when available, the full-resolution copy as
{id}-full.{ext} (what the lightbox opens, so zoom fills the screen). Returns the
display filename or None if even the display copy couldn't be fetched."""
display = _fetch_to_cache(obj.get("primaryImageSmall") or obj.get("primaryImage"), str(object_id))
if not display:
return None
full_url = obj.get("primaryImage")
if full_url and full_url != obj.get("primaryImageSmall"):
_fetch_to_cache(full_url, f"{object_id}-full") # best-effort hi-res for zoom
return display
def _candidates(conn: sqlite3.Connection, art_date: str, source: str) -> list[int]:
+10
View File
@@ -54,6 +54,16 @@ def test_pick_caches_image_metadata_and_marks_shown(conn):
assert not list(art.cache_dir().glob("*.tmp")) # atomic write left no temp file
def test_pick_caches_full_res_for_lightbox(conn):
conn.execute("INSERT INTO art_pool (source, object_id) VALUES ('met', 1)") # has distinct primaryImage
conn.commit()
a = art.pick_daily(conn, art_date="2026-06-21")
assert a and a["object_id"] == 1
assert list(art.cache_dir().glob("1.*")) # web-large display copy
assert list(art.cache_dir().glob("1-full.*")) # hi-res copy for the zoom
assert not list(art.cache_dir().glob("*.tmp"))
def test_blocked_pieces_are_never_picked(conn):
art.harvest_pool(conn)
conn.execute("UPDATE art_pool SET blocked=1 WHERE object_id=1") # block the good one