Skip to content
Open
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
60 changes: 43 additions & 17 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ function App() {
const [stories, setStories] = useState<HNStory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [cvdMode, setCvdMode] = useState<string | null>(
localStorage.getItem('cvd-preference')
)

useEffect(() => {
const fetchTopStories = async () => {
Expand Down Expand Up @@ -69,6 +72,17 @@ function App() {
fetchTopStories()
}, [])

const handleCvdChange = (mode: string | null) => {
setCvdMode(mode)
if (mode) {
document.body.setAttribute('data-cvd', mode)
localStorage.setItem('cvd-preference', mode)
} else {
document.body.removeAttribute('data-cvd')
localStorage.removeItem('cvd-preference')
}
}

const formatTime = (timestamp: number) => {
const now = Date.now() / 1000
const diff = now - timestamp
Expand All @@ -94,36 +108,48 @@ function App() {

if (loading) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<p className="text-neutral-400 text-sm">Loading...</p>
<div className="min-h-screen bg-[rgb(var(--bg-primary))] flex items-center justify-center">
<p className="text-[rgb(var(--text-tertiary))] text-sm">Loading...</p>
</div>
)
}

if (error) {
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<p className="text-neutral-500 text-sm">{error}</p>
<div className="min-h-screen bg-[rgb(var(--bg-primary))] flex items-center justify-center">
<p className="text-[rgb(var(--text-secondary))] text-sm">{error}</p>
</div>
)
}

return (
<div className="min-h-screen bg-neutral-50 relative">
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-orange-200 to-transparent pointer-events-none"></div>
<div className="min-h-screen bg-[rgb(var(--bg-primary))] relative">
<div className="absolute top-0 left-0 right-0 h-32 bg-gradient-to-b from-[rgb(var(--gradient-from))] to-transparent pointer-events-none"></div>
<div className="max-w-3xl mx-auto px-6 py-12 relative">
<header className="mb-8">
<h1 className="text-4xl font-extrabold text-slate-900 tracking-tight">
Calm HN
</h1>
<p className="text-slate-500 text-[10px] mt-2 uppercase tracking-wider">
Top stories from the last three months
</p>
<header className="mb-8 flex items-start justify-between gap-4">
<div>
<h1 className="text-4xl font-extrabold text-[rgb(var(--text-primary))] tracking-tight">
Calm HN
</h1>
<p className="text-[rgb(var(--text-secondary))] text-[10px] mt-2 uppercase tracking-wider">
Top stories from the last three months
</p>
</div>
<select
value={cvdMode || ''}
onChange={(e) => handleCvdChange(e.target.value || null)}
className="text-[10px] px-2 py-1 rounded bg-[rgb(var(--bg-secondary))] text-[rgb(var(--text-secondary))] border-0 outline-none cursor-pointer uppercase tracking-wider opacity-40 hover:opacity-100 transition-opacity"
aria-label="Color vision mode"
>
<option value="">Default</option>
<option value="protanopia">Protanopia</option>
<option value="deuteranopia">Deuteranopia</option>
</select>
</header>

<div className="space-y-6">
{stories.map((story, index) => (
<article key={story.id} className="group -mx-3 px-3 py-3 rounded-lg hover:bg-slate-100 transition-colors duration-300 relative">
<article key={story.id} className="group -mx-3 px-3 py-3 rounded-lg hover:bg-[rgb(var(--bg-secondary))] transition-colors duration-300 relative">
<a
href={story.url || `https://news.ycombinator.com/item?id=${story.id}`}
target="_blank"
Expand All @@ -132,10 +158,10 @@ function App() {
aria-label={story.title}
/>
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 relative z-10 pointer-events-none">
<span className="bg-slate-200 text-slate-500 text-[10px] leading-none font-medium px-2 py-0.5 rounded-full flex-shrink-0 self-center mt-px group-hover:bg-orange-200 group-hover:text-slate-600 transition-colors group-hover:duration-[750ms] duration-300">
<span className="bg-[rgb(var(--bg-badge))] text-[rgb(var(--text-badge))] text-[10px] leading-none font-medium px-2 py-0.5 rounded-full flex-shrink-0 self-center mt-px group-hover:bg-[rgb(var(--bg-badge-hover))] group-hover:text-[rgb(var(--text-badge-hover))] transition-colors group-hover:duration-[750ms] duration-300">
{index + 1}
</span>
<h2 className="text-slate-900 text-lg leading-relaxed">
<h2 className="text-[rgb(var(--text-primary))] text-lg leading-relaxed">
<span className="inline-flex items-baseline gap-1.5">
{story.title}
{story.url && (
Expand All @@ -144,7 +170,7 @@ function App() {
</span>
</h2>
<div></div>
<div className="flex items-center gap-3 text-xs text-slate-400 group-hover:text-slate-500 transition-colors duration-300">
<div className="flex items-center gap-3 text-xs text-[rgb(var(--text-tertiary))] group-hover:text-[rgb(var(--text-secondary))] transition-colors duration-300">
<span className="flex items-center gap-1">
<ArrowUp size={12} weight="regular" className="opacity-60" />
{story.score}
Expand Down
147 changes: 147 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,153 @@
src: url("./InterVariable.woff2") format("woff2");
}

/* Light theme (default) */
:root {
--bg-primary: 250 250 250; /* neutral-50 */
--bg-secondary: 241 245 249; /* slate-100 */
--bg-badge: 226 232 240; /* slate-200 */
--bg-badge-hover: 254 215 170; /* orange-200 */
--gradient-from: 254 215 170; /* orange-200 */
--text-primary: 15 23 42; /* slate-900 */
--text-secondary: 100 116 139; /* slate-500 */
--text-tertiary: 148 163 184; /* slate-400 */
--text-badge: 100 116 139; /* slate-500 */
--text-badge-hover: 71 85 105; /* slate-600 */
--text-muted: 203 213 225; /* slate-300 */
}

/* Dark theme */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: 15 23 42; /* slate-900 */
--bg-secondary: 30 41 59; /* slate-800 */
--bg-badge: 51 65 85; /* slate-700 */
--bg-badge-hover: 194 65 12; /* orange-700 */
--gradient-from: 194 65 12; /* orange-700 */
--text-primary: 248 250 252; /* slate-50 */
--text-secondary: 203 213 225; /* slate-300 */
--text-tertiary: 148 163 184; /* slate-400 */
--text-badge: 203 213 225; /* slate-300 */
--text-badge-hover: 226 232 240; /* slate-200 */
--text-muted: 71 85 105; /* slate-600 */
}
}

/* High contrast light theme */
@media (prefers-contrast: more) and (prefers-color-scheme: light) {
:root {
--bg-primary: 255 255 255; /* white */
--bg-secondary: 226 232 240; /* slate-200 */
--bg-badge: 203 213 225; /* slate-300 */
--bg-badge-hover: 251 146 60; /* orange-400 */
--gradient-from: 251 146 60; /* orange-400 */
--text-primary: 0 0 0; /* black */
--text-secondary: 30 41 59; /* slate-800 */
--text-tertiary: 71 85 105; /* slate-600 */
--text-badge: 30 41 59; /* slate-800 */
--text-badge-hover: 0 0 0; /* black */
--text-muted: 148 163 184; /* slate-400 */
}
}

/* High contrast dark theme */
@media (prefers-contrast: more) and (prefers-color-scheme: dark) {
:root {
--bg-primary: 0 0 0; /* black */
--bg-secondary: 30 41 59; /* slate-800 */
--bg-badge: 71 85 105; /* slate-600 */
--bg-badge-hover: 255 140 0; /* bright orange */
--gradient-from: 255 140 0; /* bright orange */
--text-primary: 255 255 255; /* white */
--text-secondary: 226 232 240; /* slate-200 */
--text-tertiary: 203 213 225; /* slate-300 */
--text-badge: 241 245 249; /* slate-100 */
--text-badge-hover: 255 255 255; /* white */
--text-muted: 100 116 139; /* slate-500 */
}
}

/* Protanopia & Deuteranopia light theme (red-green color blindness) */
/* Uses blue-yellow contrasts instead of red-green */
@media (prefers-color-scheme: light) {
@supports (color: color(display-p3 1 1 1)) {
:root:has(body[data-cvd="protanopia"]),
:root:has(body[data-cvd="deuteranopia"]) {
--bg-primary: 250 250 250; /* neutral-50 */
--bg-secondary: 241 245 249; /* slate-100 */
--bg-badge: 219 234 254; /* blue-100 */
--bg-badge-hover: 147 197 253; /* blue-300 */
--gradient-from: 147 197 253; /* blue-300 */
--text-primary: 15 23 42; /* slate-900 */
--text-secondary: 71 85 105; /* slate-600 */
--text-tertiary: 100 116 139; /* slate-500 */
--text-badge: 30 64 175; /* blue-800 */
--text-badge-hover: 30 58 138; /* blue-900 */
--text-muted: 203 213 225; /* slate-300 */
}
}
}

/* Protanopia & Deuteranopia dark theme */
@media (prefers-color-scheme: dark) {
@supports (color: color(display-p3 1 1 1)) {
:root:has(body[data-cvd="protanopia"]),
:root:has(body[data-cvd="deuteranopia"]) {
--bg-primary: 15 23 42; /* slate-900 */
--bg-secondary: 30 41 59; /* slate-800 */
--bg-badge: 30 58 138; /* blue-900 */
--bg-badge-hover: 59 130 246; /* blue-500 */
--gradient-from: 59 130 246; /* blue-500 */
--text-primary: 248 250 252; /* slate-50 */
--text-secondary: 203 213 225; /* slate-300 */
--text-tertiary: 148 163 184; /* slate-400 */
--text-badge: 147 197 253; /* blue-300 */
--text-badge-hover: 191 219 254; /* blue-200 */
--text-muted: 71 85 105; /* slate-600 */
}
}
}

/* Protanopia & Deuteranopia high contrast light */
@media (prefers-contrast: more) and (prefers-color-scheme: light) {
@supports (color: color(display-p3 1 1 1)) {
:root:has(body[data-cvd="protanopia"]),
:root:has(body[data-cvd="deuteranopia"]) {
--bg-primary: 255 255 255; /* white */
--bg-secondary: 226 232 240; /* slate-200 */
--bg-badge: 191 219 254; /* blue-200 */
--bg-badge-hover: 96 165 250; /* blue-400 */
--gradient-from: 96 165 250; /* blue-400 */
--text-primary: 0 0 0; /* black */
--text-secondary: 30 41 59; /* slate-800 */
--text-tertiary: 51 65 85; /* slate-700 */
--text-badge: 29 78 216; /* blue-700 */
--text-badge-hover: 0 0 0; /* black */
--text-muted: 148 163 184; /* slate-400 */
}
}
}

/* Protanopia & Deuteranopia high contrast dark */
@media (prefers-contrast: more) and (prefers-color-scheme: dark) {
@supports (color: color(display-p3 1 1 1)) {
:root:has(body[data-cvd="protanopia"]),
:root:has(body[data-cvd="deuteranopia"]) {
--bg-primary: 0 0 0; /* black */
--bg-secondary: 30 41 59; /* slate-800 */
--bg-badge: 29 78 216; /* blue-700 */
--bg-badge-hover: 96 165 250; /* blue-400 */
--gradient-from: 96 165 250; /* blue-400 */
--text-primary: 255 255 255; /* white */
--text-secondary: 226 232 240; /* slate-200 */
--text-tertiary: 203 213 225; /* slate-300 */
--text-badge: 219 234 254; /* blue-100 */
--text-badge-hover: 255 255 255; /* white */
--text-muted: 100 116 139; /* slate-500 */
}
}
}

body {
font-family: "InterVariable", sans-serif;
}
14 changes: 14 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,20 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

// Detect color vision deficiency preference
// Users can set this via browser extension or URL parameter
const params = new URLSearchParams(window.location.search)
const cvdParam = params.get('cvd')
if (cvdParam === 'protanopia' || cvdParam === 'deuteranopia') {
document.body.setAttribute('data-cvd', cvdParam)
localStorage.setItem('cvd-preference', cvdParam)
} else {
const stored = localStorage.getItem('cvd-preference')
if (stored === 'protanopia' || stored === 'deuteranopia') {
document.body.setAttribute('data-cvd', stored)
}
}

createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
Expand Down