diff --git a/web/src/components/ArticleCard.tsx b/web/src/components/ArticleCard.tsx
index 2c3ffa9..8087677 100644
--- a/web/src/components/ArticleCard.tsx
+++ b/web/src/components/ArticleCard.tsx
@@ -15,9 +15,10 @@ interface ArticleCardProps {
/** Initial-letter cover used when a feed provides no image (no third-party fallback). */
function CoverFallback({ title, feed }: { title: string; feed?: string }) {
+ const initial = (feed || title).trim().charAt(0).toUpperCase();
return (
-
-
{feed || title}
+
+ {initial}
);
}
@@ -25,6 +26,7 @@ function CoverFallback({ title, feed }: { title: string; feed?: string }) {
function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onToggleFavorite }: ArticleCardProps) {
const [offset, setOffset] = useState(0);
const [imgFailed, setImgFailed] = useState(false);
+ const [imgLoaded, setImgLoaded] = useState(false);
// Touch-only swipe: right = read/unread, left = favorite. Mouse drags (text
// selection) never trigger it.
@@ -80,12 +82,15 @@ function ArticleCardImpl({ article, index, isCurrent, onOpen, onToggleRead, onTo
referrerPolicy="no-referrer"
width={640}
height={360}
- className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-[1.03]"
+ className={`w-full h-full object-cover transition-[transform,opacity] duration-700 group-hover:scale-[1.03] ${imgLoaded ? 'opacity-100' : 'opacity-0'}`}
+ onLoad={() => setImgLoaded(true)}
onError={() => setImgFailed(true)}
/>
) : (
)}
+ {/* Light bottom shade: gives the cover depth and lifts the feed chip. */}
+
{article.feed_title || 'Journal'}
diff --git a/web/src/components/TopBar.tsx b/web/src/components/TopBar.tsx
index 1753acd..f328674 100644
--- a/web/src/components/TopBar.tsx
+++ b/web/src/components/TopBar.tsx
@@ -211,7 +211,7 @@ export function TopBar({ onSelectFeed, selectedFeedId, onEnterFocus }: TopBarPro
return (
<>
-
+
FlowReader
diff --git a/web/src/index.css b/web/src/index.css
index ac079d8..a22f643 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -113,6 +113,13 @@
text-wrap: pretty;
}
+/* Article grid: as many ~20rem columns as the width allows. */
+@utility article-grid {
+ display: grid;
+ gap: 1.5rem;
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
+}
+
/* Skip rendering work for off-screen cards in long lists. */
@utility cv-auto {
content-visibility: auto;
diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx
index 1dcb000..26523d3 100644
--- a/web/src/pages/DashboardPage.tsx
+++ b/web/src/pages/DashboardPage.tsx
@@ -224,7 +224,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
return (
-
+
{/* The page title is the feed selector in the top bar. */}
@@ -284,7 +284,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
{/* Grid */}
{isPending ? (
-
+
{Array.from({ length: 6 }, (_, i) => (
@@ -306,7 +306,7 @@ export function DashboardPage({ selectedFeedId, hidden }: DashboardPageProps) {
) : articles.length > 0 ? (
<>
{articles.map((article, i) => (
request.destination === 'image',
- handler: 'StaleWhileRevalidate',
- options: {
- cacheName: 'images',
- expiration: { maxEntries: 400, maxAgeSeconds: 60 * 60 * 24 * 30, purgeOnQuotaError: true },
- cacheableResponse: { statuses: [0, 200] },
- },
- },
+ // No image route: feed images are cross-origin, and a fetch() from
+ // the worker is bound by the CSP's connect-src 'self', so it would
+ // fail every cover. The browser's HTTP cache handles them instead.
{
urlPattern: ({ request }) => request.destination === 'font',
handler: 'CacheFirst',