Redesign frontend with brutal-newspaper aesthetic#47
Conversation
Apply a black-and-white "brutal newspaper" visual treatment across the whole frontend (sidebar masthead + sticky add-feed footer, top bar with serif title and mono subtitle, decimal-leading-zero inbox briefs with preview + source, focused-reading article view with eyebrow / large serif title / outlined action chips / paper body with drop cap and § subheads, brutal auth pages). Adds RTL-aware rules for Farsi feeds and a Farsi fixture for the test server. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Reskins the entire frontend with a black-and-white "brutal newspaper" visual treatment: redesigns the sidebar (top-anchored masthead + pinned add-feed footer), restructures FeedBar with a serif title + mono subtitle, rebuilds ItemRow into a focused-reading article view (eyebrow, drop cap, outlined action chips, § subheads), restyles the auth pages, adds extensive RTL-aware rules, and ships a Farsi RSS fixture in the test server.
Changes:
- Complete CSS rewrite of
main.css,layout.css, andauth.cssaround new design tokens (--paper,--ink, serif/sans/mono variables) loaded from Google Fonts via CDN. - Restructure
Sidebar,ItemList,ItemRow,FeedBar,AddFeedForm,AddFolderButtonto support the new visual treatment, plus a preview + per-item feed-source label on item rows and an empty-state callout. - Add Farsi fixture (
internal/testserver/fixtures/farsi.xml) intended to exercise the RTL CSS path end-to-end, plus minor color tweaks on auth pages.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/web/public/index.html | Switch favicon to mono, add Google Fonts preconnect+stylesheet links. |
| internal/web/public/login.html / setup.html / signup.html | Same favicon + Google Fonts additions. |
| internal/web/public/js/pages/LoginPage.js / SignupPage.js / SetupPage.js | Link/label colors switched from green to black. |
| internal/web/public/js/components/Sidebar.js | Reorganize sidebar: brand masthead on top, scroll region in middle, add-feed pinned at bottom. |
| internal/web/public/js/components/ItemRow.js | New article-view branch when selected (eyebrow, pill, chips); preview text + feed source in compact rows; URL host extraction helper. |
| internal/web/public/js/components/ItemList.js | Pass feedTitle down; new empty-state callout. |
| internal/web/public/js/components/FeedBar.js | Add subtitle line (host / counts), rename "Read All"/"Unread All" → "Mark all read"/"Mark unread". |
| internal/web/public/js/components/AddFolderButton.js / AddFeedForm.js | Reorder DOM to put input first, add placeholders and lead icons; new submit icon logic. |
| internal/web/public/css/main.css | Wholesale rewrite of styles to brutal-newspaper aesthetic, new tokens, RTL rules. |
| internal/web/public/css/layout.css | Update sidebar/content widths, restyle confirm dialog, simplify action visibility rules. |
| internal/web/public/css/auth.css | Auth pages restyled to match new system (duplicates token block from main.css). |
| internal/testserver/fixtures/farsi.xml | New Farsi RSS fixture (not yet wired into MOCK_FEEDS or any test). |
Comments suppressed due to low confidence (4)
internal/web/public/css/main.css:202
#addfeed:not(:has(#urlToAdd:not([style*="display: none"])))and the matching:has(...)selector below are how this stylesheet decides between the closed/full-width state and the open/inline state. Two concerns: (1):has()is not supported in Firefox <121 / older Safari; on those browsers neither rule matches and the layout breaks in both states. (2) The selector strictly inspects the inlinestyle="display: none"string emitted by the Preact template — any future refactor that toggles visibility via a class or a different inline style (e.g.style="display:none"without space) will silently break this UI. A class like.is-opentoggled inAddFeedForm.jswould be much more robust; the same issue applies to#addfolder.open(which already uses a class — that's the convention to follow).
#addfeed:not(:has(#urlToAdd:not([style*="display: none"]))) {
display: block;
}
#addfeed .addfeed-lead {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--ink);
font-size: 12px;
padding-left: 12px;
padding-right: 6px;
}
/* Closed state — full-width black bar */
.add {
display: flex !important;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
float: none !important;
height: 38px;
padding: 0 12px;
background: var(--ink);
color: var(--paper);
border: 1px solid var(--ink);
border-radius: 0;
font-family: var(--font-sans);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.add:hover { background: var(--paper); color: var(--ink); }
/* Open state — input wrapper + small ink "go" button */
#addfeed:has(#urlToAdd:not([style*="display: none"])) {
align-items: center;
height: 38px;
background: var(--paper);
border: 1px solid var(--ink);
box-shadow: 3px 3px 0 0 var(--ink);
padding: 0 4px 0 0;
box-sizing: border-box;
}
#addfeed:has(#urlToAdd:not([style*="display: none"])) .add {
width: 30px;
height: 30px;
padding: 0;
flex: 0 0 30px;
gap: 0;
}
#addfeed:has(#urlToAdd:not([style*="display: none"])) .add span { display: none; }
internal/web/public/css/main.css:788
#items > lideclarescounter-increment: items(line 753), and#items > li.selectedredeclares it again on line 784. The selected rule'scounter-increment: itemsis redundant — it doesn't add a second increment (which would requirecounter-increment: items 2) but it does override and re-state the same value. Worth removing for clarity, since combined with::before { content: none; }on the next line it reads as if selected rows are meant to skip the counter (they don't, which is the intended behavior — but the code obscures that).
#items > li.selected {
background: transparent;
color: var(--ink);
padding: 32px 0 36px;
border-bottom: 1px solid var(--rule);
border-top: 1px solid var(--rule);
margin: 12px auto;
counter-increment: items;
max-width: 760px;
}
#items > li.selected::before { content: none; }
internal/web/public/js/components/ItemRow.js:107
- The eyebrow renders a status pill that says literally "Unread" or "Read" while the design comment elsewhere calls these "briefs". More importantly, when the row is selected the article is being viewed, so showing a static "Read" / "Unread" pill at the top of an opened article is confusing — by the time the user sees this pill, the row is already auto-marking itself read in
markReadIfNew. Consider either dropping the pill on selected rows, or only showing it when the item is still actually unread (item.is_new ? 'Unread' : null).
<div class="lr-article-eyebrow">
<span class="lr-pill lr-pill-status">${item.is_new ? 'Unread' : 'Read'}</span>
${sourceHost && html`<span class="lr-article-meta lr-article-source">${sourceHost}</span>`}
<span class="lr-article-meta lr-article-when" data-testid="item-row-time">${ts}</span>
</div>
internal/web/public/css/auth.css:84
- Both the auth pages and the main app set
box-shadow: 3px/4px/6px ... 0 var(--ink)(pure black) on buttons, focused inputs, and the confirm dialog. Combined with the universal:focus { outline: none; border-color: var(--ink); }on.form-control, this makes keyboard focus visually almost identical to the resting state (a 1px black border vs. a 1px black border + small offset shadow). For users navigating with Tab — including users with low vision or motor impairments — the keyboard focus indicator should be clearly distinguishable. Consider a thicker outline, a contrastingoutline-offset, or keeping a visible:focus-visiblering.
.form-control:focus {
outline: none;
border-color: var(--ink);
box-shadow: 3px 3px 0 0 var(--ink);
}
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"> | ||
| <channel> | ||
| <title>اخبار فارسی</title> | ||
| <link>http://localhost:3001/feeds/farsi</link> | ||
| <description>یک فید آزمایشی به زبان فارسی</description> | ||
| <language>fa</language> | ||
| <lastBuildDate>Mon, 14 Nov 2025 12:00:00 GMT</lastBuildDate> | ||
| <atom:link href="http://localhost:3001/feeds/farsi.xml" rel="self" type="application/rss+xml" /> | ||
|
|
||
| <item> | ||
| <title>استقرار هزاران مامور پلیس در لندن برای مدیریت دو تظاهرات بزرگ</title> | ||
| <link>http://bbc.com/persian/articles/example</link> | ||
| <description>هزاران مامور پلیس در لندن مستقر شدهاند تا دو راهپیمایی بزرگ با گرایشهای مخالف هم را مدیریت کنند.</description> | ||
| <pubDate>Mon, 14 Nov 2025 10:00:00 GMT</pubDate> | ||
| <guid>http://bbc.com/persian/articles/example</guid> | ||
| </item> | ||
|
|
||
| <item> | ||
| <title>کشف یک ساختار باستانی در ایران</title> | ||
| <link>http://bbc.com/persian/articles/example2</link> | ||
| <description>باستانشناسان ساختاری ناشناخته از دوره هخامنشی را در نزدیکی پرسپولیس کشف کردهاند.</description> | ||
| <pubDate>Sun, 13 Nov 2025 14:00:00 GMT</pubDate> | ||
| <guid>http://bbc.com/persian/articles/example2</guid> | ||
| </item> | ||
| </channel> | ||
| </rss> |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | ||
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | ||
| <link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Source+Serif+4:opsz,wght@8..60,300;8..60,400;8..60,500;8..60,600;8..60,700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" /> |
| #urlToAdd { | ||
| float: right; | ||
| border: 1px solid #ccc; | ||
| padding: 6px; | ||
| color: #666; | ||
| width:170px; | ||
| float: left; | ||
| flex: 1; | ||
| border: none; | ||
| outline: none; | ||
| background: transparent; | ||
| padding: 0 8px; | ||
| color: var(--ink); | ||
| font-family: var(--font-mono); | ||
| font-size: 12px; | ||
| letter-spacing: -0.01em; | ||
| width: 100%; | ||
| min-width: 0; | ||
| } |
| <ul id="items" data-testid="item-list"></ul> | ||
| <div class="lr-empty-callout" data-testid="item-list-empty"> | ||
| <div class="lr-callout-eyebrow">— Quiet inbox</div> | ||
| <h2 class="lr-callout-title">No items yet</h2> | ||
| <p class="lr-callout-sub"> | ||
| Add a feed in the sidebar to start collecting briefs. New items will appear here, numbered, in reverse chronological order. | ||
| </p> |
| function stripHtml(s) { | ||
| if (!s) return ''; | ||
| if (typeof DOMParser === 'undefined') return s; | ||
| const doc = new DOMParser().parseFromString(s, 'text/html'); | ||
| return (doc.body && doc.body.textContent || '').replace(/\s+/g, ' ').trim(); | ||
| } | ||
|
|
||
| export function ItemRow({ item, isSelected, onToggle, onChanged, feedTitle }) { | ||
| const body = item.full_content || item.desc || ''; | ||
| const dir = useMemo( | ||
| () => detectDir((item.title || '') + ' ' + body), | ||
| [item.id, item.title, body], | ||
| ); | ||
| const previewText = useMemo(() => { | ||
| const txt = stripHtml(item.desc || ''); | ||
| return txt.length > 220 ? txt.slice(0, 217) + '…' : txt; | ||
| }, [item.id, item.desc]); |
| function sourceHostFromLink(link) { | ||
| if (!link) return ''; | ||
| try { | ||
| return new URL(link).hostname.replace(/^www\./, ''); | ||
| } catch { | ||
| return ''; | ||
| } | ||
| } |
| :root { | ||
| --paper: #FFFFFF; | ||
| --paper-2: #F4F4F4; | ||
| --paper-3: #ECECEC; | ||
| --ink: #000000; | ||
| --ink-2: #111111; | ||
| --ink-muted: #555555; | ||
| --ink-faint: #888888; | ||
| --rule: #000000; | ||
| --rule-soft: #999999; | ||
| --rule-hair: #D8D8D8; | ||
|
|
||
| --font-sans: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; | ||
| --font-serif: "Source Serif 4", "Source Serif Pro", "Iowan Old Style", Georgia, serif; | ||
| --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; | ||
| } |
| #feedbar .folder-rename { | ||
| border: 1px solid var(--ink); | ||
| background: var(--paper); | ||
| padding: 4px 8px; | ||
| font: inherit; | ||
| font-family: var(--font-serif); | ||
| font-size: 24px; | ||
| font-weight: 700; | ||
| color: var(--ink); | ||
| outline: none; | ||
| border-radius: 0; | ||
| } |
| subtitle = `${childFeeds.length} feed${childFeeds.length === 1 ? '' : 's'}${unread ? ` · ${unread} unread` : ''}`; | ||
| } else if (sel.kind === 'unread' || sel.kind === 'starred') { | ||
| const count = (items.value || []).length; | ||
| subtitle = `${count} item${count === 1 ? '' : 's'}`; |
| .lr-article-actions { | ||
| display: flex; | ||
| gap: 0; | ||
| margin-top: 26px; | ||
| padding-top: 16px; | ||
| border-top: 1px solid var(--rule-hair); | ||
| flex-wrap: wrap; | ||
| } | ||
|
|
||
| .error-message { | ||
| color: #a94442; | ||
| background-color: #f2dede; | ||
| border: 1px solid #ebccd1; | ||
| border-radius: 4px; | ||
| padding: 10px; | ||
| margin-bottom: 20px; | ||
| .lr-link-btn, | ||
| .lr-link-btn-ghost { | ||
| display: inline-flex; | ||
| align-items: center; | ||
| gap: 8px; | ||
| height: 34px; | ||
| padding: 0 16px; | ||
| border: 1px solid var(--ink); | ||
| border-radius: 0; | ||
| font-family: var(--font-sans); | ||
| font-size: 10.5px; | ||
| font-weight: 600; | ||
| letter-spacing: 0.14em; | ||
| text-transform: uppercase; | ||
| white-space: nowrap; | ||
| flex-shrink: 0; | ||
| margin-right: -1px; | ||
| cursor: pointer; | ||
| transition: background 0.12s ease, color 0.12s ease; | ||
| text-decoration: none; | ||
| } |
- Extract design tokens to tokens.css; main.css and auth.css consume them - Shared util/url.js for hostFromUrl, used by ItemRow + FeedBar - AddFeedForm uses an .is-open class toggle instead of a brittle :has() inline-style selector; matches AddFolderButton's convention - ItemRow: drop "Read" pill on already-read articles; lightweight regex stripHtml instead of DOMParser for inbox previews - FeedBar: subtitle says "N unread" / "N starred" instead of "N items"; folder-rename input matches title's 32px serif type - ItemList: wrap empty branch in a single container for fragment safety - main.css: drop redundant counter-increment on selected, remove dead float on #urlToAdd, add :last-child margin reset on action chips - auth.css: distinct :focus-visible ring (outline + offset) so keyboard focus is separable from the resting hard-shadow style - Wire farsi.xml into MOCK_FEEDS and add tests/ui/rtl.spec.js covering the RTL CSS path end-to-end (compact .rtl class + ltr/rtl direction on eyebrow / actions / title / body) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks for the thorough review. Addressed in 8e7da2d: Resolved
Deferred (explicitly)
Latest CI:
|
Summary
Test plan
🤖 Generated with Claude Code