Skip to content

Redesign frontend with brutal-newspaper aesthetic#47

Merged
cubny merged 2 commits into
masterfrom
redesign/brutal-newspaper
May 16, 2026
Merged

Redesign frontend with brutal-newspaper aesthetic#47
cubny merged 2 commits into
masterfrom
redesign/brutal-newspaper

Conversation

@cubny

@cubny cubny commented May 16, 2026

Copy link
Copy Markdown
Owner

Summary

  • Apply a black-and-white "brutal newspaper" visual treatment across the whole frontend: sidebar masthead + sticky add-feed footer, serif top-bar title with mono subtitle, decimal-leading-zero inbox briefs with preview + source, focused-reading article view (eyebrow / serif title / outlined action chips / paper body with drop cap and § subheads), brutal auth pages.
  • Add RTL-aware rules so Farsi/Arabic articles render correctly (LTR eyebrow + actions, RTL title + body, mirrored drop cap, logical-property list padding, doubled-bullet suppression).
  • Ship a Farsi fixture in the test server so the RTL pathway is exercisable end-to-end.

Test plan

  • make test (Go unit tests)
  • make test-ui (Playwright — 24/24 green)
  • Visual check: login → setup → empty inbox → add feed → list view → expanded article (LTR + RTL feeds) → folders + drag/drop → confirm dialog

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and auth.css around new design tokens (--paper, --ink, serif/sans/mono variables) loaded from Google Fonts via CDN.
  • Restructure Sidebar, ItemList, ItemRow, FeedBar, AddFeedForm, AddFolderButton to 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 inline style="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-open toggled in AddFeedForm.js would 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 > li declares counter-increment: items (line 753), and #items > li.selected redeclares it again on line 784. The selected rule's counter-increment: items is redundant — it doesn't add a second increment (which would require counter-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 contrasting outline-offset, or keeping a visible :focus-visible ring.
.form-control:focus {
    outline: none;
    border-color: var(--ink);
    box-shadow: 3px 3px 0 0 var(--ink);
}

Comment on lines +1 to +27
<?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>
Comment on lines +7 to +9
<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" />
Comment on lines 114 to 127
#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;
}
Comment on lines +87 to +93
<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>
Comment on lines +16 to +32
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]);
Comment on lines +7 to +14
function sourceHostFromLink(link) {
if (!link) return '';
try {
return new URL(link).hostname.replace(/^www\./, '');
} catch {
return '';
}
}
Comment thread internal/web/public/css/auth.css Outdated
Comment on lines +1 to +16
: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;
}
Comment on lines +716 to 727
#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'}`;
Comment on lines +860 to 889
.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>
@cubny

cubny commented May 16, 2026

Copy link
Copy Markdown
Owner Author

Thanks for the thorough review. Addressed in 8e7da2d:

Resolved

  • :has() inline-style fragility (main.css ~202) — AddFeedForm now toggles an .is-open class on #addfeed, matching the #addfolder.open convention. The brittle inline-style selectors are gone.
  • Redundant counter-increment: items on .selected — removed.
  • Eyebrow "Read/Unread" pill — now only shows Unread when item.is_new is true; nothing on already-read articles.
  • Auth focus indicator — added a distinct :focus-visible { outline: 2px solid var(--ink-muted); outline-offset: 3px; } on inputs and the primary button. Resting state and focused state are now visually separable.
  • Duplicate :root token blocks — extracted to a shared css/tokens.css. main.css and auth.css consume it; all four HTML entry points link it.
  • #urlToAdd redundant float/width: 100% — dropped, using flex: 1 1 auto; min-width: 0.
  • .lr-article-actions chip wrap clipping — added :last-child { margin-right: 0; }.
  • stripHtml perf on long feeds — replaced DOMParser with a regex strip + small entity-decode pass. Still memoized via useMemo per-item.
  • sourceHostFromLink / hostFromUrl duplication — extracted to js/util/url.js, imported from both ItemRow.js and FeedBar.js.
  • Subtitle wording for unread/starred — now N unread / N starred instead of the ambiguous N items.
  • Folder-rename input size — bumped to 32px serif so it matches the static title.
  • ItemList empty branch returning two siblings — wrapped both in a single <div class="item-list-wrap"> so the JSX is fragment-safe.
  • Farsi fixture not wired up — added to MOCK_FEEDS and covered by a new tests/ui/rtl.spec.js (2 specs: .rtl class on compact rows; direction: ltr on eyebrow + actions, direction: rtl on title + body). Suite now 26/26 green.

Deferred (explicitly)

  • Google Fonts CDN dependency — fair concern for the self-hosted use case. Tracking as a follow-up: vendor Geist / Source Serif 4 / JetBrains Mono under css/fonts/ with @font-face declarations and stripped CDN links. Out of scope for this PR (which is already a large reskin); planned next.

Latest CI:

  • make test — pass
  • make test-ui — 26/26 pass

@cubny cubny merged commit 921287a into master May 16, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants