Hero emotional-safety guardrail + calmer card polish

Hero guardrail (core to the promise, not cosmetic):
- New hero.py: the lead story is chosen with a stricter filter than the rest of
  the brief — very low cortisol/ragebait and no grief/medical/violence terms
  (cancer, glioblastoma, death, diagnosis, ...). Such constructive-but-charged
  stories stay among the five; they just never lead by default.
- /api/brief applies user avoid-terms FIRST, then lead_with_gentle, so personal
  boundaries always take precedence over the general guardrail.
- Verified live: the brief no longer leads with a glioblastoma story.

Card polish (per review):
- Secondary cards with no real image are now text-first (no row of empty media
  bands); hero still always shows media or a typographic fallback.
- Inline tuning actions are quiet until hover/focus on pointer devices, and stay
  visible (softer) on touch — less interface machinery.

Tests: hero safety + lead reordering (70 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-05-30 22:44:00 +00:00
parent 14842127da
commit 15d51fb8fd
5 changed files with 137 additions and 11 deletions
+30 -10
View File
@@ -15,15 +15,20 @@
}
</script>
<article class:hero>
<a class="media" href={safeHref} target="_blank" rel="noopener">
{#if article.image_url && imgOk}
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
onerror={() => (imgOk = false)} />
{:else}
<span class="fallback">{article.topic ?? 'good news'}</span>
{/if}
</a>
<article class:hero class:textfirst={!hero && !(article.image_url && imgOk)}>
<!-- Hero always shows media (image or fallback). Secondary cards only show a
media band when a real image exists, so image-less cards are text-first
rather than a row of empty blocks. -->
{#if hero || (article.image_url && imgOk)}
<a class="media" href={safeHref} target="_blank" rel="noopener">
{#if article.image_url && imgOk}
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
onerror={() => (imgOk = false)} />
{:else}
<span class="fallback">{article.topic ?? 'good news'}</span>
{/if}
</a>
{/if}
<div class="body">
<div class="tags">
@@ -95,9 +100,24 @@
.actions { margin-top: auto; padding-top: 10px; display: flex; gap: 14px; flex-wrap: wrap; }
.actions button {
background: none; border: none; padding: 0; color: var(--muted);
font-size: 0.78rem; border-bottom: 1px dotted var(--line);
font-size: 0.76rem; border-bottom: 1px dotted var(--line);
}
.actions button:hover { color: var(--sage-deep); border-bottom-color: var(--sage); }
/* On pointer devices, keep the tuning controls quiet until the card is hovered
or focused — they shouldn't read as interface machinery. Touch devices (no
hover) keep them visible. */
@media (hover: hover) {
.actions { opacity: 0; transition: opacity 0.16s ease; }
article:hover .actions,
article:focus-within .actions { opacity: 1; }
}
/* Text-first secondary cards (no real image): a little breathing room up top. */
.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;
}
.hero { display: grid; grid-template-columns: 1.1fr 1fr; }
.hero .media { aspect-ratio: auto; height: 100%; min-height: 280px; }