Nº 056 of 520
PWA news reader
A paper-toned news front with serif headlines: scrolling section chips, a lead card with a CSS-illustrated landscape, a story list with reading times and saved-for-offline marks, and a bottom nav.
Preview
Use it
Free to use · credit appreciated1 Once, in your project
npx skills add SusanAcharya/Design-Lounge 2 Then ask
Using the Design Lounge skill, build "PWA news reader" (piece pwa-news-reader) in my stack. Match its brief; restyle it onto my product's theme.
No skill? Paste the brief instead. It carries the numbers, tokens and behaviour.
<!-- Design Lounge Nº 056 · "PWA news reader" · www.designlounge.live -->
# PWA news reader
> **Build brief for a coding agent.** Rebuild this piece in the reader's stack. If they haven't said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don't "improve" them.
## What it is
The front page of "Meridian", an installed news PWA. Cream paper, a 30px Newsreader masthead, a horizontally scrolling row of section chips, a lead card whose image area is a CSS-drawn landscape (sky gradient, sun disc, two hills), and a hairline-separated list of six stories with 17px serif headlines, reading time and a green "saved" mark for anything cached for offline. Chips filter the list with a 280ms dip; bookmark buttons toggle the saved state and update the badge on the Saved tab. The detail worth copying is the offline affordance: saved stories say so inline in the meta line, in the same colour as the bookmark, not with a separate icon column.
## Reference behaviour
1. Initial state: "Top stories" chip pressed; lead card (Climate) with "saved"; six stories, two of them saved; Saved tab badge reads 3 (lead + 2).
2. Swipe the chip row: it scrolls horizontally with the scrollbar hidden and proximity snap on each chip.
3. Tap a chip (e.g. "Technology"): the previously pressed chip returns to outline; the tapped one fills `--ink` with paper text (160ms). The list dips to opacity 0 / 6px down over 280ms. At 280ms, stories not in that section are hidden, the section label changes to "Technology · 1 story", and the list returns.
4. Tap "Top stories" to show all six again; label returns to "Latest".
5. Tap a story's bookmark (40px round button, right column): `aria-pressed` flips; the icon fills `--saved` green; a "· saved" fragment with a download-check icon is appended to (or removed from) the story's meta line; the Saved badge recounts.
6. Tap a bottom-nav item: it becomes `aria-current="page"` (ink colour); others stay `--ink-3`. Only visual in this piece.
7. The lead card is a single link; tapping it would open the article (out of scope).
## Structure
```
390 × 844
┌────────────────────────────────────┐
│ (54px clearance) │
│ Meridian Tuesday 29 Sep · … │ masthead serif 30
│ (Top stories)(World)(Climate)(Tec… │ chips 36px, horizontal scroll
│ ╭────────────────────────────────╮ │
│ │ sky gradient · sun · hills │ │ art 190px, CSS only
│ │────────────────────────────────│ │
│ │ CLIMATE │ │ kicker 11px red
│ │ The valley that moved its │ │ h2 serif 26px
│ │ river back │ │
│ │ Forty years after it was… │ │ dek 14px
│ │ Ines Kalder · 9 min read · ⇩saved │
│ ╰────────────────────────────────╯ │
│ LATEST │
│ ───────────────────────────────── │
│ [thumb] Nord coalition talks… ▮ │ story: 72px thumb, serif 17px, bookmark
… 192 more lines. Copy or download for the full brief. Loading the source…Brief
232 linesBuild brief for a coding agent. Rebuild this piece in the reader’s stack. If they haven’t said which stack, ask once, then default to semantic HTML + CSS + a little vanilla JS. Match the numbers below; don’t “improve” them.
What it is
The front page of “Meridian”, an installed news PWA. Cream paper, a 30px Newsreader masthead, a horizontally scrolling row of section chips, a lead card whose image area is a CSS-drawn landscape (sky gradient, sun disc, two hills), and a hairline-separated list of six stories with 17px serif headlines, reading time and a green “saved” mark for anything cached for offline. Chips filter the list with a 280ms dip; bookmark buttons toggle the saved state and update the badge on the Saved tab. The detail worth copying is the offline affordance: saved stories say so inline in the meta line, in the same colour as the bookmark, not with a separate icon column.
Reference behaviour
- Initial state: “Top stories” chip pressed; lead card (Climate) with “saved”; six stories, two of them saved; Saved tab badge reads 3 (lead + 2).
- Swipe the chip row: it scrolls horizontally with the scrollbar hidden and proximity snap on each chip.
- Tap a chip (e.g. “Technology”): the previously pressed chip returns to outline; the tapped one fills
--inkwith paper text (160ms). The list dips to opacity 0 / 6px down over 280ms. At 280ms, stories not in that section are hidden, the section label changes to “Technology · 1 story”, and the list returns. - Tap “Top stories” to show all six again; label returns to “Latest”.
- Tap a story’s bookmark (40px round button, right column):
aria-pressedflips; the icon fills--savedgreen; a ”· saved” fragment with a download-check icon is appended to (or removed from) the story’s meta line; the Saved badge recounts. - Tap a bottom-nav item: it becomes
aria-current="page"(ink colour); others stay--ink-3. Only visual in this piece. - The lead card is a single link; tapping it would open the article (out of scope).
Structure
390 × 844
┌────────────────────────────────────┐
│ (54px clearance) │
│ Meridian Tuesday 29 Sep · … │ masthead serif 30
│ (Top stories)(World)(Climate)(Tec… │ chips 36px, horizontal scroll
│ ╭────────────────────────────────╮ │
│ │ sky gradient · sun · hills │ │ art 190px, CSS only
│ │────────────────────────────────│ │
│ │ CLIMATE │ │ kicker 11px red
│ │ The valley that moved its │ │ h2 serif 26px
│ │ river back │ │
│ │ Forty years after it was… │ │ dek 14px
│ │ Ines Kalder · 9 min read · ⇩saved │
│ ╰────────────────────────────────╯ │
│ LATEST │
│ ───────────────────────────────── │
│ [thumb] Nord coalition talks… ▮ │ story: 72px thumb, serif 17px, bookmark
│ World · 4 min · ⇩saved │
│ ───────────────────────────────── │
│ [thumb] Orbital's tiny chip… ▯ │
│ Technology · 6 min │
│ ───────────────────────────────── │
│ ... (4 more) │
│ ▤ Today ≡ Sections ▮ Saved³ ○ Me │ nav 64px
│ (34px clearance) │
└────────────────────────────────────┘
<main>— absolute,inset: 0 0 98px, scrolls;padding-top:54px.<header class="mast">—<h1>+ date span..chips(role="group" aria-label="Sections") — six<button aria-pressed data-cat>.<a class="lead">—.art(with one<i>for the second hill) +.txt(kicker,<h2>, dek,.meta).<p class="sec">section label.<ul class="list">of<li class="story" data-cat>:.thumb(three custom properties--c1/--c2/--c3),.body(<h3>,.meta),<button class="save" aria-pressed>.
<nav class="nav" aria-label="Primary">— four buttons; the Saved one holds a.nbadge.
Sample content:
- Masthead: “Meridian” / “Tuesday 29 Sep · 08:14”. Chips: Top stories (
all), World, Climate, Technology, Culture, Sport. - Lead: kicker “Climate”; headline “The valley that moved its river back”; dek “Forty years after it was straightened, the Tessel is being allowed to meander again — and the floods have stopped.”; meta “Ines Kalder · 9 min read · saved”.
| Story | Section | Read | Saved | Thumb --c1/--c2/--c3 |
|---|---|---|---|---|
| Nord coalition talks stall over fisheries quota | World | 4 min | yes | #D9DFE6 / #7B8EA3 / #3F5468 |
| Orbital’s tiny chip promises a week of battery — with caveats | Technology | 6 min | no | #E6DFD2 / #B89D6C / #A83A2A |
| Marrow Theatre reopens with a play performed in the dark | Culture | 5 min | yes | #E9D6D2 / #C0716A / #5C2B26 |
| Halden win the derby on a goal nobody saw coming | Sport | 3 min | no | #D6E4D7 / #5F9A6A / #F0C33C |
| Why September’s heat felt different this year | Climate | 7 min | no | #DFE6D3 / #8A9A6A / #E2A04A |
| Fjord Bank to stop cash handling at 40 branches | World | 4 min | no | #E5DCD0 / #A58A68 / #2F6B4F |
Thumb drawing: --c1 fill; ::before a --c2 half-ellipse rising from the bottom (inset −20% sides, −30% bottom, 70% tall); ::after a 16px --c3 disc at top-right (12px inset).
Tokens
:root {
/* paper neutrals */
--paper: #faf6ee;
--paper-2: #f1ebdf; /* lead card */
--line: #e3dccd; /* hairlines, chip borders */
--ink: #1c1a17;
--ink-2: #5e5850; /* dek, chip text */
--ink-3: #948c80; /* meta, inactive nav, bookmark idle */
/* accents */
--red: #a83a2a; /* kicker, badge, focus */
--red-soft: #f3ddd6;
--saved: #2f6b4f; /* saved mark, filled bookmark */
/* lead illustration */
--sky: #e9d9c0;
--sky-2: #f4e4cd;
--sun: #e2a04a;
--hill: #8a9a6a;
--hill-2: #5e6f48;
/* type */
--serif: "Newsreader", Georgia, serif; /* opsz 6..72, wght 500/600 */
--sans: "Inter", system-ui, sans-serif;
--fs-mast: 30px; --fs-lead: 26px; --fs-story: 17px; --fs-dek: 14px; --fs-chip: 13px; --fs-meta: 12px; --fs-kicker: 11px; --fs-nav: 11px;
/* layout */
--nav-h: 64px;
--bottom-clear: 34px;
--gutter: 20px;
--art-h: 190px;
--thumb: 72px;
--r-card: 18px;
--r-thumb: 10px;
--r-chip: 999px;
/* motion */
--t-micro: 160ms;
--t-swap: 280ms;
--ease-std: cubic-bezier(.2, .7, .2, 1);
--ease-out: cubic-bezier(.16, 1, .3, 1);
}
Typography
| Role | Family | Size | Weight | Line-height | Tracking | Case |
|---|---|---|---|---|---|---|
| Masthead | Newsreader | 30px | 600 | 1 | −0.01em | sentence |
| Lead headline | Newsreader | 26px | 600 | 1.15 | −0.01em | sentence |
| Story headline | Newsreader | 17px | 600 | 1.25 | 0 | sentence |
| Dek | Inter | 14px | 400 | 1.45 | 0 | sentence |
| Kicker / section label | Inter | 11px | 600 | 1.3 | +0.08em | UPPERCASE |
| Chip | Inter | 13px | 500 | 1 | 0 | sentence |
| Meta line | Inter | 12px | 500 | 1.4 | 0 | sentence |
| Date | Inter | 12px | 500 | 1.4 | 0 | sentence |
| Nav label | Inter | 11px | 500 | 1 | 0 | sentence |
| Badge | Inter | 10px | 600 | 16px | 0 | numerals |
Set the serif with font-optical-sizing:auto so 17px and 26–30px get different optical sizes.
Motion
| Element | Trigger | Property | From → To | Duration | Easing | Notes |
|---|---|---|---|---|---|---|
.chips button | select | background, color, border-color | outline → ink fill | 160ms | linear | |
.list.swap .story | chip select | opacity, translateY | 1, 0 → 0, 6px | 280ms | --ease-std / --ease-out | reversed after the filter |
.save | toggle | color, fill | ink-3 → saved | 0 | — | instant |
.chips | swipe | scroll-snap | — | native | — | scroll-snap-type:x proximity |
| nav item | select | color | ink-3 → ink | 0 | — | instant |
Reduced motion: the list swap dip is removed (opacity:1; transform:none), the filter still applies after 280ms; all transitions 1ms.
States
- Chip pressed:
aria-pressed="true", fill--ink, text--paper, border--ink. - Chip idle: transparent, 1px
--lineborder, text--ink-2. - Story saved: bookmark
aria-pressed="true", icon filled--saved; meta gains ”· saved” with a 14px download-check icon in--saved. - Story hidden (filtered):
hiddenattribute →display:none. - Nav current:
aria-current="page", colour--ink. - Focus-visible: 3px
--redoutline, 2px offset on chips, bookmarks, nav and the lead link. - Empty section: not reachable with the sample data; if a section has zero stories, show the label “Technology · 0 stories” and leave the list empty.
Accessibility
- Chips:
role="group" aria-label="Sections"witharia-pressedbuttons (toggle semantics, not tabs, because the list below is filtered rather than replaced). - Bookmarks:
<button aria-pressed aria-label="Save for offline">; the visible “saved” text in the meta line duplicates the state for sighted users. - The lead card is one
<a>with heading, dek and meta inside, so its accessible name is the headline (putaria-labelledbyon it pointing at the<h2>if your framework flattens link names). - Thumbnails and the landscape are
aria-hiddendecorative CSS. - Badge count is inside the “Saved” button so its name reads “Saved 3”.
- Contrast:
--ink-2on--paper7.0:1;--ink-3on--paper3.6:1 — used only for 11–12px meta at weight 500, so bump to#7d756a(4.6:1) if AA on meta is required;--redon--paper5.9:1;--savedon--paper5.7:1. - Hit targets: chips 36px tall with 8px gaps (allow 44px row), bookmarks 40px, nav items ≥ 88×52.
Responsive rules
- 390 wide: as specified.
- 360 wide: gutter 16px; lead headline 24px; thumbs 64px.
- ≥ 600 wide: two-column story grid under a full-width lead; chips stay a single scrolling row.
- ≥ 840 wide (tablet): lead card and list side by side (lead 5/12, list 7/12); bottom nav becomes a left rail.
Acceptance checklist
- Background is
#FAF6EE; headlines are Newsreader 600 at 30 / 26 / 17px; UI text is Inter. - Chip row scrolls horizontally with no visible scrollbar and proximity snapping; pressed chip is
#1C1A17with paper text. - Selecting a chip dips the list 6px / opacity 0 over 280ms, filters, then restores; the section label reports “Section · N stories”.
- Lead card is
#F1EBDF, 18px radius, with a 190px CSS-drawn landscape (gradient sky, 64px sun, two hills) — no image files. - Each story row has a 72px thumb, a serif headline, a meta line with section and reading time, and a 40px bookmark button.
- Saved stories show ”· saved” in
#2F6B4Fwith a download-check icon; the bookmark is filled. - Toggling a bookmark updates the meta line and the Saved tab badge count.
- Stories are separated by 1px
#E3DCCDhairlines, not cards. - Bottom nav is 64px plus 34px clearance; current item is ink-coloured with
aria-current="page". - Focus rings visible on chips, lead link, bookmarks and nav.
- Reduced motion removes the list dip but keeps filtering.
- No fixed control inside the top 54px.
Implementation notes
Landscape in three pseudo-elements. Sun and hills are border-radius shapes; the second hill’s shadow gives a third band:
.art { height:190px; position:relative; overflow:hidden;
background: linear-gradient(180deg, var(--sky) 0%, var(--sky-2) 100%); }
.art::before { content:""; position:absolute; left:58%; top:34px; width:64px; height:64px; border-radius:50%; background:var(--sun); }
.art::after { content:""; position:absolute; left:-10%; right:-10%; bottom:-40px; height:120px;
border-radius:50% 50% 0 0 / 100% 100% 0 0; background:var(--hill); box-shadow: 0 26px 0 12px var(--hill-2); }
.art i { position:absolute; left:20%; bottom:30px; width:150%; height:80px;
border-radius:60% 60% 0 0 / 100% 100% 0 0; background:var(--hill-2); opacity:.85; }
Filter after the dip, not during. One timer, cleared on rapid taps:
list.classList.add('swap'); clearTimeout(t);
t = setTimeout(() => {
const c = chip.dataset.cat; let n = 0;
stories.forEach(s => { const show = c === 'all' || s.dataset.cat === c; s.hidden = !show; if (show) n++; });
label.textContent = c === 'all' ? 'Latest' : `${chip.textContent} · ${n} ${n === 1 ? 'story' : 'stories'}`;
list.classList.remove('swap');
}, 280);
Saved mark lives in the meta line. Append/remove the fragment next to the reading time rather than toggling a hidden span, so the DOM matches what’s announced:
if (on && !sv) meta.insertAdjacentHTML('beforeend', '<span>·</span><span class="sv">…saved</span>');
else if (!on && sv) { sv.previousElementSibling.remove(); sv.remove(); }
Common mistakes: using <img> placeholders for thumbs (the brief wants CSS colour blocks driven by custom properties); making chips tabs with role="tablist" (the content is filtered, not swapped); letting the chip row wrap at 360px.
Details
- Palette
- Type
- Newsreader · Inter
- Motion
- Subtle motion
- Build
- One session
- Tags
On the shelves