Compare commits
2 Commits
79ecb800af
...
5157c221ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 5157c221ed | |||
| ed814c97b9 |
@@ -41,9 +41,6 @@
|
|||||||
else goto('/home3', { replaceState: true });
|
else goto('/home3', { replaceState: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
let who = $derived(
|
|
||||||
art ? [art.artist || 'Unknown artist', art.date_text].filter(Boolean) : []
|
|
||||||
);
|
|
||||||
// Woods are built from four real mitered rails (grain turns at the corners); metals/none aren't.
|
// 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));
|
let isWood = $derived(['walnut', 'oak', 'mahogany'].includes(frame));
|
||||||
|
|
||||||
@@ -70,6 +67,30 @@
|
|||||||
function saveThickness() {
|
function saveThickness() {
|
||||||
try { localStorage.setItem('ub_art_thickness', String(thickness)); } catch { /* ignore */ }
|
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 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>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={onKey} />
|
<svelte:window onkeydown={onKey} />
|
||||||
@@ -96,15 +117,30 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
<div class="intro">
|
|
||||||
<h1>Daily Art</h1>
|
|
||||||
<p>A masterwork a day, from the world's open collections.</p>
|
|
||||||
</div>
|
|
||||||
<div class="divider" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
{#if state === 'ready'}
|
{#if state === 'ready'}
|
||||||
<figure class="piece">
|
<!-- "The Story": the guide write-up on the left, the framed piece + controls on the right -->
|
||||||
<button class="frame frame--{frame}" style="--frame-scale:{thickness}"
|
<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">
|
||||||
|
{#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 class="right">
|
||||||
|
<div class="art-stage">
|
||||||
|
<button class="frame frame--{frame} art-frame" style="--frame-scale:{thickness}"
|
||||||
onclick={() => (zoom = true)} aria-label="Expand artwork">
|
onclick={() => (zoom = true)} aria-label="Expand artwork">
|
||||||
{#if isWood}{@render woodRails()}{/if}
|
{#if isWood}{@render woodRails()}{/if}
|
||||||
<span class="mat">
|
<span class="mat">
|
||||||
@@ -115,24 +151,16 @@
|
|||||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" />
|
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" />
|
||||||
</svg>
|
</svg>
|
||||||
Click to expand
|
Expand
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<figcaption class="placard">
|
<div class="controls">
|
||||||
<h2 class="title">{art.title}</h2>
|
<div class="ctl-row">
|
||||||
<p class="who">
|
<span class="ctl-label">Frame</span>
|
||||||
{#each who as part, i}{#if i > 0}<span class="sep">·</span>{/if}{part}{/each}
|
<div class="swatches">
|
||||||
</p>
|
|
||||||
{#if art.medium}<p class="medium">{art.medium}</p>{/if}
|
|
||||||
<p class="credit">
|
|
||||||
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}
|
{#each FRAMES as f}
|
||||||
<button class="swatch swatch--{f.id}" class:on={frame === f.id}
|
<button class="swatch swatch--{f.id}" class:on={frame === f.id}
|
||||||
onclick={() => setFrame(f.id)} aria-pressed={frame === f.id} title={f.label}>
|
onclick={() => setFrame(f.id)} aria-pressed={frame === f.id} title={f.label}>
|
||||||
@@ -140,15 +168,32 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{#if frame !== 'none'}
|
{#if frame !== 'none'}
|
||||||
<div class="thickness">
|
<div class="ctl-row">
|
||||||
<span class="frames-label">Thickness</span>
|
<span class="ctl-label">Thickness</span>
|
||||||
<input type="range" min="0.7" max="1.9" step="0.05"
|
<input type="range" min="0.7" max="1.9" step="0.05"
|
||||||
bind:value={thickness} oninput={saveThickness} aria-label="Frame thickness" />
|
bind:value={thickness} oninput={saveThickness} aria-label="Frame thickness" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</figcaption>
|
|
||||||
</figure>
|
{#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'}
|
{:else if state === 'empty'}
|
||||||
<p class="note">The gallery's resting — a new piece is hung each morning. Check back soon.</p>
|
<p class="note">The gallery's resting — a new piece is hung each morning. Check back soon.</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -172,33 +217,41 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* --- look-overhaul testbed: bright, modern, calm. Scoped to /art for now. --- */
|
@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 {
|
.room {
|
||||||
--canvas: #faf6ee; /* warm cream, not brown */
|
--canvas: #faf6ee; /* warm room ground */
|
||||||
--surface: #ffffff;
|
--card: #ece1cc; /* warm-tan story card */
|
||||||
--ink: #232a31; /* dark slate */
|
--art-band: #ddcfb2; /* deeper tan behind the framed piece */
|
||||||
--muted: #707b86;
|
--ink: #232a31; /* dark slate — titles */
|
||||||
|
--story: #4f4a3f; /* warm body text */
|
||||||
|
--muted: #8a8273;
|
||||||
|
--label: #6f6757; /* Space Mono micro-labels */
|
||||||
--line: #ece5d8;
|
--line: #ece5d8;
|
||||||
--accent: #0a93c0; /* upbeatBytes blue, a touch brighter */
|
--accent: #8857C2; /* Daily-Art purple (matches the home tile) */
|
||||||
--accent-deep: #066c8e;
|
--accent-deep: #6f42a8;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--canvas);
|
background: var(--canvas);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gallery {
|
.gallery {
|
||||||
flex: 1; width: 100%; max-width: 1100px; margin: 0 auto;
|
flex: 1; width: 100%; max-width: 1052px; margin: 0 auto;
|
||||||
padding: clamp(6px, 1.5vw, 16px) clamp(20px, 5vw, 56px) clamp(20px, 5vw, 56px);
|
padding: clamp(10px, 2vw, 22px) clamp(18px, 5vw, 44px) clamp(28px, 5vw, 48px);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex; flex-direction: column; align-items: center;
|
display: flex; flex-direction: column; justify-content: center;
|
||||||
}
|
}
|
||||||
/* top-left Back (the gallery centers its children, so pin this one to the left) */
|
/* top-left Back */
|
||||||
.back {
|
.back {
|
||||||
align-self: flex-start; display: inline-flex; align-items: center; gap: 6px;
|
align-self: flex-start; display: inline-flex; align-items: center; gap: 6px;
|
||||||
margin: 0 0 clamp(10px, 2vw, 18px); padding: 6px 10px 6px 0;
|
margin: 0 0 clamp(10px, 2vw, 16px); padding: 6px 10px 6px 0;
|
||||||
background: none; border: none; cursor: pointer; font: inherit; font-size: 14px;
|
background: none; border: none; cursor: pointer; font: inherit; font-size: 14px;
|
||||||
font-weight: 600; color: var(--muted); transition: color 0.15s ease;
|
font-weight: 600; color: var(--muted); transition: color 0.15s ease;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
@@ -207,22 +260,62 @@
|
|||||||
.back svg { transition: transform 0.15s ease; }
|
.back svg { transition: transform 0.15s ease; }
|
||||||
.back:hover svg { transform: translateX(-2px); }
|
.back:hover svg { transform: translateX(-2px); }
|
||||||
|
|
||||||
.intro { text-align: center; margin-bottom: clamp(18px, 3.5vw, 30px); }
|
/* The Story card: writeup (left) + framed art & controls (right). On phones the wrappers
|
||||||
.intro h1 {
|
collapse (display:contents) so the pieces reflow to head → art → writeup → controls. */
|
||||||
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
|
.story-card {
|
||||||
font-size: clamp(2rem, 5vw, 3rem); margin: 0; letter-spacing: -0.01em;
|
display: flex; border-radius: 22px; overflow: hidden; background: var(--card);
|
||||||
|
box-shadow: 0 16px 40px -22px rgba(120, 95, 50, 0.32);
|
||||||
}
|
}
|
||||||
.intro p { color: var(--muted); margin: 10px 0 0; font-size: 1.05rem; }
|
.left { flex: 1; min-width: 0; padding: clamp(28px, 4vw, 50px) clamp(24px, 3.4vw, 46px); display: flex; flex-direction: column; justify-content: center; }
|
||||||
|
.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); }
|
||||||
|
|
||||||
/* A quiet hairline between the title and the artwork, fading at both ends. */
|
/* right column: the framed piece on a deeper-tan ground, controls beneath */
|
||||||
.divider {
|
.right { flex: 0 0 46%; background: var(--art-band); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: clamp(28px, 3.4vw, 44px) clamp(22px, 3vw, 38px); }
|
||||||
width: min(440px, 76%); height: 1px; margin: 0 auto clamp(24px, 4.5vw, 44px);
|
.art-stage { display: flex; justify-content: center; width: 100%; }
|
||||||
background: linear-gradient(90deg, transparent, rgba(112, 123, 134, 0.30) 22%,
|
.art-frame { max-width: 100%; }
|
||||||
rgba(112, 123, 134, 0.30) 78%, transparent);
|
|
||||||
|
.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: 20px clamp(18px, 5vw, 22px) 4px; }
|
||||||
|
.controls { order: 4; max-width: none; 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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.piece { margin: 0; display: flex; flex-direction: column; align-items: center; max-width: 860px; }
|
|
||||||
|
|
||||||
/* The frame: a beveled moulding (wood/metal) around a cream mat around the art.
|
/* 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
|
--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
|
thickness slider). EVERY variant — even "No frame" — reserves the same footprint, so
|
||||||
@@ -344,20 +437,8 @@
|
|||||||
}
|
}
|
||||||
.frame:hover .hint, .frame:focus-visible .hint { background: rgba(20, 26, 33, 0.72); }
|
.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; }
|
.sep { display: inline-block; margin: 0 0.5em; color: var(--muted); } /* used in the lightbox caption */
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.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; flex-wrap: wrap; align-items: center; justify-content: center; gap: 11px; margin-top: 24px; }
|
|
||||||
.frames-label { font-size: 0.74rem; font-weight: 600; color: var(--muted); margin-right: 4px; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
||||||
/* Frame chips read as little beveled beads; selection is a clean offset ring. */
|
/* Frame chips read as little beveled beads; selection is a clean offset ring. */
|
||||||
.swatch {
|
.swatch {
|
||||||
width: 30px; height: 30px; border-radius: 50%; border: none; cursor: pointer; padding: 0;
|
width: 30px; height: 30px; border-radius: 50%; border: none; cursor: pointer; padding: 0;
|
||||||
@@ -377,9 +458,6 @@
|
|||||||
.swatch--none { background: linear-gradient(150deg, #ffffff, #ece6da); }
|
.swatch--none { background: linear-gradient(150deg, #ffffff, #ece6da); }
|
||||||
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
|
||||||
|
|
||||||
.thickness { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: 16px; }
|
|
||||||
.thickness input[type="range"] { width: min(220px, 60vw); accent-color: var(--accent); cursor: pointer; }
|
|
||||||
|
|
||||||
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
|
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
|
||||||
|
|
||||||
.foot {
|
.foot {
|
||||||
|
|||||||
@@ -7,3 +7,5 @@ newsreader-var.woff2 / newsreader-italic-var.woff2 — "Newsreader" by Productio
|
|||||||
License: SIL Open Font License 1.1. All self-hosted (no Google hotlink) for the /home3 design direction.
|
License: SIL Open Font License 1.1. All self-hosted (no Google hotlink) for the /home3 design direction.
|
||||||
|
|
||||||
Work Sans — Wei Huang, OFL 1.1 (Google Fonts). Latin subset, variable 400–700.
|
Work Sans — Wei Huang, OFL 1.1 (Google Fonts). Latin subset, variable 400–700.
|
||||||
|
|
||||||
|
Space Mono — Colophon Foundry, OFL 1.1 (Google Fonts). Latin subset, 400.
|
||||||
|
|||||||
Binary file not shown.
@@ -2294,6 +2294,8 @@ def create_app() -> FastAPI:
|
|||||||
"source": a["source"], "museum": museums.get(a["source"], a["source"]),
|
"source": a["source"], "museum": museums.get(a["source"], a["source"]),
|
||||||
"is_public_domain": bool(a["is_public_domain"]),
|
"is_public_domain": bool(a["is_public_domain"]),
|
||||||
"license": "Public Domain (CC0)" if a["is_public_domain"] else None,
|
"license": "Public Domain (CC0)" if a["is_public_domain"] else None,
|
||||||
|
"blurb": a.get("blurb"),
|
||||||
|
"palette": json.loads(a["palette"]) if a.get("palette") else [],
|
||||||
"image_url": f"/api/art/image/{a['object_id']}",
|
"image_url": f"/api/art/image/{a['object_id']}",
|
||||||
"image_url_large": f"/api/art/image/{a['object_id']}?size=full",
|
"image_url_large": f"/api/art/image/{a['object_id']}?size=full",
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-8
@@ -132,6 +132,69 @@ def _download_image(obj: dict, object_id: int) -> str | None:
|
|||||||
return display
|
return display
|
||||||
|
|
||||||
|
|
||||||
|
def _palette(image_path: "Path", n: int = 5) -> list[str]:
|
||||||
|
"""Extract ~n representative hex colors from the cached image (for the 'colors in this
|
||||||
|
piece' strip). Best-effort: any failure → empty list (the strip just hides)."""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
with Image.open(image_path) as im:
|
||||||
|
im = im.convert("RGB")
|
||||||
|
im.thumbnail((120, 120)) # tiny — palette, not fidelity
|
||||||
|
# Adaptive median-cut to a small palette, then order by how much of the image each covers.
|
||||||
|
q = im.quantize(colors=max(n * 2, 8), method=Image.Quantize.MEDIANCUT)
|
||||||
|
pal = q.getpalette()
|
||||||
|
counts = sorted(q.getcolors(), reverse=True) # [(count, index), ...] most-used first
|
||||||
|
out, seen = [], set()
|
||||||
|
for _count, idx in counts:
|
||||||
|
r, g, b = pal[idx * 3], pal[idx * 3 + 1], pal[idx * 3 + 2]
|
||||||
|
hexc = f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
if hexc in seen:
|
||||||
|
continue
|
||||||
|
seen.add(hexc)
|
||||||
|
out.append(hexc)
|
||||||
|
if len(out) >= n:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
except Exception: # noqa: BLE001 — palette is decorative; never break the pick
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
_BLURB_SYSTEM = (
|
||||||
|
"You are the calm, knowledgeable curator of a daily-art feature for a general audience — "
|
||||||
|
"people who enjoy a beautiful painting but aren't art historians. In 2 to 3 warm, plain "
|
||||||
|
"sentences, help them appreciate the piece and why it's worth a moment: its mood, the "
|
||||||
|
"artist, the era or movement, and a little real context or significance.\n"
|
||||||
|
"GROUNDING (important): the catalogue details below — especially the title and the "
|
||||||
|
"'Depicts' tags — are your only reliable guide to the SUBJECT. You cannot actually see the "
|
||||||
|
"image, so do NOT assert literal visual specifics you can't verify: do not state how many "
|
||||||
|
"figures are shown, their exact poses or actions, colors, or background details. Lean on "
|
||||||
|
"what's certain (title, tags, medium, date, artist, movement) and on feeling/significance. "
|
||||||
|
"If you don't recognize the exact work, stay general and contextual rather than inventing. "
|
||||||
|
"No preamble, no title repetition, no hype, no markdown — just the note."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _blurb(client, obj: dict) -> str | None:
|
||||||
|
"""A short 'museum guide' note for the piece, grounded in the Met catalogue metadata.
|
||||||
|
Best-effort + cached by the caller; returns None on any trouble."""
|
||||||
|
tags = ", ".join(t.get("term", "") for t in (obj.get("tags") or []) if t.get("term"))[:200]
|
||||||
|
facts = "\n".join(f"{k}: {v}" for k, v in (
|
||||||
|
("Title", obj.get("title")), ("Artist", obj.get("artistDisplayName")),
|
||||||
|
("Artist bio", obj.get("artistDisplayBio")), ("Date", obj.get("objectDate")),
|
||||||
|
("Medium", obj.get("medium")), ("Type", obj.get("objectName")),
|
||||||
|
("Classification", obj.get("classification")), ("Culture", obj.get("culture")),
|
||||||
|
("Period", obj.get("period")), ("Depicts", tags),
|
||||||
|
) if v)
|
||||||
|
user = f"Catalogue details:\n{facts}\n\nWrite the note."
|
||||||
|
try:
|
||||||
|
out = client.chat_text([{"role": "system", "content": _BLURB_SYSTEM},
|
||||||
|
{"role": "user", "content": user}]) or ""
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
out = " ".join(out.replace("*", "").replace("_", " ").split()).strip()[:600] # no stray markdown
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
def _candidates(conn: sqlite3.Connection, art_date: str, source: str) -> list[int]:
|
def _candidates(conn: sqlite3.Connection, art_date: str, source: str) -> list[int]:
|
||||||
"""The N least-recently-shown pool IDs, rotated deterministically by the date so the
|
"""The N least-recently-shown pool IDs, rotated deterministically by the date so the
|
||||||
same piece shows for everyone that day and pieces don't repeat soon."""
|
same piece shows for everyone that day and pieces don't repeat soon."""
|
||||||
@@ -148,7 +211,7 @@ def _candidates(conn: sqlite3.Connection, art_date: str, source: str) -> list[in
|
|||||||
|
|
||||||
|
|
||||||
def pick_daily(conn: sqlite3.Connection, art_date: str | None = None, source: str = "met",
|
def pick_daily(conn: sqlite3.Connection, art_date: str | None = None, source: str = "met",
|
||||||
force: bool = False) -> dict | None:
|
force: bool = False, client=None) -> dict | None:
|
||||||
"""Pick + cache the day's art. Idempotent (skips if today's already done unless force).
|
"""Pick + cache the day's art. Idempotent (skips if today's already done unless force).
|
||||||
Tries successive candidates so a bad object/image never breaks the day; returns the
|
Tries successive candidates so a bad object/image never breaks the day; returns the
|
||||||
stored row, or None if nothing could be fetched (caller keeps the prior day's piece)."""
|
stored row, or None if nothing could be fetched (caller keeps the prior day's piece)."""
|
||||||
@@ -166,21 +229,23 @@ def pick_daily(conn: sqlite3.Connection, art_date: str | None = None, source: st
|
|||||||
fname = _download_image(obj, oid)
|
fname = _download_image(obj, oid)
|
||||||
if not fname:
|
if not fname:
|
||||||
continue
|
continue
|
||||||
# All network work is done above; only now do we open a brief write txn + commit.
|
# All network/LLM/compute is done up front; only then a brief write txn + commit.
|
||||||
|
palette = json.dumps(_palette(cache_dir() / fname)) or None
|
||||||
|
blurb = _blurb(client, obj) if client else None
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO daily_art (art_date, source, object_id, title, artist, date_text, medium, "
|
"INSERT INTO daily_art (art_date, source, object_id, title, artist, date_text, medium, "
|
||||||
"department, credit, source_url, image_file, image_url_full, is_public_domain) "
|
"department, credit, source_url, image_file, image_url_full, is_public_domain, blurb, palette) "
|
||||||
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) "
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
|
||||||
"ON CONFLICT(art_date) DO UPDATE SET object_id=excluded.object_id, title=excluded.title, "
|
"ON CONFLICT(art_date) DO UPDATE SET object_id=excluded.object_id, title=excluded.title, "
|
||||||
"artist=excluded.artist, date_text=excluded.date_text, medium=excluded.medium, "
|
"artist=excluded.artist, date_text=excluded.date_text, medium=excluded.medium, "
|
||||||
"department=excluded.department, credit=excluded.credit, source_url=excluded.source_url, "
|
"department=excluded.department, credit=excluded.credit, source_url=excluded.source_url, "
|
||||||
"image_file=excluded.image_file, image_url_full=excluded.image_url_full, "
|
"image_file=excluded.image_file, image_url_full=excluded.image_url_full, "
|
||||||
"is_public_domain=excluded.is_public_domain",
|
"is_public_domain=excluded.is_public_domain, blurb=excluded.blurb, palette=excluded.palette",
|
||||||
(art_date, source, oid, obj.get("title") or "Untitled",
|
(art_date, source, oid, obj.get("title") or "Untitled",
|
||||||
obj.get("artistDisplayName") or None, obj.get("objectDate") or None,
|
obj.get("artistDisplayName") or None, obj.get("objectDate") or None,
|
||||||
obj.get("medium") or None, obj.get("department") or None,
|
obj.get("medium") or None, obj.get("department") or None,
|
||||||
obj.get("creditLine") or None, obj.get("objectURL") or None, fname,
|
obj.get("creditLine") or None, obj.get("objectURL") or None, fname,
|
||||||
obj.get("primaryImage") or None, 1 if obj.get("isPublicDomain") else 0),
|
obj.get("primaryImage") or None, 1 if obj.get("isPublicDomain") else 0, blurb, palette),
|
||||||
)
|
)
|
||||||
conn.execute("UPDATE art_pool SET shown_at=? WHERE source=? AND object_id=?",
|
conn.execute("UPDATE art_pool SET shown_at=? WHERE source=? AND object_id=?",
|
||||||
(art_date, source, oid))
|
(art_date, source, oid))
|
||||||
@@ -199,13 +264,13 @@ def get_today(conn: sqlite3.Connection, art_date: str | None = None) -> dict | N
|
|||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
def run_daily(conn: sqlite3.Connection, source: str = "met") -> dict:
|
def run_daily(conn: sqlite3.Connection, source: str = "met", client=None) -> dict:
|
||||||
"""Cycle entry point: ensure the pool exists, then ensure today has a piece. Bounded
|
"""Cycle entry point: ensure the pool exists, then ensure today has a piece. Bounded
|
||||||
and non-fatal — safe to call every cycle (it no-ops once the day is picked)."""
|
and non-fatal — safe to call every cycle (it no-ops once the day is picked)."""
|
||||||
pool = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
|
pool = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
|
||||||
harvested = None
|
harvested = None
|
||||||
if pool == 0:
|
if pool == 0:
|
||||||
harvested = harvest_pool(conn, source=source)
|
harvested = harvest_pool(conn, source=source)
|
||||||
picked = pick_daily(conn, source=source)
|
picked = pick_daily(conn, source=source, client=client)
|
||||||
return {"pool": conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0],
|
return {"pool": conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0],
|
||||||
"harvested": harvested, "picked_object": picked.get("object_id") if picked else None}
|
"harvested": harvested, "picked_object": picked.get("object_id") if picked else None}
|
||||||
|
|||||||
+2
-2
@@ -319,7 +319,7 @@ def main() -> None:
|
|||||||
if args.harvest:
|
if args.harvest:
|
||||||
h = art.harvest_pool(conn)
|
h = art.harvest_pool(conn)
|
||||||
print(f"art harvest: found={h['found']} added={h['added']} pool={h['pool']} errors={h['errors']}")
|
print(f"art harvest: found={h['found']} added={h['added']} pool={h['pool']} errors={h['errors']}")
|
||||||
picked = art.pick_daily(conn, force=args.force)
|
picked = art.pick_daily(conn, force=args.force, client=LocalModelClient.from_env())
|
||||||
if picked:
|
if picked:
|
||||||
print(f"art pick: {picked['art_date']} -> #{picked['object_id']} "
|
print(f"art pick: {picked['art_date']} -> #{picked['object_id']} "
|
||||||
f"\"{picked['title']}\" — {picked['artist'] or 'Unknown'}")
|
f"\"{picked['title']}\" — {picked['artist'] or 'Unknown'}")
|
||||||
@@ -556,7 +556,7 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
|||||||
# once the day is picked; non-fatal like every other step.
|
# once the day is picked; non-fatal like every other step.
|
||||||
if not args.no_art:
|
if not args.no_art:
|
||||||
try:
|
try:
|
||||||
a = art.run_daily(conn)
|
a = art.run_daily(conn, client=LocalModelClient.from_env()) # client → the guide blurb
|
||||||
print(f"art: pool={a['pool']} picked={a['picked_object']}")
|
print(f"art: pool={a['pool']} picked={a['picked_object']}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
print(f"art: skipped ({exc})")
|
print(f"art: skipped ({exc})")
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ CREATE TABLE IF NOT EXISTS daily_art (
|
|||||||
image_file TEXT, -- our cached (web-large) image
|
image_file TEXT, -- our cached (web-large) image
|
||||||
image_url_full TEXT, -- source full-res URL, for a later richer /art view
|
image_url_full TEXT, -- source full-res URL, for a later richer /art view
|
||||||
is_public_domain INTEGER, -- license marker (CC0/public domain), stored for citizenship
|
is_public_domain INTEGER, -- license marker (CC0/public domain), stored for citizenship
|
||||||
|
blurb TEXT, -- LLM "museum guide" note: what you're looking at (cached)
|
||||||
|
palette TEXT, -- JSON array of hex colors extracted from the image
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -640,6 +642,9 @@ def _migrate(conn: sqlite3.Connection) -> None:
|
|||||||
conn.execute("ALTER TABLE daily_art ADD COLUMN image_url_full TEXT")
|
conn.execute("ALTER TABLE daily_art ADD COLUMN image_url_full TEXT")
|
||||||
if art_cols and "is_public_domain" not in art_cols:
|
if art_cols and "is_public_domain" not in art_cols:
|
||||||
conn.execute("ALTER TABLE daily_art ADD COLUMN is_public_domain INTEGER")
|
conn.execute("ALTER TABLE daily_art ADD COLUMN is_public_domain INTEGER")
|
||||||
|
for column in ("blurb", "palette"): # richer /art page: guide note + extracted colors
|
||||||
|
if art_cols and column not in art_cols:
|
||||||
|
conn.execute(f"ALTER TABLE daily_art ADD COLUMN {column} TEXT")
|
||||||
|
|
||||||
# feedback.read_at (admin inbox read/unread) added later.
|
# feedback.read_at (admin inbox read/unread) added later.
|
||||||
fb_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback)")}
|
fb_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback)")}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""One-off: migrate daily_art (blurb/palette) and backfill the most recent picks in place
|
||||||
|
— re-fetch Met metadata for the guide blurb, extract the palette from the cached image —
|
||||||
|
WITHOUT re-picking (keeps each day's existing piece). Run on the host with LLM env sourced."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from goodnews import art
|
||||||
|
from goodnews.db import connect, init_db
|
||||||
|
from goodnews.llm import LocalModelClient
|
||||||
|
|
||||||
|
conn = connect(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
|
||||||
|
init_db(conn) # idempotent migration: adds blurb/palette
|
||||||
|
client = LocalModelClient.from_env()
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT art_date, object_id, image_file, title FROM daily_art "
|
||||||
|
"WHERE blurb IS NULL OR palette IS NULL ORDER BY art_date DESC LIMIT 8"
|
||||||
|
).fetchall()
|
||||||
|
print(f"rows to backfill: {len(rows)}")
|
||||||
|
for r in rows:
|
||||||
|
img = art.cache_dir() / r["image_file"] if r["image_file"] else None
|
||||||
|
palette = json.dumps(art._palette(img)) if (img and img.exists()) else "[]"
|
||||||
|
blurb = None
|
||||||
|
try:
|
||||||
|
blurb = art._blurb(client, art._object(r["object_id"]))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f" blurb fetch failed for {r['object_id']}: {exc}")
|
||||||
|
conn.execute("UPDATE daily_art SET blurb=COALESCE(?, blurb), palette=? WHERE art_date=?",
|
||||||
|
(blurb, palette, r["art_date"]))
|
||||||
|
conn.commit()
|
||||||
|
print(f"\n{r['art_date']} · #{r['object_id']} · {r['title']}")
|
||||||
|
print(f" palette: {palette}")
|
||||||
|
print(f" blurb: {blurb}")
|
||||||
|
conn.close()
|
||||||
@@ -44,6 +44,58 @@ def test_harvest_dedupes_into_pool(conn):
|
|||||||
assert art.harvest_pool(conn)["added"] == 0 # idempotent
|
assert art.harvest_pool(conn)["added"] == 0 # idempotent
|
||||||
|
|
||||||
|
|
||||||
|
def test_palette_extracts_hex_colors(tmp_path):
|
||||||
|
from PIL import Image
|
||||||
|
p = tmp_path / "img.png"
|
||||||
|
im = Image.new("RGB", (60, 60), (200, 30, 30)) # mostly red...
|
||||||
|
for x in range(60):
|
||||||
|
for y in range(30):
|
||||||
|
im.putpixel((x, y), (30, 150, 70)) # ...top half green
|
||||||
|
im.save(p)
|
||||||
|
cols = art._palette(p, n=3)
|
||||||
|
assert 1 <= len(cols) <= 3
|
||||||
|
assert all(c.startswith("#") and len(c) == 7 for c in cols)
|
||||||
|
|
||||||
|
|
||||||
|
def test_palette_bad_image_is_empty(tmp_path):
|
||||||
|
p = tmp_path / "bad.jpg"
|
||||||
|
p.write_bytes(b"\xff\xd8\xff" + b"x" * 500) # not a decodable image
|
||||||
|
assert art._palette(p) == []
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeClient:
|
||||||
|
def __init__(self, text="A quiet wheat field at dusk."):
|
||||||
|
self.text, self.seen = text, None
|
||||||
|
def chat_text(self, messages):
|
||||||
|
self.seen = messages
|
||||||
|
return self.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_blurb_grounds_in_metadata_and_cleans():
|
||||||
|
c = _FakeClient(" A returning soldier in a golden field. \n")
|
||||||
|
out = art._blurb(c, {"title": "The Veteran", "artistDisplayName": "Homer",
|
||||||
|
"medium": "Oil on canvas", "tags": [{"term": "wheat"}, {"term": "scythe"}]})
|
||||||
|
assert out == "A returning soldier in a golden field."
|
||||||
|
user = c.seen[-1]["content"]
|
||||||
|
assert "Homer" in user and "Oil on canvas" in user and "wheat" in user # catalogue facts fed in
|
||||||
|
|
||||||
|
|
||||||
|
def test_blurb_none_on_error_or_empty():
|
||||||
|
class Bad:
|
||||||
|
def chat_text(self, m): raise RuntimeError("down")
|
||||||
|
assert art._blurb(Bad(), {"title": "X"}) is None
|
||||||
|
assert art._blurb(_FakeClient(" "), {"title": "X"}) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_pick_stores_blurb_and_palette(conn):
|
||||||
|
art.harvest_pool(conn)
|
||||||
|
a = art.pick_daily(conn, art_date="2026-06-21", client=_FakeClient("A quiet masterwork."))
|
||||||
|
assert a["blurb"] == "A quiet masterwork."
|
||||||
|
assert a["palette"] == "[]" # fixture image isn't decodable → empty palette, stored as JSON
|
||||||
|
b = art.pick_daily(conn, art_date="2026-06-22") # no client → no blurb, pick still succeeds
|
||||||
|
assert b["blurb"] is None
|
||||||
|
|
||||||
|
|
||||||
def test_pick_caches_image_metadata_and_marks_shown(conn):
|
def test_pick_caches_image_metadata_and_marks_shown(conn):
|
||||||
art.harvest_pool(conn)
|
art.harvest_pool(conn)
|
||||||
a = art.pick_daily(conn, art_date="2026-06-21")
|
a = art.pick_daily(conn, art_date="2026-06-21")
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ def client(tmp_path, monkeypatch):
|
|||||||
"VALUES ('2026-06-21','met',10154,'Lander''s Peak','Bierstadt','1863','Oil','Paintings',"
|
"VALUES ('2026-06-21','met',10154,'Lander''s Peak','Bierstadt','1863','Oil','Paintings',"
|
||||||
"'Gift','https://met/10154','10154.jpg','https://met/full.jpg',1)"
|
"'Gift','https://met/10154','10154.jpg','https://met/full.jpg',1)"
|
||||||
)
|
)
|
||||||
|
c.execute("UPDATE daily_art SET blurb=?, palette=? WHERE object_id=10154",
|
||||||
|
("A luminous western vista.", '["#7fb4cf", "#c79a3c"]'))
|
||||||
c.commit(); c.close()
|
c.commit(); c.close()
|
||||||
cache.mkdir(parents=True, exist_ok=True)
|
cache.mkdir(parents=True, exist_ok=True)
|
||||||
(cache / "10154.jpg").write_bytes(b"\xff\xd8\xff" + b"x" * 5000) # web-large display copy
|
(cache / "10154.jpg").write_bytes(b"\xff\xd8\xff" + b"x" * 5000) # web-large display copy
|
||||||
@@ -43,3 +45,5 @@ def test_today_exposes_full_res_url(client):
|
|||||||
assert a["image_url"] == "/api/art/image/10154"
|
assert a["image_url"] == "/api/art/image/10154"
|
||||||
assert a["image_url_large"] == "/api/art/image/10154?size=full"
|
assert a["image_url_large"] == "/api/art/image/10154?size=full"
|
||||||
assert a["license"] == "Public Domain (CC0)" and a["museum"] == "The Met"
|
assert a["license"] == "Public Domain (CC0)" and a["museum"] == "The Met"
|
||||||
|
assert a["blurb"] == "A luminous western vista."
|
||||||
|
assert a["palette"] == ["#7fb4cf", "#c79a3c"] # parsed from stored JSON
|
||||||
|
|||||||
Reference in New Issue
Block a user