feat(web): sleek Stage-0 reader — design system, card list, reader detail
Implements the Top 5 fixes from the Stage-0 UX review (docs/ux-review/UX-REVIEW.md): 1. Dark mode: full light+dark custom-property palette (bg/fg/muted/line/accent/ card) under :root + @media (prefers-color-scheme: dark), applied to body. color-scheme: light dark is now actually honoured — summary text was invisible on a dark canvas before. 2. Table -> responsive card list: one card per summary (title link, channel·date meta, provider chip, fallback badge, action state). Single-column reflow at 375px, no horizontal crush. 3. Minimal design system: 4/8px spacing scale, one accent, styled accent links (underline-on-hover), real buttons with active/pressed state, consistent radius and dividers — applied across list + detail. 4. Detail page as a reader: prose capped at 38rem, title->meta->summary-> highlights->takeaways hierarchy with section rules, 1.7 line-height. Meta is built from non-empty parts (detailMeta) so the no-video edge case no longer renders a stray "· — ·". 5. Contrast + a11y: muted bumped to #595959 (~7:1, clears WCAG AA), fallback badge gets vertical padding + aria-label/title, friendly first-run empty state, hx-indicator on the filter form. Tests updated for the card markup (table -> cards); missing date is now omitted rather than em-dashed. task check green; HTMX action toggles verified working.
This commit is contained in:
+36
-42
@@ -34,7 +34,7 @@ templ ListPage(rows []store.SummaryRow, f Filter) {
|
||||
@Layout("Tapir — Summaries") {
|
||||
@filterForm(f)
|
||||
<div id="summary-list">
|
||||
@summaryTable(rows)
|
||||
@summaryList(rows)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -47,54 +47,49 @@ templ filterForm(f Filter) {
|
||||
hx-get="/"
|
||||
hx-target="#summary-list"
|
||||
hx-swap="innerHTML"
|
||||
hx-indicator="#filter-indicator"
|
||||
>
|
||||
<label>Channel <input type="text" name="channel" value={ f.Channel } placeholder="any"/></label>
|
||||
<label>From <input type="date" name="from" value={ f.From }/></label>
|
||||
<label>To <input type="date" name="to" value={ f.To }/></label>
|
||||
<button type="submit">Filter</button>
|
||||
<button type="submit" class="btn">Filter</button>
|
||||
<span id="filter-indicator" class="htmx-indicator">filtering…</span>
|
||||
</form>
|
||||
}
|
||||
|
||||
// summaryTable is the swappable list fragment: title · channel · published ·
|
||||
// provider · fallback badge · current action state.
|
||||
templ summaryTable(rows []store.SummaryRow) {
|
||||
<table class="summaries">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Channel</th>
|
||||
<th>Published</th>
|
||||
<th>Provider</th>
|
||||
<th></th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
if len(rows) == 0 {
|
||||
<tr><td colspan="6" class="empty">No summaries.</td></tr>
|
||||
}
|
||||
// summaryList is the swappable list fragment: one card per summary (title link,
|
||||
// channel · date meta, provider chip, fallback badge, action state). Cards
|
||||
// reflow to a single column on mobile; an empty list shows a friendly first-run
|
||||
// state instead of a blank table.
|
||||
templ summaryList(rows []store.SummaryRow) {
|
||||
if len(rows) == 0 {
|
||||
<div class="empty">
|
||||
<strong>No summaries yet</strong>
|
||||
<span>Summaries appear here as your subscriptions are processed — run <code>tapir run</code> to fetch and summarize new videos.</span>
|
||||
</div>
|
||||
} else {
|
||||
<ul class="cards">
|
||||
for _, r := range rows {
|
||||
<tr>
|
||||
<td><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></td>
|
||||
<td>{ r.Channel }</td>
|
||||
<td>{ displayDate(r.PublishedAt) }</td>
|
||||
<td>{ r.AIProvider }</td>
|
||||
<td>
|
||||
<li class="card">
|
||||
<div class="card-title"><a href={ videoURL(r.VideoID) }>{ displayTitle(r) }</a></div>
|
||||
if cardMeta(r) != "" {
|
||||
<div class="card-meta">{ cardMeta(r) }</div>
|
||||
}
|
||||
<div class="card-foot">
|
||||
if r.AIProvider != "" {
|
||||
<span class="chip">{ r.AIProvider }</span>
|
||||
}
|
||||
if r.FallbackUsed {
|
||||
<span class="badge">fallback</span>
|
||||
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
||||
}
|
||||
</td>
|
||||
<td class="state">
|
||||
if len(r.Actions) == 0 {
|
||||
<span class="muted">—</span>
|
||||
} else {
|
||||
{ strings.Join(r.Actions, ", ") }
|
||||
if len(r.Actions) > 0 {
|
||||
<span class="card-state">{ strings.Join(r.Actions, ", ") }</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
// DetailPage is the full summary view: text, highlights, takeaways, metadata,
|
||||
@@ -104,16 +99,15 @@ templ DetailPage(r store.SummaryRow) {
|
||||
<article class="detail">
|
||||
<h1>{ displayTitle(r) }</h1>
|
||||
<p class="meta">
|
||||
{ r.Channel } · { displayDate(r.PublishedAt) } · { r.AIProvider }
|
||||
if r.AIModel != "" {
|
||||
{ "(" + r.AIModel + ")" }
|
||||
if detailMeta(r) != "" {
|
||||
<span>{ detailMeta(r) }</span>
|
||||
}
|
||||
if r.FallbackUsed {
|
||||
<span class="badge">fallback</span>
|
||||
<span class="badge" title="summarized with the fallback model" aria-label="summarized with the fallback model">fallback</span>
|
||||
}
|
||||
</p>
|
||||
if r.URL != "" {
|
||||
<p><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source ↗</a></p>
|
||||
<p class="source"><a href={ externalURL(r.URL) } rel="noopener noreferrer">watch on source ↗</a></p>
|
||||
}
|
||||
@ActionButtons(r.VideoID, actionSet(r.Actions))
|
||||
<section>
|
||||
|
||||
Reference in New Issue
Block a user