WOTD #4/#5 content quality + Editorial Asymmetric /word page (CD)

Content quality ("LLM polishes, dictionary anchors"):
- New wotd._polish: rewrites the real dictionary gloss into ONE warm plain
  sentence + two clear everyday example sentences, grounded in the real
  definition (no invented meanings). Stored in new wotd_pool/daily_wotd columns
  gloss + usage, alongside the raw definition/examples which stay the anchor.
- harvest() polishes each new word; pick_daily() lazily polishes + caches back
  any older pooled word that lacks a gloss (client threaded through run_daily).
- Admin word-add polishes on insert; re-pick passes an LLM client so quote
  meaning / word gloss fill on a forced fresh pick.
- /api/word/today now prefers gloss + usage, falling back to the raw dictionary
  def/examples when polish is absent (so it's always safe).
- db._migrate adds gloss/usage to wotd_pool + daily_wotd (idempotent ALTER).

Frontend — /word redesigned to CD's "Editorial Asymmetric": faded oversized
initial bleeding off the right, vertical part-of-speech rail, big Newsreader
word, airy definition, left-ruled italic example sentences, outline Listen
button + date. (Uses our self-hosted Newsreader/Hanken stack rather than the
mockup's Google fonts; the made-up syllable respelling is omitted since we only
have real IPA.)

Tests: _polish parse/trim/cap, harvest stores gloss/usage, pick lazy-polishes
older words, admin gloss flows through to /api/word/today. 403 backend + 27 fe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-23 06:08:14 -04:00
parent e5f3d942e2
commit cebbed58ab
6 changed files with 196 additions and 65 deletions
+82 -47
View File
@@ -7,6 +7,12 @@
let state = $state('loading'); // loading | ready | empty
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : '');
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
let dateLabel = $derived.by(() => {
if (!w?.date) return '';
const [, m, d] = w.date.split('-').map(Number);
return MONTHS[m - 1] ? `${MONTHS[m - 1]} ${d}` : '';
});
onMount(async () => {
try {
@@ -43,29 +49,41 @@
<HubShell active="">
<article class="word-page">
{#if state === 'ready'}
<p class="eyebrow">Word of the day</p>
<h1 class="word">{cap(w.word)}</h1>
<div class="meta">
{#if w.part_of_speech}<span class="pos">{w.part_of_speech}</span>{/if}
{#if w.phonetic}<span class="pron">{w.phonetic}</span>{/if}
<button class="listen" onclick={speak} aria-label="Hear it spoken">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H3v6h3l5 4z" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /><path d="M18.5 6a8 8 0 0 1 0 12" />
</svg>
Listen
</button>
<!-- "Editorial Asymmetric" (CD): faded oversized initial, vertical part-of-speech rail,
big serif word, airy definition, left-ruled example sentences. -->
<div class="wcard">
<span class="wm" aria-hidden="true">{cap(w.word)[0]}</span>
<div class="wtop">
<span class="eyebrow">Word of the day</span>
{#if dateLabel}<span class="edition">{dateLabel}</span>{/if}
</div>
<div class="whead">
{#if w.part_of_speech}<span class="vpos">{w.part_of_speech}</span>{/if}
<div class="whead-main">
<h1 class="word">{cap(w.word)}</h1>
<div class="pron-row">
{#if w.phonetic}<span class="pron">{w.phonetic}</span>{/if}
<button class="listen" onclick={speak} aria-label="Hear it spoken">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H3v6h3l5 4z" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /><path d="M18.5 6a8 8 0 0 1 0 12" />
</svg>
Listen
</button>
</div>
</div>
</div>
<p class="definition">{w.definition}</p>
{#if w.examples?.length}
<section class="examples">
<div class="ex-label">In a sentence</div>
{#each w.examples as ex}<p class="ex">{ex}</p>{/each}
</section>
{/if}
</div>
<p class="definition">{w.definition}</p>
{#if w.examples?.length}
<section class="examples">
<h2>In a sentence</h2>
<ul>
{#each w.examples as ex}<li>{ex}</li>{/each}
</ul>
</section>
{/if}
{:else if state === 'empty'}
<p class="note">Today's word is on its way. Check back soon.</p>
{:else}
@@ -75,41 +93,58 @@
</HubShell>
<style>
.word-page { max-width: 680px; margin: 0 auto; text-align: center; }
.eyebrow {
font-size: 12px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
color: #4f7da8; margin: clamp(8px, 3vw, 28px) 0 0;
.word-page { max-width: 760px; margin: 0 auto; }
.wcard {
position: relative; overflow: hidden; background: #FBF2E1; border: 1px solid #efe2c6;
border-radius: 18px; padding: clamp(32px, 6vw, 56px) clamp(26px, 5vw, 56px);
box-shadow: 0 18px 50px -34px rgba(80, 60, 25, 0.55);
}
/* faded oversized initial bleeding off the right edge */
.wm {
position: absolute; right: clamp(-60px, -4vw, -28px); top: clamp(8px, 3vw, 40px);
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-weight: 400;
font-size: clamp(220px, 42vw, 380px); line-height: 1; color: rgba(62, 110, 151, 0.06);
pointer-events: none; user-select: none;
}
.wtop { position: relative; display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.eyebrow { font-size: 12px; font-weight: 700; letter-spacing: 0.22em; text-transform: uppercase; color: #3E6E97; }
.edition { font-family: 'Newsreader', Georgia, serif; font-size: 15px; color: #a99a80; white-space: nowrap; }
.whead { position: relative; display: flex; align-items: flex-start; gap: clamp(14px, 2.5vw, 22px); margin-top: clamp(28px, 5vw, 46px); }
.vpos {
writing-mode: vertical-rl; transform: rotate(180deg); flex: none; padding-top: 6px;
font-size: 11px; font-weight: 700; letter-spacing: 0.3em; text-transform: uppercase; color: #7fa0bd;
}
.whead-main { min-width: 0; }
.word {
font-family: 'Newsreader', Georgia, serif; font-weight: 500; letter-spacing: -0.02em;
font-size: clamp(3rem, 9vw, 5.5rem); line-height: 1; margin: 14px 0 0; color: var(--ink);
font-family: 'Newsreader', Georgia, serif; font-weight: 400; letter-spacing: -0.02em;
font-size: clamp(3.4rem, 13vw, 108px); line-height: 0.92; color: #1c1a16; margin: 0; overflow-wrap: anywhere;
}
.meta { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 14px; margin-top: 18px; }
.pos { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 1.1rem; color: #8298ad; }
.pron { font-size: 1.05rem; color: #5f7791; }
.pron-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; margin-top: 18px; }
.pron { font-family: 'Newsreader', Georgia, serif; font-size: 1.15rem; color: #8a8478; }
.listen {
display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
font-family: inherit; font-size: 0.92rem; font-weight: 600; color: #fff;
background: #4f7da8; border: none; border-radius: 999px; padding: 8px 16px;
transition: background 0.15s ease;
display: inline-flex; align-items: center; gap: 7px; cursor: pointer; font-family: inherit;
font-size: 0.82rem; font-weight: 600; letter-spacing: 0.02em; color: #3E6E97;
background: transparent; border: 1px solid #3E6E97; border-radius: 999px; padding: 7px 15px;
transition: background 0.15s ease, color 0.15s ease;
}
.listen:hover { background: #3f6c97; }
.listen:hover { background: #3E6E97; color: #fdf7ec; }
.definition {
font-size: clamp(1.15rem, 2.2vw, 1.45rem); line-height: 1.5; color: #2c3a48;
margin: clamp(26px, 5vw, 44px) 0 0; text-align: left;
position: relative; font-family: 'Newsreader', Georgia, serif; font-weight: 400;
font-size: clamp(1.4rem, 4vw, 1.95rem); line-height: 1.32; color: #2c3640;
margin: clamp(30px, 5vw, 44px) 0 0; max-width: 30em;
}
.examples { margin-top: clamp(28px, 5vw, 44px); text-align: left; border-top: 1px solid #eadfca; padding-top: 24px; }
.examples h2 {
font-size: 12px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--muted); margin: 0 0 14px;
}
.examples ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
.examples li {
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 1.15rem; line-height: 1.45;
color: #4a5560; padding-left: 16px; border-left: 3px solid #d2e1f0;
.examples { position: relative; margin-top: clamp(28px, 5vw, 40px); max-width: 34em; }
.ex-label { font-size: 11px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #b3a487; margin-bottom: 16px; }
.ex {
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 1.08rem; line-height: 1.5;
color: #4a4439; padding-left: 16px; border-left: 2px solid #9bb6cf; margin: 0 0 14px;
}
.ex:last-child { margin-bottom: 0; }
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
</style>