Files
upbeatBytes/frontend/src/routes/art/+page.svelte
T
thejayman77 89987b8316 /art: thickness affects rotated fullscreen; collapsible writeup on mobile
- Fullscreen (rotated landscape view) ignored the thickness slider because I'd
  hard-coded the frame rail/mat to fit after rotation. Let them scale with
  thickness again and pull the image's short-edge cap in (60vw) so the whole
  frame still fits even at max thickness.
- Mobile: the writeup is now a collapsible "About this piece" accordion between
  the artwork and the controls (open by default). Collapse it and the frame /
  thickness / palette controls rise up beside the artwork, so you can see frame
  changes live. Desktop unchanged (writeup always shown in the left column).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:09:28 -04:00

547 lines
30 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import { afterNavigate, goto } from '$app/navigation';
import HubBar from '$lib/components/HubBar.svelte';
// Virtual frames the viewer can switch between — remembered locally, no account needed.
const FRAMES = [
{ id: 'walnut', label: 'Walnut' },
{ id: 'oak', label: 'Oak' },
{ id: 'mahogany', label: 'Mahogany' },
{ id: 'gold', label: 'Gold' },
{ id: 'silver', label: 'Silver' },
{ id: 'black', label: 'Black' },
{ 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 thickness = $state(1); // frame-scale multiplier, 0.71.9 (mat caps at 1.5)
let lightboxEl = $state(null);
// Wide paintings fill a portrait phone far better turned sideways: when the art is
// landscape we rotate the fullscreen view 90° (CSS handles WHEN — only narrow+portrait
// screens; desktop and portrait art stay upright). Aspect is read off the loaded image.
let landscape = $state(false);
function onKey(e) {
if (e.key === 'Escape' && zoom) zoom = false;
}
// Move focus to the lightbox when it opens, so Escape/Enter work and focus is trapped sanely.
$effect(() => { if (zoom && lightboxEl) lightboxEl.focus(); });
// Same single-history Back as the hub pages: return to where you came from (the hub),
// or fall back to it on a cold deep-link without pushing a new entry.
let cameFromApp = $state(false);
afterNavigate(({ from }) => { if (from) cameFromApp = true; });
function goBack() {
if (cameFromApp && typeof history !== 'undefined') history.back();
else goto('/home3', { replaceState: true });
}
// Woods are built from four real mitered rails (grain turns at the corners); metals/none aren't.
let isWood = $derived(['walnut', 'oak', 'mahogany'].includes(frame));
onMount(async () => {
try {
const saved = localStorage.getItem('ub_art_frame');
if (saved && FRAMES.some((f) => f.id === saved)) frame = saved;
const t = parseFloat(localStorage.getItem('ub_art_thickness'));
if (t >= 0.7 && t <= 1.9) thickness = t;
} catch { /* private mode — defaults are fine */ }
try {
art = await getJSON('/api/art/today');
state = art?.image_url ? 'ready' : 'empty';
} catch {
state = 'empty';
}
});
function setFrame(id) {
frame = id;
try { localStorage.setItem('ub_art_frame', id); } catch { /* ignore */ }
}
function saveThickness() {
try { localStorage.setItem('ub_art_thickness', String(thickness)); } catch { /* ignore */ }
}
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
let dateLabel = $derived.by(() => {
if (!art?.date) return '';
const [, m, d] = art.date.split('-').map(Number);
return MONTHS[m - 1] ? `${MONTHS[m - 1]} ${d}` : '';
});
// "Winslow Homer, 1865 — oil on canvas"
let attribution = $derived.by(() => {
if (!art) return '';
const who = [art.artist, art.date_text].filter(Boolean).join(', ');
return art.medium ? (who ? `${who}${art.medium}` : art.medium) : who;
});
let dlName = $derived(art ? `${(art.title || 'artwork').replace(/[^\w \-]+/g, '').trim()}.jpg` : 'artwork.jpg');
let detailsOpen = $state(true); // mobile: collapse the writeup to bring the controls up near the art
let copied = $state(false);
async function share() {
const url = art?.source_url || location.href;
try {
if (navigator.share) { await navigator.share({ title: art?.title || 'Daily Art', url }); return; }
await navigator.clipboard.writeText(url);
copied = true; setTimeout(() => (copied = false), 1800);
} catch { /* cancelled / unsupported — no-op */ }
}
</script>
<svelte:window onkeydown={onKey} />
<svelte:head>
<title>Daily Art · upbeatBytes</title>
<meta name="description" content="A masterwork a day from the world's open museum collections — one piece, beautifully framed, on upbeatBytes." />
</svelte:head>
{#snippet woodRails()}
<span class="rail rail--t"></span>
<span class="rail rail--r"></span>
<span class="rail rail--b"></span>
<span class="rail rail--l"></span>
{/snippet}
<div class="room">
<HubBar active="art" />
<main class="gallery">
<button class="back" onclick={goBack} aria-label="Go back">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 18l-6-6 6-6" />
</svg>
Back
</button>
{#if state === 'ready'}
<!-- "The Story": the guide write-up on the left, the framed piece + controls on the right -->
<article class="story-card">
<div class="left">
<div class="head">
<div class="kicker"><span class="kicker-rule"></span>Daily Art{#if dateLabel} · {dateLabel}{/if}</div>
<h1 class="art-title">{art.title}</h1>
{#if attribution}<p class="attribution">{attribution}</p>{/if}
</div>
<div class="body">
<button class="details-toggle" onclick={() => (detailsOpen = !detailsOpen)}
aria-expanded={detailsOpen} aria-controls="art-details">
About this piece
<svg class="chev" class:open={detailsOpen} viewBox="0 0 24 24" width="14" height="14"
fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<div class="body-content" id="art-details" class:collapsed={!detailsOpen}>
{#if art.blurb}<p class="blurb">{art.blurb}</p>{/if}
<div class="meta-cols">
<div class="meta"><span class="meta-label">Collection</span><span class="meta-val">{art.museum}</span></div>
{#if art.license}<div class="meta"><span class="meta-label">Rights</span><span class="meta-val">{art.license}</span></div>{/if}
</div>
{#if art.source_url}
<a class="cta" href={art.source_url} target="_blank" rel="noopener">View at {art.museum}</a>
{/if}
</div>
</div>
</div>
<div class="right">
<div class="art-stage">
<button class="frame frame--{frame} art-frame" style="--frame-scale:{thickness}"
onclick={() => (zoom = true)} aria-label="Expand artwork">
{#if isWood}{@render woodRails()}{/if}
<span class="mat">
<img src={art.image_url} alt={art.title}
onload={(e) => (landscape = e.currentTarget.naturalWidth > e.currentTarget.naturalHeight)} />
<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>
Expand
</span>
</span>
</button>
</div>
<div class="controls">
<div class="ctl-row">
<span class="ctl-label">Frame</span>
<div class="swatches">
{#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>
</div>
{#if frame !== 'none'}
<div class="ctl-row">
<span class="ctl-label">Thickness</span>
<input type="range" min="0.7" max="1.9" step="0.05"
bind:value={thickness} oninput={saveThickness} aria-label="Frame thickness" />
</div>
{/if}
{#if art.palette?.length}
<div class="ctl-divider"></div>
<div class="ctl-label">Colors in this piece</div>
<div class="palette" aria-hidden="true">
{#each art.palette as c}<span class="chip" style="background:{c}"></span>{/each}
</div>
{/if}
<div class="actions">
<button class="act act-share" onclick={share}>{copied ? 'Link copied' : '↗ Share'}</button>
{#if art.is_public_domain}
<a class="act act-dl" href={art.image_url_large || art.image_url} download={dlName}>↓ Download</a>
{/if}
</div>
</div>
</div>
</article>
{:else if state === 'empty'}
<p class="note">The gallery's resting — a new piece is hung each morning. Check back soon.</p>
{:else}
<p class="note">Hanging today's piece…</p>
{/if}
</main>
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
</div>
{#if zoom && art}
<button class="lightbox" class:rotate={landscape} bind:this={lightboxEl} onclick={() => (zoom = false)} aria-label="Close artwork">
<span class="lb-stage">
<span class="frame frame--{frame} lb-frame" style="--frame-scale:{thickness}">
{#if isWood}{@render woodRails()}{/if}
<span class="mat"><img src={art.image_url_large || art.image_url} alt={art.title} /></span>
</span>
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span>
</span>
</button>
{/if}
<style>
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-italic-var.woff2') format('woff2'); font-weight: 400 500; font-style: italic; font-display: swap; }
@font-face { font-family: 'Space Mono'; src: url('/fonts/space-mono-latin.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
/* --- "The Story": editorial Daily Art page. Daily-Art identity = the purple accent. --- */
.room {
--canvas: #faf6ee; /* warm room ground */
--card: #ece1cc; /* warm-tan story card */
--art-band: #ddcfb2; /* deeper tan behind the framed piece */
--ink: #232a31; /* dark slate — titles */
--story: #4f4a3f; /* warm body text */
--muted: #8a8273;
--label: #4a4334; /* Space Mono micro-labels — darker so they stand out */
--line: #ece5d8;
--accent: #8857C2; /* Daily-Art purple (matches the home tile) */
--accent-deep: #6f42a8;
min-height: 100vh;
background: var(--canvas);
color: var(--ink);
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
overflow-x: clip; /* seatbelt: nothing can scroll the page sideways */
}
/* /art runs wider than the hub's 1180 column — the artwork is the point, and the
mobile view is separate, so the desktop card gets room for a properly-sized piece. */
.gallery {
flex: 1; width: 100%; max-width: 1280px; margin: 0 auto;
padding: clamp(10px, 2vw, 22px) clamp(18px, 5vw, 44px) clamp(28px, 5vw, 48px);
box-sizing: border-box;
display: flex; flex-direction: column; justify-content: center;
}
/* top-left Back */
.back {
align-self: flex-start; display: inline-flex; align-items: center; gap: 6px;
margin: 0 0 clamp(10px, 2vw, 16px); padding: 6px 10px 6px 0;
background: none; border: none; cursor: pointer; font: inherit; font-size: 14px;
font-weight: 600; color: var(--muted); transition: color 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.back:hover { color: var(--accent); }
.back svg { transition: transform 0.15s ease; }
.back:hover svg { transform: translateX(-2px); }
/* The Story card: writeup (left) + framed art & controls (right). On phones the wrappers
collapse (display:contents) so the pieces reflow to head → art → writeup → controls. */
.story-card {
display: flex; border-radius: 22px; overflow: hidden; background: var(--card);
box-shadow: 0 16px 40px -22px rgba(120, 95, 50, 0.32);
}
.left { flex: 1; min-width: 0; padding: clamp(28px, 4vw, 50px) clamp(24px, 3.4vw, 46px); display: flex; flex-direction: column; justify-content: center; }
.head, .body, .body-content { display: flex; flex-direction: column; } /* so .cta align-self works */
.details-toggle { display: none; } /* desktop: the writeup is always shown in the left column */
.chev { transition: transform 0.2s ease; }
.kicker { display: inline-flex; align-items: center; gap: 9px; font-family: 'Space Mono', monospace; font-size: 12px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--accent); }
.kicker-rule { width: 18px; height: 2px; background: var(--accent); border-radius: 2px; }
.art-title { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: clamp(2rem, 4.2vw, 2.9rem); line-height: 1.05; letter-spacing: -0.01em; color: var(--ink); margin: 14px 0 0; }
.attribution { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: clamp(1rem, 1.6vw, 1.13rem); color: #7a7263; margin: 12px 0 0; }
.blurb { font-size: clamp(0.97rem, 1.25vw, 1.02rem); line-height: 1.72; color: var(--story); margin: 20px 0 0; max-width: 44ch; text-wrap: pretty; }
.meta-cols { display: flex; gap: 36px; margin-top: 26px; padding-top: 22px; border-top: 1px solid rgba(120, 95, 50, 0.20); }
.meta { display: flex; flex-direction: column; gap: 4px; }
.meta-label { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--label); }
.meta-val { font-size: 14px; font-weight: 600; color: #3f3a30; }
.cta { align-self: flex-start; margin-top: 26px; background: var(--accent); color: #fff; font-size: 14px; font-weight: 700; padding: 13px 22px; border-radius: 9px; text-decoration: none; transition: background 0.15s ease; -webkit-tap-highlight-color: transparent; }
.cta:hover { background: var(--accent-deep); }
/* right column: the framed piece on a deeper-tan ground, controls beneath */
.right { flex: 0 0 50%; background: var(--art-band); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: clamp(28px, 3vw, 42px) clamp(20px, 2.5vw, 34px); }
.art-stage { display: flex; justify-content: center; width: 100%; }
.art-frame { max-width: 100%; }
.controls { width: 100%; max-width: 440px; margin-top: clamp(24px, 3vw, 32px); }
.ctl-row { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
.ctl-row:first-child { margin-top: 0; }
.ctl-label { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--label); }
.ctl-row > .ctl-label { width: 78px; flex: none; }
.swatches { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.ctl-row input[type="range"] { flex: 1; accent-color: var(--accent); cursor: pointer; }
.ctl-divider { height: 1px; background: rgba(120, 95, 50, 0.18); margin: 24px 0 18px; }
.palette { display: flex; gap: 9px; margin-top: 11px; }
.chip { flex: 1; max-width: 46px; height: 34px; border-radius: 8px; }
.actions { display: flex; gap: 11px; margin-top: 24px; }
.act { flex: 1; text-align: center; font-size: 13.5px; font-weight: 700; padding: 12px 0; border-radius: 9px; cursor: pointer; text-decoration: none; -webkit-tap-highlight-color: transparent; }
.act-share { background: var(--accent); color: #fff; border: none; font-family: inherit; transition: background 0.15s ease; }
.act-share:hover { background: var(--accent-deep); }
.act-dl { border: 1.5px solid #c7b48f; color: var(--story); background: transparent; display: inline-flex; align-items: center; justify-content: center; transition: border-color 0.15s ease, color 0.15s ease; }
.act-dl:hover { border-color: var(--accent); color: var(--accent); }
/* Phones: collapse the wrappers so the four blocks reflow into one calm column —
head → artwork → writeup → controls (the artwork sits high, seen before it's read). */
@media (max-width: 760px) {
.gallery { justify-content: flex-start; }
.story-card { flex-direction: column; }
.left, .right { display: contents; } /* promote head/body/art-stage/controls */
.head { order: 1; padding: clamp(22px, 5vw, 28px) clamp(18px, 5vw, 22px) 14px; }
.art-stage { order: 2; background: var(--art-band); padding: 22px 18px; box-sizing: border-box; }
.body { order: 3; padding: 6px clamp(18px, 5vw, 22px) 4px; }
/* writeup collapses (tap "About this piece") so the controls rise up beside the art */
.details-toggle {
display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%;
background: none; border: none; cursor: pointer; padding: 14px 0;
font-family: 'Space Mono', monospace; font-size: 11px; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--label); -webkit-tap-highlight-color: transparent;
border-top: 1px solid rgba(120, 95, 50, 0.16);
}
.chev.open { transform: rotate(180deg); }
.body-content.collapsed { display: none; }
.controls { order: 4; width: auto; max-width: none; box-sizing: border-box;
margin: 16px clamp(14px, 4vw, 18px) clamp(20px, 5vw, 24px);
background: #fff; border: 1px solid #ece3d0; border-radius: 16px; padding: 18px 16px; }
.meta-cols { gap: 28px; }
.cta { align-self: stretch; text-align: center; }
}
/* The frame: a beveled moulding (wood/metal) around a cream mat around the art.
--rail / --mat are the moulding and mat widths; both scale with --frame-scale (the
thickness slider). EVERY variant — even "No frame" — reserves the same footprint, so
switching frames never reflows the page. */
.frame {
box-sizing: border-box;
/* Rail scales across the whole slider; the mat caps at scale 1.5 so the top of the
slider thickens only the moulding (wood), holding the white border steady. */
--rail: calc(clamp(11px, 2.3vw, 22px) * var(--frame-scale, 1));
--mat: calc(clamp(10px, 2.4vw, 22px) * min(var(--frame-scale, 1), 1.5));
border: none; cursor: zoom-in; background: none;
position: relative; line-height: 0; display: inline-block; max-width: 100%;
border-radius: 4px; padding: var(--rail);
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.20), 0 3px 10px rgba(20, 30, 45, 0.10);
}
/* The three woods share one real (CC0, Poly Haven) fine-grain texture, recolored per
species with a CSS filter, with a soft bevel baked into the texture layer. Metals
stay gradient. The texture sits on ::before (behind the mat); miters on ::after. */
.frame--walnut, .frame--oak, .frame--mahogany {
background: linear-gradient(135deg, #5a3a24, #34210f); /* fallback if texture misses */
}
/* Four real mitered rails: the grain TURNS at every corner — horizontal moulding on the
top/bottom rails (rotated texture), vertical on the sides — like four cut lengths of
wood. Each rail is a trapezoid clipped to 45° at the joints; --rail drives size + cut.
A directional bevel per rail (lit top-left) gives the moulding a rounded profile. */
.rail {
position: absolute; z-index: 0; pointer-events: none;
background-position: center; background-size: cover; background-repeat: no-repeat;
}
.rail--t { top: 0; left: 0; right: 0; height: var(--rail);
clip-path: polygon(0 0, 100% 0, calc(100% - var(--rail)) 100%, var(--rail) 100%);
background-image: linear-gradient(to bottom, rgba(255,255,255,0.22), rgba(0,0,0,0.22)), url("/textures/wood-grain-h.jpg"); }
.rail--b { bottom: 0; left: 0; right: 0; height: var(--rail);
clip-path: polygon(var(--rail) 0, calc(100% - var(--rail)) 0, 100% 100%, 0 100%);
background-image: linear-gradient(to top, rgba(255,255,255,0.05), rgba(0,0,0,0.36)), url("/textures/wood-grain-h.jpg"); }
.rail--l { top: 0; bottom: 0; left: 0; width: var(--rail);
clip-path: polygon(0 0, 100% var(--rail), 100% calc(100% - var(--rail)), 0 100%);
background-image: linear-gradient(to right, rgba(255,255,255,0.20), rgba(0,0,0,0.22)), url("/textures/wood-grain.jpg"); }
.rail--r { top: 0; bottom: 0; right: 0; width: var(--rail);
clip-path: polygon(0 var(--rail), 100% 0, 100% 100%, 0 calc(100% - var(--rail)));
background-image: linear-gradient(to left, rgba(255,255,255,0.05), rgba(0,0,0,0.36)), url("/textures/wood-grain.jpg"); }
/* per-species recolor, now applied to the rails */
.frame--walnut .rail { filter: brightness(1.08) saturate(1.05); }
.frame--oak .rail { filter: brightness(1.72) contrast(0.92) saturate(1.12) sepia(0.22) hue-rotate(-6deg); }
.frame--mahogany .rail { filter: brightness(0.98) contrast(1.06) saturate(1.55) sepia(0.4) hue-rotate(-16deg); }
/* Metals: fine brushed striations over an anisotropic sheen. */
.frame--gold {
background:
repeating-linear-gradient(180deg, rgba(255,255,255,0.08) 0 1px, rgba(120,80,10,0.07) 1px 2px),
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:
repeating-linear-gradient(180deg, rgba(255,255,255,0.11) 0 1px, rgba(40,50,60,0.07) 1px 2px),
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);
}
.frame--black {
background: linear-gradient(135deg, #2a2c30, #15171a 45%, #25282c 60%, #101113);
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.30),
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
inset 0 2px 3px rgba(255, 255, 255, 0.10),
inset 0 -3px 9px rgba(0, 0, 0, 0.60);
}
/* Mitered (45°) corner joints for the woods — each seam runs from the outer corner to
the mat, sized to the rail width so it tracks the thickness slider. */
.frame--walnut::after, .frame--oak::after, .frame--mahogany::after {
content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none; border-radius: inherit;
background:
linear-gradient(45deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) top left / var(--rail) var(--rail) no-repeat,
linear-gradient(135deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) top right / var(--rail) var(--rail) no-repeat,
linear-gradient(135deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) bottom left / var(--rail) var(--rail) no-repeat,
linear-gradient(45deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) bottom right / var(--rail) var(--rail) no-repeat;
}
.mat {
display: block; position: relative; z-index: 1; background: #fbf8f1; border-radius: 1px;
padding: var(--mat);
/* The mat sits recessed below the moulding: a crisp rabbet groove at the lip, the
frame casting shadow onto the top/left of the mat, the bottom/right catching light
— a small chiseled step instead of a flat transition. */
box-shadow:
inset 0 0 0 1px rgba(40, 28, 16, 0.18),
inset 0 6px 9px -3px rgba(0, 0, 0, 0.34),
inset 6px 0 9px -4px rgba(0, 0, 0, 0.24),
inset 0 -3px 6px -2px rgba(255, 255, 255, 0.55),
inset -3px 0 6px -2px rgba(255, 255, 255, 0.40);
}
.frame img { display: block; width: 100%; height: auto; max-height: 72vh; object-fit: contain; border-radius: 1px; }
/* No frame: keep the footprint (so switching never shifts the layout), drop the
moulding + mat, and let the bare art float with a soft drop shadow. */
.frame--none { background: transparent; box-shadow: none; }
.frame--none .mat { background: transparent; box-shadow: none; }
.frame--none img {
border-radius: 6px;
box-shadow: 0 18px 44px rgba(20, 30, 45, 0.20), 0 4px 12px rgba(20, 30, 45, 0.10);
}
/* 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); }
.sep { display: inline-block; margin: 0 0.5em; color: var(--muted); } /* used in the lightbox caption */
/* Frame chips read as little beveled beads; selection is a clean offset ring. */
.swatch {
width: 30px; height: 30px; border-radius: 50%; border: none; cursor: pointer; padding: 0;
box-shadow: inset 0 2px 3px rgba(255, 255, 255, 0.38),
inset 0 -3px 5px rgba(0, 0, 0, 0.30), 0 1px 3px rgba(0, 0, 0, 0.22);
outline: 2.5px solid transparent; outline-offset: 2px;
transition: outline-color 0.15s ease, transform 0.15s ease;
}
.swatch:hover { transform: translateY(-1px); }
.swatch.on { outline-color: var(--accent); }
.swatch--walnut { background: linear-gradient(150deg, #6b4528, #8a5d31 55%, #4f3420); }
.swatch--oak { background: linear-gradient(150deg, #b07f46, #cb9c5b 55%, #8c5d2f); }
.swatch--mahogany { background: linear-gradient(150deg, #5a241a, #7e3826 55%, #3a160e); }
.swatch--gold { background: linear-gradient(150deg, #c79a45, #ecd293 55%, #a9772f); }
.swatch--silver { background: linear-gradient(150deg, #aab1bb, #edf0f3 55%, #98a0ab); }
.swatch--black { background: linear-gradient(150deg, #34373c, #16181b 55%, #0c0d0f); }
.swatch--none { background: linear-gradient(150deg, #ffffff, #ece6da); }
.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 {
text-align: center; color: var(--muted); font-size: 0.84rem;
padding: 28px 16px 36px; border-top: 1px solid var(--line); margin-top: 24px;
}
.lightbox {
position: fixed; inset: 0; z-index: 50; border: none; cursor: zoom-out;
/* A soft, top-lit gallery wall — lighter than the page so every frame (Black
included) reads, like a piece hung on a real wall. */
background: linear-gradient(180deg, #efe9dd, #e2dbcc);
display: flex; flex-direction: column;
align-items: center; justify-content: center; padding: 2.5vmin;
}
/* frame + caption travel together so a rotated view turns as one piece */
.lb-stage { display: flex; flex-direction: column; align-items: center; gap: 12px; }
/* The full-screen view wears the same frame. Rail + mat are only a touch larger than the
page (keeping the ~1:1 wood:white proportion), and the image is capped so the whole
framed piece — including the bottom rail — always fits on screen. */
.lb-frame {
cursor: zoom-out; max-width: 92vw;
--rail: calc(clamp(18px, 1.9vw, 36px) * var(--frame-scale, 1));
--mat: calc(clamp(16px, 1.6vw, 30px) * min(var(--frame-scale, 1), 1.5));
}
.lb-frame.frame--none { --rail: 0px; --mat: 0px; } /* bare art fills the screen */
.lb-frame img { max-width: 86vw; max-height: 66vh; width: auto; height: auto; object-fit: contain; }
.lb-frame.frame--none img { max-width: 96vw; max-height: 88vh; } /* no frame → go big */
.lb-cap { color: #5b636e; font-size: 0.9rem; }
.lb-cap .sep { color: #a6acb4; }
/* On a narrow portrait phone the framed piece (image + mat + rail) overflowed the
viewport — the rail got clipped and the whole thing read as distorted. Pull the
image cap in so the WHOLE frame fits on screen, and leave room for the caption. */
@media (max-width: 640px) {
.lb-frame { max-width: 94vw; }
.lb-frame img { max-width: 72vw; max-height: 60vh; }
.lb-frame.frame--none img { max-width: 92vw; max-height: 78vh; }
}
/* Landscape artwork on a PORTRAIT phone: turn the whole stage 90° so the painting
fills the long axis (turn the phone to view it level; tap anywhere to close). The
image caps are in SWAPPED units — vh drives the on-screen long edge after rotation,
vw the short edge — so it always fits. Desktop and portrait art never hit this. */
@media (max-width: 640px) and (orientation: portrait) {
.lightbox.rotate .lb-stage { transform: rotate(90deg); transform-origin: center; }
.lightbox.rotate .lb-cap { display: none; } /* would sit sideways; the placard's on the page */
/* The image's SHORT edge + the frame's rail/mat map to the phone's narrow width, so
cap it there (max-height, pre-rotation) with a modest fixed frame so the whole
moulding always fits; the long edge (max-width → screen height) stays generous. */
.lightbox.rotate .lb-frame { max-width: 92vh; } /* rail/mat keep scaling with thickness */
/* short edge (→ screen width after rotation) capped low enough that the frame fits even
at max thickness; the long edge stays generous */
.lightbox.rotate .lb-frame img { max-width: 88vh; max-height: 60vw; }
.lightbox.rotate .lb-frame.frame--none img { max-width: 94vh; max-height: 92vw; }
}
</style>