Option A: typographic editorial tiles + single treated hero image; balance brief topics
Frontend (the premium baseline): - The hero is now the ONLY image slot. Soft feed images get an atmospheric gradient overlay; no over-reliance on inconsistent RSS image quality. - Every secondary/lane card is a uniform typographic editorial tile: no thumbnails, equal visual weight, a faint topic wordmark watermark, a slim sage top accent, consistent source, reason text as the trust signal, visible Replace with quiet tuning actions. Fixes the jarring mixed-media row rhythm and removes muddy thumbnails entirely. Backend (composition): - _select_diverse now balances topics: no more than 2 of one topic while other topics have candidates (relaxing source then topic caps only to fill), so the daily five stop clustering medical/science items. Candidates now carry s.topic. Tests updated for the topic-balance contract (79 total). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
<script>
|
||||
let { article, onaction, onreplace, hero = false } = $props();
|
||||
|
||||
// Cards can be replaced in place, so derive from the current article prop and
|
||||
// reset the image-failure flag whenever the image URL changes.
|
||||
// Reset image-failure when the article changes (cards swap in place).
|
||||
let failed = $state(false);
|
||||
$effect(() => {
|
||||
void article.image_url;
|
||||
failed = false;
|
||||
});
|
||||
let hasImg = $derived(!!article.image_url && !failed);
|
||||
// The hero is the ONLY image slot; every other card is typographic.
|
||||
let showImage = $derived(hero && hasImg);
|
||||
let safeHref = $derived(
|
||||
typeof article.url === 'string' && /^https?:\/\//.test(article.url) ? article.url : '#'
|
||||
);
|
||||
@@ -18,16 +19,13 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Image-less cards are designed, not missing: secondary cards go text-first
|
||||
(no empty media band), and the hero becomes a fully typographic lead with a
|
||||
faint topic wordmark behind it. We never reserve big empty image space. -->
|
||||
<article
|
||||
class:hero
|
||||
class:textfirst={!hero && !hasImg}
|
||||
class:hero={hero && hasImg}
|
||||
class:herotype={hero && !hasImg}
|
||||
class:tile={!hero}
|
||||
data-topic={article.topic ?? ''}
|
||||
>
|
||||
{#if hasImg}
|
||||
{#if showImage}
|
||||
<a class="media" href={safeHref} target="_blank" rel="noopener">
|
||||
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
|
||||
onerror={() => (failed = true)} />
|
||||
@@ -43,14 +41,14 @@
|
||||
|
||||
<h3><a href={safeHref} target="_blank" rel="noopener">{article.title}</a></h3>
|
||||
|
||||
{#if article.paywalled}
|
||||
<p class="paywall">May need a subscription to read</p>
|
||||
{/if}
|
||||
|
||||
{#if hero && article.description}
|
||||
<p class="desc">{article.description}</p>
|
||||
{/if}
|
||||
|
||||
{#if article.paywalled}
|
||||
<p class="paywall">May need a subscription to read</p>
|
||||
{/if}
|
||||
|
||||
{#if article.reason_text}
|
||||
<p class="why">{article.reason_text}</p>
|
||||
{/if}
|
||||
@@ -79,13 +77,6 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.media {
|
||||
position: relative;
|
||||
display: block;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: linear-gradient(135deg, var(--sage-soft), #f1ece0);
|
||||
}
|
||||
.media img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.body { padding: 16px 18px 14px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||
.tags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 0.74rem; }
|
||||
@@ -108,66 +99,71 @@
|
||||
margin: 2px 0 0; font-style: italic; color: var(--muted);
|
||||
font-size: 0.9rem; padding-left: 12px; border-left: 2px solid var(--sage-soft);
|
||||
}
|
||||
/* Keep card heights even: clamp variable-length text on lane/grid cards. */
|
||||
article:not(.hero):not(.herotype) h3,
|
||||
.why {
|
||||
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
|
||||
-webkit-line-clamp: 3;
|
||||
/* Keep heights even: clamp variable-length text on tiles. */
|
||||
.tile h3, .why {
|
||||
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 3;
|
||||
}
|
||||
.hero .desc, .herotype .desc {
|
||||
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden;
|
||||
-webkit-line-clamp: 6;
|
||||
display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; -webkit-line-clamp: 6;
|
||||
}
|
||||
.actions { margin-top: auto; padding-top: 10px; display: flex; gap: 14px; flex-wrap: wrap; }
|
||||
|
||||
.actions { margin-top: auto; padding-top: 12px; display: flex; gap: 14px; flex-wrap: wrap; }
|
||||
.actions button {
|
||||
background: none; border: none; padding: 0; color: var(--muted);
|
||||
font-size: 0.76rem; border-bottom: 1px dotted var(--line);
|
||||
}
|
||||
.actions button:hover { color: var(--sage-deep); border-bottom-color: var(--sage); }
|
||||
.actions .replace { color: var(--sage-deep); border-bottom-color: var(--sage-soft); }
|
||||
/* Tuning actions stay quiet until hover/focus on pointer devices; the Replace
|
||||
escape hatch stays visible so a paywalled card always shows a way through. */
|
||||
@media (hover: hover) {
|
||||
.actions button:not(.replace) { opacity: 0; transition: opacity 0.16s ease; }
|
||||
article:hover .actions button:not(.replace),
|
||||
article:focus-within .actions button:not(.replace) { opacity: 1; }
|
||||
}
|
||||
|
||||
/* text-first secondary card: a small accent instead of an empty image band */
|
||||
.textfirst .body { padding-top: 18px; }
|
||||
.textfirst .body::before {
|
||||
content: ''; display: block; width: 28px; height: 3px;
|
||||
background: var(--sage-soft); border-radius: 2px; margin-bottom: 4px;
|
||||
/* ---- Typographic editorial tile (every non-hero card) ---- */
|
||||
.tile { position: relative; }
|
||||
.tile .body { padding-top: 18px; }
|
||||
.tile .body::before {
|
||||
content: ''; display: block; width: 30px; height: 3px;
|
||||
background: var(--sage); border-radius: 2px; margin-bottom: 10px; opacity: 0.85;
|
||||
}
|
||||
.tile::after {
|
||||
content: attr(data-topic);
|
||||
position: absolute; right: 10px; bottom: -8px;
|
||||
font-family: var(--serif); font-size: 3.4rem; line-height: 1;
|
||||
color: var(--sage); opacity: 0.05; text-transform: lowercase; letter-spacing: -0.02em;
|
||||
pointer-events: none; user-select: none;
|
||||
}
|
||||
.tile .tags, .tile h3, .tile .why, .tile .actions { position: relative; }
|
||||
|
||||
/* hero WITH image: two columns */
|
||||
/* ---- Hero WITH image: two columns, with an atmospheric overlay ---- */
|
||||
.hero { display: grid; grid-template-columns: 1.1fr 1fr; }
|
||||
.hero .media { aspect-ratio: auto; height: 100%; min-height: 280px; }
|
||||
.hero .body { padding: 28px 30px; justify-content: center; gap: 12px; }
|
||||
.hero .media {
|
||||
position: relative; display: block; height: 100%; min-height: 300px;
|
||||
background: linear-gradient(135deg, var(--sage-soft), #f1ece0);
|
||||
}
|
||||
.hero .media img { width: 100%; height: 100%; object-fit: cover; }
|
||||
/* Soft feed images read as atmospheric, not broken. */
|
||||
.hero .media::after {
|
||||
content: ''; position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(180deg, rgba(33, 40, 31, 0) 55%, rgba(33, 40, 31, 0.16));
|
||||
}
|
||||
.hero .body { padding: 30px 32px; justify-content: center; gap: 12px; }
|
||||
.hero h3 { font-size: 1.95rem; }
|
||||
|
||||
/* hero WITHOUT image: a fully typographic lead with a faint topic wordmark */
|
||||
/* ---- Hero WITHOUT image: fully typographic lead ---- */
|
||||
.herotype {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
position: relative; overflow: hidden;
|
||||
background:
|
||||
radial-gradient(120% 140% at 100% 0%, var(--sage-soft) 0%, transparent 55%),
|
||||
linear-gradient(180deg, var(--surface), var(--surface));
|
||||
var(--surface);
|
||||
}
|
||||
.herotype::after {
|
||||
content: attr(data-topic);
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: -18px;
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(4rem, 12vw, 8rem);
|
||||
line-height: 1;
|
||||
color: var(--sage);
|
||||
opacity: 0.07;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: -0.02em;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
position: absolute; right: 12px; bottom: -18px;
|
||||
font-family: var(--serif); font-size: clamp(4rem, 12vw, 8rem); line-height: 1;
|
||||
color: var(--sage); opacity: 0.07; text-transform: lowercase; letter-spacing: -0.02em;
|
||||
pointer-events: none; user-select: none;
|
||||
}
|
||||
.herotype .body { position: relative; padding: 40px 36px; gap: 12px; }
|
||||
.herotype h3 { font-size: 2.15rem; }
|
||||
|
||||
Reference in New Issue
Block a user