Web dashboard v2 step 2: Tailwind design system, theme toggle, reader pages

Replaces the hand-written stylesheet with a Tailwind CSS v4 source
(src/web/tailwind.css) whose minified build is committed as
src/web/static/app.css and still embedded with include_str!, so the binary
and the test suite never need Node. package.json pins tailwindcss and
@tailwindcss/cli to 4.3.3 and adds css, css:watch and css:check.

Colours are semantic CSS variables swapped per theme and exposed to Tailwind
through @theme inline, so templates write bg-paper/text-ink/border-rule once
and both themes work. theme.js runs synchronously in <head> to set
data-theme before paint; app.js cycles the toggle system -> light -> dark and
persists the choice. Newsreader is self-hosted and served from
web::static_asset with the same ETag/304 logic as the other assets.

Every reader template is restyled: ears row, centred masthead with its rules,
tracked section labels, the drop cap on The Brief, "Why it's here" with its
accent bar, the segmented rating control, prev/next cards, and the colophon
as a two-column definition grid. Dashboard templates are untouched (step 3).

Also fixes a .block collision with Tailwind's display utility, the SVG
`hidden` property that never toggled the theme icon, and points the dev
seed's profile_path at ./dev so previewing cannot rewrite data/profile.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHyYupFdBiR4VfoUM7NjSM
This commit is contained in:
2026-09-03 20:51:08 +00:00
co-authored by Claude Fable 5.1
parent 61ed37fc20
commit 12134f3596
27 changed files with 874 additions and 248 deletions
+8 -7
View File
@@ -1,7 +1,8 @@
{% extends "layout.html" %}{% block content %}<article class="reading issue">
{% if empty %}<h1>No issue yet</h1><p>The first issue has not been published.</p>{% else %}
<p class="dateline">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="stats">{{ issue.stats_line }}</p>
<p class="strap">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p>
{% if !downloads.is_empty() %}<p class="downloads">{% for download in downloads %}<a href="{{ download.href }}">{{ download.label }} ({{ download.size }})</a>{% endfor %}</p>{% endif %}
{% for section in issue.sections %}<section><h2>{{ section.name }}</h2>{% for entry in section.entries %}<article{% if entry.is_lead %} class="lead"{% endif %}><h3><a href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="byline">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }} ({{ entry.domain }}) · {{ entry.reading_minutes }} min · {{ entry.word_count }} words</p>{% if !entry.comment_links.is_empty() %}<p class="comments">{% for link in entry.comment_links %}<a rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</section>{% endfor %}
<p><a href="/issues">Browse the archive</a></p>{% endif %}</article>{% endblock %}
{% extends "layout.html" %}{% block ears %}{% if empty %}<span>Morning edition</span>{% else %}<span>{{ issue.display_date }} <span class="hidden sm:inline">· No. {{ issue.issue_number }}</span></span>{% endif %}{% endblock %}{% block content %}
<article class="mx-auto mt-8 max-w-[68ch] px-4 sm:mt-12 sm:px-6">
{% if empty %}<div class="py-16 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Morning edition</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em]">No issue yet</h1><p class="mt-4 text-ink-2">The first issue has not been published.</p></div>{% else %}
<header class="mb-10 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ issue.stats_line }}</p><p class="mx-auto mt-5 max-w-[58ch] text-lg italic leading-relaxed text-ink-2">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p></header>
{% if !downloads.is_empty() %}<div class="mb-8 flex flex-wrap justify-center gap-2">{% for download in downloads %}<a class="btn" href="{{ download.href }}">{{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
{% for section in issue.sections %}<section class="mt-10"><h2 class="border-t border-rule pt-2 font-sans text-[0.72rem] font-semibold uppercase tracking-[0.12em] text-muted">{{ section.name }}</h2><div>{% for entry in section.entries %}<article class="border-b border-rule py-6{% if entry.is_lead %} pt-5{% endif %}"><h3 class="font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="mt-2 font-sans text-sm leading-relaxed text-muted">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% if !entry.domain.is_empty() %} ({{ entry.domain }}){% endif %} · {{ entry.reading_minutes }} min</p>{# step 1 adds summary/why here #}{% if !entry.comment_links.is_empty() %}<p class="mt-3 flex flex-wrap gap-x-4 gap-y-1 font-sans text-sm text-muted">{% for link in entry.comment_links %}<a class="text-muted hover:text-accent" rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</div></section>{% endfor %}
<p class="mt-10 text-center font-sans text-sm"><a href="/issues">Browse the archive →</a></p>
{% endif %}</article>{% endblock %}