Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/viewer-html.ts

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions tests/e2e/hero-image.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// ABOUTME: Regression test for the article hero image surviving image dedup.
// ABOUTME: The dedup pass pre-seeds the hero URL; it must skip the hero itself.

import { test, expect } from '@playwright/test';

// Article with a frontmatter image AND the same image repeated in the body —
// the exact shape produced by "Fetch full content" adding an og:image.
const ARTICLE = `---
title: "Hero Dedup Regression"
url: https://example.com/hero-test
bookmarked: 2026-08-12T10:00:00.000Z
domain: example.com
image: https://example.com/lead.jpg?w=1200
source: extracted
---

![Lead](https://example.com/lead.jpg?w=800 "Lead")

Some body text that follows the lead image.

![Other](https://example.com/other.jpg)
`;

test('hero image survives dedup; body duplicate is removed', async ({ page }) => {
await page.goto('/');
await page.waitForFunction(() => typeof (window as any).renderArticle === 'function');

await page.evaluate((text) => {
(window as any).renderArticle(text, 'hero-test.md');
}, ARTICLE);

// The hero must exist and still contain its image (it was previously
// removed as a "duplicate" of its own pre-seeded URL).
const heroImg = page.locator('.article-hero img');
await expect(heroImg).toHaveCount(1);
await expect(heroImg).toHaveAttribute('src', /lead\.jpg/);

// The body copy of the same image (same path, different params) is deduped…
const bodyLeads = page.locator('#content .content-wrap img[src*="lead.jpg"]');
await expect(bodyLeads).toHaveCount(0);

// …while distinct body images are untouched.
const otherImg = page.locator('#content img[src*="other.jpg"]');
await expect(otherImg).toHaveCount(1);
});

test('launch highlights Explore in the sidebar nav (#110)', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.sidebar-nav-item');
const active = page.locator('.sidebar-nav-item.active');
await expect(active).toHaveCount(1);
await expect(active).toHaveAttribute('data-nav', 'explore');
});
4 changes: 2 additions & 2 deletions viewer.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,8 @@
</div>
<div class="pinned-filters" id="pinned-filters"></div>
<nav class="sidebar-nav" id="sidebar-nav">
<button class="sidebar-nav-item" data-nav="explore" onclick="sidebarNavFilter('explore')"><svg class="icon icon-sm" aria-hidden="true"><use href="#i-home"/></svg><span class="sidebar-nav-label">Explore</span></button>
<button class="sidebar-nav-item active" data-nav="all" onclick="sidebarNavFilter('all')"><svg class="icon icon-sm" aria-hidden="true"><use href="#i-inbox"/></svg><span class="sidebar-nav-label">All Items</span><span class="sidebar-nav-count" id="nav-count-all"></span></button>
<button class="sidebar-nav-item active" data-nav="explore" onclick="sidebarNavFilter('explore')"><svg class="icon icon-sm" aria-hidden="true"><use href="#i-home"/></svg><span class="sidebar-nav-label">Explore</span></button>
<button class="sidebar-nav-item" data-nav="all" onclick="sidebarNavFilter('all')"><svg class="icon icon-sm" aria-hidden="true"><use href="#i-inbox"/></svg><span class="sidebar-nav-label">All Items</span><span class="sidebar-nav-count" id="nav-count-all"></span></button>
<button class="sidebar-nav-item" data-nav="sources" onclick="sidebarNavFilter('sources')"><svg class="icon icon-sm" aria-hidden="true"><use href="#i-squares"/></svg><span class="sidebar-nav-label">Sources</span><span class="sidebar-nav-count" id="nav-count-sources"></span><svg class="icon icon-xs sidebar-nav-chevron" aria-hidden="true"><use href="#i-chevron-right"/></svg></button>
<button class="sidebar-nav-item" data-nav="starred" onclick="sidebarNavFilter('starred')"><svg class="icon icon-sm" aria-hidden="true"><use href="#i-heart"/></svg><span class="sidebar-nav-label">Starred</span><span class="sidebar-nav-count" id="nav-count-starred"></span></button>
<button class="sidebar-nav-item" data-nav="notebook" onclick="sidebarNavFilter('notebook')"><svg class="icon icon-sm" aria-hidden="true"><use href="#i-pen"/></svg><span class="sidebar-nav-label">Notebook</span></button>
Expand Down
5 changes: 5 additions & 0 deletions viewer/04-article.js
Original file line number Diff line number Diff line change
Expand Up @@ -1094,6 +1094,11 @@ function renderArticle(text, filename) {
try { seen.add(new URL(meta.thumbnail).origin + new URL(meta.thumbnail).pathname); } catch { seen.add(meta.thumbnail); }
}
for (const img of imgs) {
// Never dedupe the hero itself — its URL is pre-seeded into `seen`, so
// without this guard the hero is removed as a duplicate of itself and
// the article loses its top image (most visibly after a re-fetch adds
// an og:image to the frontmatter).
if (img.closest('.article-hero')) continue;
const src = img.getAttribute('src');
if (!src) continue;
// Normalize: strip query params for dedup comparison
Expand Down
7 changes: 6 additions & 1 deletion viewer/13-init.js
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,13 @@ async function init() {
ttsProvider = cfg.provider || 'browser';
}).catch(function() {});

// Show dashboard instead of auto-loading first article
// Show dashboard instead of auto-loading first article. The dashboard
// IS the Explore view, so keep the nav highlight in sync (#110) — the
// static HTML default can be overridden by stale DOM state on reopen.
renderHub();
document.querySelectorAll('.sidebar-nav-item').forEach(function(item) {
item.classList.toggle('active', item.dataset.nav === 'explore');
});
showOnboardingIfNeeded();
// Seed the change tracker so first poll doesn't false-trigger
fetch('/api/files-changed').then(function(r) { return r.ok ? r.json() : null; }).then(function(d) { if (d) _lastKnownChangeAt = d.changedAt; }).catch(function() {});
Expand Down
Loading