v0.21.0 header+inbox UX (#24,#25,#31): rename About→Philosophy, inline-SVG inbox icon, light inbox pass
- #24: header link "About" → "Philosophy" (route/title unchanged). - #25 icon: replace 📮 emoji with a dependency-free inline-SVG envelope in .inbox-trigger; aria-label/title reframed to "Inbox". Badge intact. - #25 inbox UX (light, no redesign): sharper unread/read distinction (accent dot + left bar + tint via tokens), per-row "mark as read" affordance that marks-without-navigating, clearer "Mark all read" label, and a real empty/caught-up state. New Inbox.css is tokenized and written one notch more specific than App.css where it overrides (Inbox.css injects before App.css under ESM eval order). Data flow, API calls, routing-on-click, and badge behavior preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+11
-3
@@ -169,7 +169,7 @@ export default function App() {
|
|||||||
wonders why a conversation is public can reach the answer
|
wonders why a conversation is public can reach the answer
|
||||||
in two clicks. Anonymous viewers see it too. */}
|
in two clicks. Anonymous viewers see it too. */}
|
||||||
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
<Link to="/philosophy" className="header-about" title="Why this exists (§14)">
|
||||||
About
|
Philosophy
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/docs" className="header-about" title="User guide">
|
<Link to="/docs" className="header-about" title="User guide">
|
||||||
Docs
|
Docs
|
||||||
@@ -188,9 +188,17 @@ export default function App() {
|
|||||||
<button
|
<button
|
||||||
className="inbox-trigger"
|
className="inbox-trigger"
|
||||||
onClick={() => setInboxOpen(o => !o)}
|
onClick={() => setInboxOpen(o => !o)}
|
||||||
title="Notifications inbox (§15.2)"
|
aria-label="Inbox"
|
||||||
|
title="Inbox (§15.2)"
|
||||||
>
|
>
|
||||||
<span aria-hidden>📮</span>
|
<svg
|
||||||
|
width="18" height="18" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" strokeWidth="1.75"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Z" />
|
||||||
|
<path d="m3.5 6.5 8.5 6 8.5-6" />
|
||||||
|
</svg>
|
||||||
{unreadCount > 0 && (
|
{unreadCount > 0 && (
|
||||||
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
<span className="badge">{unreadCount > 99 ? '99+' : unreadCount}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* Inbox.css — §15.2 inbox panel refinements (roadmap #25, light pass).
|
||||||
|
*
|
||||||
|
* The base inbox layout/structure lives in App.css (the §15 / Slice 6
|
||||||
|
* block). This sheet is a TOKENIZED polish layer on top of it: it does
|
||||||
|
* NOT re-lay-out the panel, it sharpens the unread/read distinction,
|
||||||
|
* adds the per-row "mark read" affordance + the unread dot, and gives
|
||||||
|
* the empty/loading states real copy and spacing.
|
||||||
|
*
|
||||||
|
* Cascade note: Inbox.jsx is imported by App.jsx (line 6) BEFORE the
|
||||||
|
* App.css import (line 30), so under ESM depth-first evaluation this
|
||||||
|
* sheet is injected FIRST and App.css wins on equal specificity. Any
|
||||||
|
* rule here that must override an App.css value is therefore written
|
||||||
|
* one notch more specific (e.g. `.inbox-list .inbox-row.unread`).
|
||||||
|
* New classes that App.css doesn't define need no such guard.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ===== Unread vs. read distinction ===== */
|
||||||
|
|
||||||
|
/* A clear left accent bar + warmer tint on unread; read rows sit calm. */
|
||||||
|
.inbox-list .inbox-row {
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
transition: background var(--motion-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.inbox-list .inbox-row.unread {
|
||||||
|
background: var(--color-warning-bg-soft, var(--c-warning-bg-soft));
|
||||||
|
box-shadow: inset 3px 0 0 var(--color-accent);
|
||||||
|
}
|
||||||
|
.inbox-list .inbox-row.read .inbox-summary {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: var(--weight-normal);
|
||||||
|
}
|
||||||
|
.inbox-list .inbox-row.unread .inbox-summary {
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: var(--weight-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The dot is a NEW affordance: a filled accent dot for unread, hidden
|
||||||
|
* (but space-reserved) for read so summaries stay column-aligned. */
|
||||||
|
.inbox-unread-dot {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
background: var(--color-accent);
|
||||||
|
}
|
||||||
|
.inbox-row.read .inbox-unread-dot {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Per-row "mark read" affordance ===== */
|
||||||
|
|
||||||
|
/* The row is a flex Link followed by this button; pin the button to the
|
||||||
|
* right edge, revealed on row hover/focus and always visible on touch. */
|
||||||
|
.inbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.inbox-row .inbox-row-link {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.inbox-row-dismiss {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
margin-right: var(--space-5);
|
||||||
|
padding: 0;
|
||||||
|
color: var(--color-text-subtle);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition:
|
||||||
|
opacity var(--motion-fast) var(--ease-out),
|
||||||
|
color var(--motion-fast) var(--ease-out),
|
||||||
|
background var(--motion-fast) var(--ease-out),
|
||||||
|
border-color var(--motion-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.inbox-row:hover .inbox-row-dismiss,
|
||||||
|
.inbox-row:focus-within .inbox-row-dismiss,
|
||||||
|
.inbox-row-dismiss:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.inbox-row-dismiss:hover {
|
||||||
|
color: var(--color-success-fg);
|
||||||
|
background: var(--color-success-bg);
|
||||||
|
border-color: var(--color-success-bg);
|
||||||
|
}
|
||||||
|
.inbox-row-dismiss:focus-visible {
|
||||||
|
outline: 2px solid var(--color-focus-ring);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
/* Coarse pointers (touch) have no hover; keep the affordance discoverable. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.inbox-row-dismiss { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Mark-all-read button ===== */
|
||||||
|
.inbox-mark-all {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Empty / loading states ===== */
|
||||||
|
.inbox-state {
|
||||||
|
padding: var(--space-9) var(--space-7);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.inbox-empty {
|
||||||
|
padding: var(--space-11) var(--space-7);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.inbox-empty-title {
|
||||||
|
margin: 0 0 var(--space-3);
|
||||||
|
font-size: var(--text-md);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
color: var(--color-text-strong);
|
||||||
|
}
|
||||||
|
.inbox-empty .muted {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
markNotificationRead,
|
markNotificationRead,
|
||||||
markNotificationsReadByFilter,
|
markNotificationsReadByFilter,
|
||||||
} from '../api.js'
|
} from '../api.js'
|
||||||
|
import './Inbox.css'
|
||||||
|
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
{ value: '', label: 'All categories' },
|
{ value: '', label: 'All categories' },
|
||||||
@@ -56,11 +57,15 @@ export default function Inbox({ onClose, lastChangeTick }) {
|
|||||||
return Array.from(seen.entries())
|
return Array.from(seen.entries())
|
||||||
}, [items])
|
}, [items])
|
||||||
|
|
||||||
|
async function markOneRead(item) {
|
||||||
|
if (item.read_at) return
|
||||||
|
await markNotificationRead(item.id)
|
||||||
|
setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p))
|
||||||
|
setUnreadCount(c => Math.max(0, c - 1))
|
||||||
|
}
|
||||||
|
|
||||||
async function handleRowClick(item) {
|
async function handleRowClick(item) {
|
||||||
if (!item.read_at) {
|
await markOneRead(item)
|
||||||
await markNotificationRead(item.id)
|
|
||||||
setItems(prev => prev.map(p => p.id === item.id ? { ...p, read_at: new Date().toISOString() } : p))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function markAllUnderFilter() {
|
async function markAllUnderFilter() {
|
||||||
@@ -121,22 +126,36 @@ export default function Inbox({ onClose, lastChangeTick }) {
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="btn-link"
|
className="btn-link inbox-mark-all"
|
||||||
onClick={markAllUnderFilter}
|
onClick={markAllUnderFilter}
|
||||||
disabled={items.every(i => i.read_at)}
|
disabled={items.every(i => i.read_at)}
|
||||||
|
title="Mark every notification matching the current filter as read"
|
||||||
>
|
>
|
||||||
Mark all read (under filter)
|
Mark all read
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="inbox-body">
|
<div className="inbox-body">
|
||||||
{loading && <p className="muted">Loading…</p>}
|
{loading && <p className="inbox-state muted">Loading your inbox…</p>}
|
||||||
{!loading && items.length === 0 && (
|
{!loading && items.length === 0 && (
|
||||||
<p className="muted">No notifications match. Try a different filter, or come back later.</p>
|
<div className="inbox-empty">
|
||||||
|
<p className="inbox-empty-title">You're all caught up.</p>
|
||||||
|
<p className="muted">
|
||||||
|
{filters.unread || filters.rfcSlug || filters.category
|
||||||
|
? 'Nothing matches the current filters. Clear them to see everything.'
|
||||||
|
: 'New activity on RFCs you follow will show up here.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<ul className="inbox-list">
|
<ul className="inbox-list">
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<InboxRow key={item.id} item={item} onClick={handleRowClick} onClose={onClose} />
|
<InboxRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
onClick={handleRowClick}
|
||||||
|
onMarkRead={markOneRead}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,16 +164,24 @@ export default function Inbox({ onClose, lastChangeTick }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InboxRow({ item, onClick, onClose }) {
|
function InboxRow({ item, onClick, onMarkRead, onClose }) {
|
||||||
const unread = !item.read_at
|
const unread = !item.read_at
|
||||||
const target = deepLink(item)
|
const target = deepLink(item)
|
||||||
const handle = async () => {
|
const handle = async () => {
|
||||||
await onClick(item)
|
await onClick(item)
|
||||||
if (target) onClose?.()
|
if (target) onClose?.()
|
||||||
}
|
}
|
||||||
|
const handleMarkRead = async (e) => {
|
||||||
|
// Don't let the row's Link fire — this affordance only marks read,
|
||||||
|
// it never navigates.
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
await onMarkRead(item)
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<li className={`inbox-row ${unread ? 'unread' : ''}`}>
|
<li className={`inbox-row ${unread ? 'unread' : 'read'}`}>
|
||||||
<Link to={target || '#'} onClick={handle} className="inbox-row-link">
|
<Link to={target || '#'} onClick={handle} className="inbox-row-link">
|
||||||
|
<span className="inbox-unread-dot" aria-hidden />
|
||||||
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
|
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
|
||||||
<span className="inbox-summary">{item.summary}</span>
|
<span className="inbox-summary">{item.summary}</span>
|
||||||
{item.bundled_count > 1 && (
|
{item.bundled_count > 1 && (
|
||||||
@@ -162,6 +189,24 @@ function InboxRow({ item, onClick, onClose }) {
|
|||||||
)}
|
)}
|
||||||
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
{unread && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inbox-row-dismiss"
|
||||||
|
onClick={handleMarkRead}
|
||||||
|
aria-label="Mark as read"
|
||||||
|
title="Mark as read"
|
||||||
|
>
|
||||||
|
{/* check glyph — dependency-free inline SVG */}
|
||||||
|
<svg
|
||||||
|
width="14" height="14" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" strokeWidth="2.25"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round" aria-hidden
|
||||||
|
>
|
||||||
|
<path d="m5 13 4 4 10-11" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user