89987b8316
- 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>
547 lines
30 KiB
Svelte
547 lines
30 KiB
Svelte
<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.7–1.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>
|