[READ-ONLY] Mirror of https://github.com/thang-qt/ThreadLine. Alternative frontend for HackerNews and Lobsters threadline.thangqt.com/
0

Configure Feed

Select the types of activity you want to include in your feed.

Initial Threadline implementation

Quang Thang (Jul 11, 2026, 12:09 PM +0700) 6b29cfaa

+5147
+6
.env.example
··· 1 + # Leave unset on Cloudflare Pages when using the included same-origin functions. 2 + # Set this for purely static hosts that use an external feed aggregation endpoint. 3 + # VITE_FEED_API_URL=https://your-proxy.example.com/api/feed 4 + 5 + # Set this for purely static hosts that need an external Lobsters discussion proxy. 6 + # VITE_API_PROXY_URL=https://your-proxy.example.com/api/lobsters
+10
.gitignore
··· 1 + node_modules/ 2 + dist/ 3 + .env 4 + .env.local 5 + .DS_Store 6 + *.tsbuildinfo 7 + 8 + plan.md 9 + progress.md 10 + research.md
+67
README.md
··· 1 + # Threadline 2 + 3 + Threadline is a static-first, local-preference reader for community news sources. It currently combines Hacker News and Lobsters, with source metadata and feed contracts organized so additional sources can be added later. The app is a React single-page app with routes for `/` (feed), `/story/:source/:id` (discussion), and `/settings` (preferences). 4 + 5 + Preferences are stored only in `localStorage`. The storage key remains `hnster.preferences.v1` intentionally for compatibility with older builds. 6 + 7 + ## Development 8 + 9 + This repository uses pnpm and includes a Nix development shell: 10 + 11 + ```bash 12 + nix develop # supplies Node 22 and pnpm 13 + pnpm install 14 + pnpm dev 15 + ``` 16 + 17 + The Vite server mirrors the Cloudflare API surface in development: 18 + 19 + - `/api/feed` fetches and normalizes the combined front page. 20 + - `/api/lobsters/*` proxies Lobsters JSON discussion/feed endpoints for CORS-safe comment loading. 21 + 22 + ```bash 23 + pnpm typecheck 24 + pnpm test 25 + pnpm build 26 + ``` 27 + 28 + ## Feed architecture 29 + 30 + The browser first requests a same-origin aggregated feed: 31 + 32 + ```text 33 + GET /api/feed?hn=top&lobsters=hottest 34 + ``` 35 + 36 + The Cloudflare Pages Function fetches Hacker News and Lobsters HTML front pages, parses the visible story metadata, and returns a normalized `FeedResult`. If scraping fails or returns suspiciously few stories, it falls back to the public APIs: 37 + 38 + - Hacker News Firebase item API 39 + - Lobsters JSON feed API 40 + 41 + Partial failures return the successful source plus a per-source error so the UI can still render available stories. 42 + 43 + For purely static hosts without the feed aggregation function, the browser falls back to public feed APIs directly. Hacker News can work in that mode, but Lobsters discussions still require a CORS-safe proxy (`VITE_API_PROXY_URL`) unless the app is deployed with the included Cloudflare Pages Function. 44 + 45 + ## Lobsters proxy and deployment 46 + 47 + Lobsters does not reliably permit browser cross-origin requests for discussions. The client therefore calls a proxy base, not Lobsters directly: 48 + 49 + - Default: same-origin `/api/lobsters`, implemented by `functions/api/lobsters/[[path]].ts` on Cloudflare Pages. 50 + - External: set `VITE_API_PROXY_URL` at **build time** to a full proxy base, for example `https://reader-proxy.example.workers.dev/api/lobsters`. Do not include a trailing feed path. 51 + 52 + The proxy only forwards safe endpoint forms: 53 + 54 + - `hottest.json` 55 + - `newest.json` 56 + - `s/<id>.json` 57 + 58 + ## Optional environment variables 59 + 60 + - `VITE_FEED_API_URL`: full URL for an external compatible `/api/feed` endpoint. 61 + - `VITE_API_PROXY_URL`: full URL for an external compatible `/api/lobsters` endpoint. 62 + 63 + Leave both unset on Cloudflare Pages when using the included same-origin functions. 64 + 65 + ## Cloudflare Pages 66 + 67 + Use build command `pnpm build` and output directory `dist`. The repo includes `public/_redirects`, copied into `dist`, to serve `index.html` for non-asset routes so direct visits to `/settings` and `/story/...` work. Cloudflare Pages Functions under `/api/*` are handled separately from this static SPA fallback.
+27
flake.lock
··· 1 + { 2 + "nodes": { 3 + "nixpkgs": { 4 + "locked": { 5 + "lastModified": 1783522502, 6 + "narHash": "sha256-iffAls3iaNTyJC2faYcUXSI+Gp02cDjYl+MygxKl2GI=", 7 + "owner": "NixOS", 8 + "repo": "nixpkgs", 9 + "rev": "0bb7ec54c8483066ec9d7720e780a5caa71f8612", 10 + "type": "github" 11 + }, 12 + "original": { 13 + "owner": "NixOS", 14 + "ref": "nixos-unstable", 15 + "repo": "nixpkgs", 16 + "type": "github" 17 + } 18 + }, 19 + "root": { 20 + "inputs": { 21 + "nixpkgs": "nixpkgs" 22 + } 23 + } 24 + }, 25 + "root": "root", 26 + "version": 7 27 + }
+17
flake.nix
··· 1 + { 2 + description = "Threadline static reader development shell"; 3 + 4 + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 5 + 6 + outputs = { self, nixpkgs }: 7 + let 8 + systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; 9 + in { 10 + devShells = builtins.listToAttrs (map (system: { 11 + name = system; 12 + value.default = let pkgs = import nixpkgs { inherit system; }; in pkgs.mkShell { 13 + packages = [ pkgs.nodejs_22 pkgs.pnpm ]; 14 + }; 15 + }) systems); 16 + }; 17 + }
+46
functions/api/feed.ts
··· 1 + import type { HnFeed, LobstersFeed } from '../../src/types'; 2 + import { fetchAggregatedFeed } from '../../src/server/feed/fetchFeed'; 3 + import { isHnFeed, isLobstersFeed, sourceEnabledFromSearch } from '../../src/lib/sources'; 4 + import { edgeCache, JSON_HEADERS, jsonError, jsonResponse } from './http'; 5 + 6 + function parseHnMode(value: string | null): HnFeed | Response { 7 + if (!value) return 'top'; 8 + if (isHnFeed(value)) return value; 9 + return jsonError('Unsupported Hacker News feed', 400); 10 + } 11 + 12 + function parseLobstersMode(value: string | null): LobstersFeed | Response { 13 + if (!value) return 'hottest'; 14 + if (isLobstersFeed(value)) return value; 15 + return jsonError('Unsupported Lobsters feed', 400); 16 + } 17 + 18 + export const onRequestOptions: PagesFunction = async () => new Response(null, { status: 204, headers: JSON_HEADERS }); 19 + 20 + export const onRequestGet: PagesFunction = async (context) => { 21 + const url = new URL(context.request.url); 22 + const hn = parseHnMode(url.searchParams.get('hn')); 23 + if (hn instanceof Response) return hn; 24 + const lobsters = parseLobstersMode(url.searchParams.get('lobsters')); 25 + if (lobsters instanceof Response) return lobsters; 26 + const sourceEnabled = sourceEnabledFromSearch(url.searchParams); 27 + 28 + const cacheUrl = new URL(context.request.url); 29 + cacheUrl.searchParams.set('hn', hn); 30 + cacheUrl.searchParams.set('lobsters', lobsters); 31 + cacheUrl.searchParams.set('hnEnabled', sourceEnabled.hn ? '1' : '0'); 32 + cacheUrl.searchParams.set('lobstersEnabled', sourceEnabled.lobsters ? '1' : '0'); 33 + const cacheKey = new Request(cacheUrl.toString(), context.request); 34 + const cache = edgeCache(); 35 + const cached = await cache.match(cacheKey); 36 + if (cached) return cached; 37 + 38 + const payload = await fetchAggregatedFeed({ hn, lobsters, sourceEnabled }); 39 + const status = payload.stories.length ? 200 : 502; 40 + const response = jsonResponse(payload, { 41 + status, 42 + headers: { 'Cache-Control': 'public, max-age=60, stale-while-revalidate=600' } 43 + }); 44 + if (payload.stories.length) context.waitUntil(cache.put(cacheKey, response.clone())); 45 + return response; 46 + };
+19
functions/api/http.ts
··· 1 + export const JSON_HEADERS = { 2 + 'Content-Type': 'application/json; charset=utf-8', 3 + 'Access-Control-Allow-Origin': '*', 4 + 'Access-Control-Allow-Methods': 'GET, OPTIONS', 5 + 'Access-Control-Allow-Headers': 'Content-Type', 6 + Vary: 'Origin' 7 + }; 8 + 9 + export function jsonResponse(value: unknown, init: ResponseInit = {}): Response { 10 + return Response.json(value, { ...init, headers: { ...JSON_HEADERS, ...(init.headers ?? {}) } }); 11 + } 12 + 13 + export function jsonError(message: string, status: number): Response { 14 + return jsonResponse({ error: message }, { status }); 15 + } 16 + 17 + export function edgeCache(): Cache { 18 + return (caches as unknown as { default: Cache }).default; 19 + }
+46
functions/api/lobsters/[[path]].ts
··· 1 + import { isAllowedLobstersProxyPath } from '../../../src/lib/sources'; 2 + import { edgeCache, JSON_HEADERS, jsonError } from '../http'; 3 + 4 + const LOBSTERS_ORIGIN = 'https://lobste.rs'; 5 + 6 + function pathFromParam(param: string | string[] | undefined): string { 7 + if (Array.isArray(param)) return param.join('/'); 8 + return param ?? ''; 9 + } 10 + 11 + export const onRequestOptions: PagesFunction = async () => new Response(null, { status: 204, headers: JSON_HEADERS }); 12 + 13 + export const onRequestGet: PagesFunction = async (context) => { 14 + const path = pathFromParam(context.params.path); 15 + if (!isAllowedLobstersProxyPath(path)) return jsonError('Unsupported Lobsters API path', 404); 16 + 17 + const cacheUrl = new URL(context.request.url); 18 + cacheUrl.search = ''; 19 + const cacheKey = new Request(cacheUrl.toString(), context.request); 20 + const cache = edgeCache(); 21 + const cached = await cache.match(cacheKey); 22 + if (cached) return cached; 23 + 24 + let upstream: Response; 25 + try { 26 + upstream = await fetch(`${LOBSTERS_ORIGIN}/${path}`, { 27 + headers: { 28 + Accept: 'application/json', 29 + 'User-Agent': 'threadline-static-reader/0.1 (+https://github.com/)' 30 + }, 31 + cf: { cacheTtl: path.startsWith('s/') ? 120 : 60, cacheEverything: true } 32 + }); 33 + } catch { 34 + return jsonError('Could not reach Lobsters upstream', 502); 35 + } 36 + 37 + if (!upstream.ok) return jsonError(`Lobsters returned ${upstream.status}`, upstream.status); 38 + 39 + const headers = new Headers(JSON_HEADERS); 40 + headers.set('Content-Type', upstream.headers.get('Content-Type') ?? 'application/json; charset=utf-8'); 41 + headers.set('Cache-Control', path.startsWith('s/') ? 'public, max-age=120, stale-while-revalidate=600' : 'public, max-age=60, stale-while-revalidate=300'); 42 + 43 + const response = new Response(upstream.body, { status: upstream.status, headers }); 44 + context.waitUntil(cache.put(cacheKey, response.clone())); 45 + return response; 46 + };
+13
index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <meta name="description" content="A static-first newspaper reader for Hacker News and Lobsters." /> 7 + <title>Threadline — community links and discussions</title> 8 + </head> 9 + <body> 10 + <div id="root"></div> 11 + <script type="module" src="/src/main.tsx"></script> 12 + </body> 13 + </html>
+29
package.json
··· 1 + { 2 + "name": "threadline", 3 + "version": "0.1.0", 4 + "private": true, 5 + "type": "module", 6 + "packageManager": "pnpm@11.5.1", 7 + "scripts": { 8 + "dev": "vite", 9 + "build": "tsc -b && vite build", 10 + "preview": "vite preview", 11 + "typecheck": "tsc -b", 12 + "test": "vitest run" 13 + }, 14 + "dependencies": { 15 + "react": "latest", 16 + "react-dom": "latest", 17 + "react-router-dom": "latest" 18 + }, 19 + "devDependencies": { 20 + "@cloudflare/workers-types": "latest", 21 + "@types/node": "latest", 22 + "@types/react": "latest", 23 + "@types/react-dom": "latest", 24 + "@vitejs/plugin-react": "latest", 25 + "typescript": "latest", 26 + "vite": "latest", 27 + "vitest": "latest" 28 + } 29 + }
+1115
pnpm-lock.yaml
··· 1 + lockfileVersion: '9.0' 2 + 3 + settings: 4 + autoInstallPeers: true 5 + excludeLinksFromLockfile: false 6 + 7 + importers: 8 + 9 + .: 10 + dependencies: 11 + react: 12 + specifier: latest 13 + version: 19.2.7 14 + react-dom: 15 + specifier: latest 16 + version: 19.2.7(react@19.2.7) 17 + react-router-dom: 18 + specifier: latest 19 + version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 20 + devDependencies: 21 + '@cloudflare/workers-types': 22 + specifier: latest 23 + version: 5.20260710.1 24 + '@types/node': 25 + specifier: latest 26 + version: 26.1.1 27 + '@types/react': 28 + specifier: latest 29 + version: 19.2.17 30 + '@types/react-dom': 31 + specifier: latest 32 + version: 19.2.3(@types/react@19.2.17) 33 + '@vitejs/plugin-react': 34 + specifier: latest 35 + version: 6.0.3(vite@8.1.4(@types/node@26.1.1)) 36 + typescript: 37 + specifier: latest 38 + version: 7.0.2 39 + vite: 40 + specifier: latest 41 + version: 8.1.4(@types/node@26.1.1) 42 + vitest: 43 + specifier: latest 44 + version: 4.1.10(@types/node@26.1.1)(vite@8.1.4(@types/node@26.1.1)) 45 + 46 + packages: 47 + 48 + '@cloudflare/workers-types@5.20260710.1': 49 + resolution: {integrity: sha512-4ooaY2Pb5XGwDn8Fzm6jnTAJkIX0R5LBvL9euQpp2T58sQItlAQd9yivAlkwGhpY5cM1u81/9HaXwKAjXwtyzA==} 50 + 51 + '@emnapi/core@1.11.1': 52 + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} 53 + 54 + '@emnapi/runtime@1.11.1': 55 + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} 56 + 57 + '@emnapi/wasi-threads@1.2.2': 58 + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} 59 + 60 + '@jridgewell/sourcemap-codec@1.5.5': 61 + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} 62 + 63 + '@napi-rs/wasm-runtime@1.1.6': 64 + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} 65 + peerDependencies: 66 + '@emnapi/core': ^1.7.1 67 + '@emnapi/runtime': ^1.7.1 68 + 69 + '@oxc-project/types@0.139.0': 70 + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} 71 + 72 + '@rolldown/binding-android-arm64@1.1.5': 73 + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} 74 + engines: {node: ^20.19.0 || >=22.12.0} 75 + cpu: [arm64] 76 + os: [android] 77 + 78 + '@rolldown/binding-darwin-arm64@1.1.5': 79 + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} 80 + engines: {node: ^20.19.0 || >=22.12.0} 81 + cpu: [arm64] 82 + os: [darwin] 83 + 84 + '@rolldown/binding-darwin-x64@1.1.5': 85 + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} 86 + engines: {node: ^20.19.0 || >=22.12.0} 87 + cpu: [x64] 88 + os: [darwin] 89 + 90 + '@rolldown/binding-freebsd-x64@1.1.5': 91 + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} 92 + engines: {node: ^20.19.0 || >=22.12.0} 93 + cpu: [x64] 94 + os: [freebsd] 95 + 96 + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': 97 + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} 98 + engines: {node: ^20.19.0 || >=22.12.0} 99 + cpu: [arm] 100 + os: [linux] 101 + 102 + '@rolldown/binding-linux-arm64-gnu@1.1.5': 103 + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} 104 + engines: {node: ^20.19.0 || >=22.12.0} 105 + cpu: [arm64] 106 + os: [linux] 107 + libc: [glibc] 108 + 109 + '@rolldown/binding-linux-arm64-musl@1.1.5': 110 + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} 111 + engines: {node: ^20.19.0 || >=22.12.0} 112 + cpu: [arm64] 113 + os: [linux] 114 + libc: [musl] 115 + 116 + '@rolldown/binding-linux-ppc64-gnu@1.1.5': 117 + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} 118 + engines: {node: ^20.19.0 || >=22.12.0} 119 + cpu: [ppc64] 120 + os: [linux] 121 + libc: [glibc] 122 + 123 + '@rolldown/binding-linux-s390x-gnu@1.1.5': 124 + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} 125 + engines: {node: ^20.19.0 || >=22.12.0} 126 + cpu: [s390x] 127 + os: [linux] 128 + libc: [glibc] 129 + 130 + '@rolldown/binding-linux-x64-gnu@1.1.5': 131 + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} 132 + engines: {node: ^20.19.0 || >=22.12.0} 133 + cpu: [x64] 134 + os: [linux] 135 + libc: [glibc] 136 + 137 + '@rolldown/binding-linux-x64-musl@1.1.5': 138 + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} 139 + engines: {node: ^20.19.0 || >=22.12.0} 140 + cpu: [x64] 141 + os: [linux] 142 + libc: [musl] 143 + 144 + '@rolldown/binding-openharmony-arm64@1.1.5': 145 + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} 146 + engines: {node: ^20.19.0 || >=22.12.0} 147 + cpu: [arm64] 148 + os: [openharmony] 149 + 150 + '@rolldown/binding-wasm32-wasi@1.1.5': 151 + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} 152 + engines: {node: ^20.19.0 || >=22.12.0} 153 + cpu: [wasm32] 154 + 155 + '@rolldown/binding-win32-arm64-msvc@1.1.5': 156 + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} 157 + engines: {node: ^20.19.0 || >=22.12.0} 158 + cpu: [arm64] 159 + os: [win32] 160 + 161 + '@rolldown/binding-win32-x64-msvc@1.1.5': 162 + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} 163 + engines: {node: ^20.19.0 || >=22.12.0} 164 + cpu: [x64] 165 + os: [win32] 166 + 167 + '@rolldown/pluginutils@1.0.1': 168 + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} 169 + 170 + '@standard-schema/spec@1.1.0': 171 + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} 172 + 173 + '@tybys/wasm-util@0.10.3': 174 + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} 175 + 176 + '@types/chai@5.2.3': 177 + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} 178 + 179 + '@types/deep-eql@4.0.2': 180 + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} 181 + 182 + '@types/estree@1.0.9': 183 + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} 184 + 185 + '@types/node@26.1.1': 186 + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} 187 + 188 + '@types/react-dom@19.2.3': 189 + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} 190 + peerDependencies: 191 + '@types/react': ^19.2.0 192 + 193 + '@types/react@19.2.17': 194 + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} 195 + 196 + '@typescript/typescript-aix-ppc64@7.0.2': 197 + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} 198 + engines: {node: '>=16.20.0'} 199 + cpu: [ppc64] 200 + os: [aix] 201 + 202 + '@typescript/typescript-darwin-arm64@7.0.2': 203 + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} 204 + engines: {node: '>=16.20.0'} 205 + cpu: [arm64] 206 + os: [darwin] 207 + 208 + '@typescript/typescript-darwin-x64@7.0.2': 209 + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} 210 + engines: {node: '>=16.20.0'} 211 + cpu: [x64] 212 + os: [darwin] 213 + 214 + '@typescript/typescript-freebsd-arm64@7.0.2': 215 + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} 216 + engines: {node: '>=16.20.0'} 217 + cpu: [arm64] 218 + os: [freebsd] 219 + 220 + '@typescript/typescript-freebsd-x64@7.0.2': 221 + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} 222 + engines: {node: '>=16.20.0'} 223 + cpu: [x64] 224 + os: [freebsd] 225 + 226 + '@typescript/typescript-linux-arm64@7.0.2': 227 + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} 228 + engines: {node: '>=16.20.0'} 229 + cpu: [arm64] 230 + os: [linux] 231 + 232 + '@typescript/typescript-linux-arm@7.0.2': 233 + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} 234 + engines: {node: '>=16.20.0'} 235 + cpu: [arm] 236 + os: [linux] 237 + 238 + '@typescript/typescript-linux-loong64@7.0.2': 239 + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} 240 + engines: {node: '>=16.20.0'} 241 + cpu: [loong64] 242 + os: [linux] 243 + 244 + '@typescript/typescript-linux-mips64el@7.0.2': 245 + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} 246 + engines: {node: '>=16.20.0'} 247 + cpu: [mips64el] 248 + os: [linux] 249 + 250 + '@typescript/typescript-linux-ppc64@7.0.2': 251 + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} 252 + engines: {node: '>=16.20.0'} 253 + cpu: [ppc64] 254 + os: [linux] 255 + 256 + '@typescript/typescript-linux-riscv64@7.0.2': 257 + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} 258 + engines: {node: '>=16.20.0'} 259 + cpu: [riscv64] 260 + os: [linux] 261 + 262 + '@typescript/typescript-linux-s390x@7.0.2': 263 + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} 264 + engines: {node: '>=16.20.0'} 265 + cpu: [s390x] 266 + os: [linux] 267 + 268 + '@typescript/typescript-linux-x64@7.0.2': 269 + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} 270 + engines: {node: '>=16.20.0'} 271 + cpu: [x64] 272 + os: [linux] 273 + 274 + '@typescript/typescript-netbsd-arm64@7.0.2': 275 + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} 276 + engines: {node: '>=16.20.0'} 277 + cpu: [arm64] 278 + os: [netbsd] 279 + 280 + '@typescript/typescript-netbsd-x64@7.0.2': 281 + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} 282 + engines: {node: '>=16.20.0'} 283 + cpu: [x64] 284 + os: [netbsd] 285 + 286 + '@typescript/typescript-openbsd-arm64@7.0.2': 287 + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} 288 + engines: {node: '>=16.20.0'} 289 + cpu: [arm64] 290 + os: [openbsd] 291 + 292 + '@typescript/typescript-openbsd-x64@7.0.2': 293 + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} 294 + engines: {node: '>=16.20.0'} 295 + cpu: [x64] 296 + os: [openbsd] 297 + 298 + '@typescript/typescript-sunos-x64@7.0.2': 299 + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} 300 + engines: {node: '>=16.20.0'} 301 + cpu: [x64] 302 + os: [sunos] 303 + 304 + '@typescript/typescript-win32-arm64@7.0.2': 305 + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} 306 + engines: {node: '>=16.20.0'} 307 + cpu: [arm64] 308 + os: [win32] 309 + 310 + '@typescript/typescript-win32-x64@7.0.2': 311 + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} 312 + engines: {node: '>=16.20.0'} 313 + cpu: [x64] 314 + os: [win32] 315 + 316 + '@vitejs/plugin-react@6.0.3': 317 + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} 318 + engines: {node: ^20.19.0 || >=22.12.0} 319 + peerDependencies: 320 + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 321 + babel-plugin-react-compiler: ^1.0.0 322 + vite: ^8.0.0 323 + peerDependenciesMeta: 324 + '@rolldown/plugin-babel': 325 + optional: true 326 + babel-plugin-react-compiler: 327 + optional: true 328 + 329 + '@vitest/expect@4.1.10': 330 + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} 331 + 332 + '@vitest/mocker@4.1.10': 333 + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} 334 + peerDependencies: 335 + msw: ^2.4.9 336 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 337 + peerDependenciesMeta: 338 + msw: 339 + optional: true 340 + vite: 341 + optional: true 342 + 343 + '@vitest/pretty-format@4.1.10': 344 + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} 345 + 346 + '@vitest/runner@4.1.10': 347 + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} 348 + 349 + '@vitest/snapshot@4.1.10': 350 + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} 351 + 352 + '@vitest/spy@4.1.10': 353 + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} 354 + 355 + '@vitest/utils@4.1.10': 356 + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} 357 + 358 + assertion-error@2.0.1: 359 + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} 360 + engines: {node: '>=12'} 361 + 362 + chai@6.2.2: 363 + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} 364 + engines: {node: '>=18'} 365 + 366 + convert-source-map@2.0.0: 367 + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} 368 + 369 + cookie@1.1.1: 370 + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} 371 + engines: {node: '>=18'} 372 + 373 + csstype@3.2.3: 374 + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} 375 + 376 + detect-libc@2.1.2: 377 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} 378 + engines: {node: '>=8'} 379 + 380 + es-module-lexer@2.3.0: 381 + resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} 382 + 383 + estree-walker@3.0.3: 384 + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} 385 + 386 + expect-type@1.4.0: 387 + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} 388 + engines: {node: '>=12.0.0'} 389 + 390 + fdir@6.5.0: 391 + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} 392 + engines: {node: '>=12.0.0'} 393 + peerDependencies: 394 + picomatch: ^3 || ^4 395 + peerDependenciesMeta: 396 + picomatch: 397 + optional: true 398 + 399 + fsevents@2.3.3: 400 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 401 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 402 + os: [darwin] 403 + 404 + lightningcss-android-arm64@1.32.0: 405 + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} 406 + engines: {node: '>= 12.0.0'} 407 + cpu: [arm64] 408 + os: [android] 409 + 410 + lightningcss-darwin-arm64@1.32.0: 411 + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} 412 + engines: {node: '>= 12.0.0'} 413 + cpu: [arm64] 414 + os: [darwin] 415 + 416 + lightningcss-darwin-x64@1.32.0: 417 + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} 418 + engines: {node: '>= 12.0.0'} 419 + cpu: [x64] 420 + os: [darwin] 421 + 422 + lightningcss-freebsd-x64@1.32.0: 423 + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} 424 + engines: {node: '>= 12.0.0'} 425 + cpu: [x64] 426 + os: [freebsd] 427 + 428 + lightningcss-linux-arm-gnueabihf@1.32.0: 429 + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} 430 + engines: {node: '>= 12.0.0'} 431 + cpu: [arm] 432 + os: [linux] 433 + 434 + lightningcss-linux-arm64-gnu@1.32.0: 435 + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} 436 + engines: {node: '>= 12.0.0'} 437 + cpu: [arm64] 438 + os: [linux] 439 + libc: [glibc] 440 + 441 + lightningcss-linux-arm64-musl@1.32.0: 442 + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} 443 + engines: {node: '>= 12.0.0'} 444 + cpu: [arm64] 445 + os: [linux] 446 + libc: [musl] 447 + 448 + lightningcss-linux-x64-gnu@1.32.0: 449 + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} 450 + engines: {node: '>= 12.0.0'} 451 + cpu: [x64] 452 + os: [linux] 453 + libc: [glibc] 454 + 455 + lightningcss-linux-x64-musl@1.32.0: 456 + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} 457 + engines: {node: '>= 12.0.0'} 458 + cpu: [x64] 459 + os: [linux] 460 + libc: [musl] 461 + 462 + lightningcss-win32-arm64-msvc@1.32.0: 463 + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} 464 + engines: {node: '>= 12.0.0'} 465 + cpu: [arm64] 466 + os: [win32] 467 + 468 + lightningcss-win32-x64-msvc@1.32.0: 469 + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} 470 + engines: {node: '>= 12.0.0'} 471 + cpu: [x64] 472 + os: [win32] 473 + 474 + lightningcss@1.32.0: 475 + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} 476 + engines: {node: '>= 12.0.0'} 477 + 478 + magic-string@0.30.21: 479 + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} 480 + 481 + nanoid@3.3.15: 482 + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} 483 + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 484 + hasBin: true 485 + 486 + obug@2.1.3: 487 + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} 488 + engines: {node: '>=12.20.0'} 489 + 490 + pathe@2.0.3: 491 + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} 492 + 493 + picocolors@1.1.1: 494 + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} 495 + 496 + picomatch@4.0.5: 497 + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} 498 + engines: {node: '>=12'} 499 + 500 + postcss@8.5.16: 501 + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} 502 + engines: {node: ^10 || ^12 || >=14} 503 + 504 + react-dom@19.2.7: 505 + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} 506 + peerDependencies: 507 + react: ^19.2.7 508 + 509 + react-router-dom@7.18.1: 510 + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} 511 + engines: {node: '>=20.0.0'} 512 + peerDependencies: 513 + react: '>=18' 514 + react-dom: '>=18' 515 + 516 + react-router@7.18.1: 517 + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} 518 + engines: {node: '>=20.0.0'} 519 + peerDependencies: 520 + react: '>=18' 521 + react-dom: '>=18' 522 + peerDependenciesMeta: 523 + react-dom: 524 + optional: true 525 + 526 + react@19.2.7: 527 + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} 528 + engines: {node: '>=0.10.0'} 529 + 530 + rolldown@1.1.5: 531 + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} 532 + engines: {node: ^20.19.0 || >=22.12.0} 533 + hasBin: true 534 + 535 + scheduler@0.27.0: 536 + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} 537 + 538 + set-cookie-parser@2.7.2: 539 + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} 540 + 541 + siginfo@2.0.0: 542 + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} 543 + 544 + source-map-js@1.2.1: 545 + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} 546 + engines: {node: '>=0.10.0'} 547 + 548 + stackback@0.0.2: 549 + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} 550 + 551 + std-env@4.2.0: 552 + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} 553 + 554 + tinybench@2.9.0: 555 + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} 556 + 557 + tinyexec@1.2.4: 558 + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} 559 + engines: {node: '>=18'} 560 + 561 + tinyglobby@0.2.17: 562 + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} 563 + engines: {node: '>=12.0.0'} 564 + 565 + tinyrainbow@3.1.0: 566 + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} 567 + engines: {node: '>=14.0.0'} 568 + 569 + tslib@2.8.1: 570 + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 571 + 572 + typescript@7.0.2: 573 + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} 574 + engines: {node: '>=16.20.0'} 575 + hasBin: true 576 + 577 + undici-types@8.3.0: 578 + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} 579 + 580 + vite@8.1.4: 581 + resolution: {integrity: sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==} 582 + engines: {node: ^20.19.0 || >=22.12.0} 583 + hasBin: true 584 + peerDependencies: 585 + '@types/node': ^20.19.0 || >=22.12.0 586 + '@vitejs/devtools': ^0.3.0 587 + esbuild: ^0.27.0 || ^0.28.0 588 + jiti: '>=1.21.0' 589 + less: ^4.0.0 590 + sass: ^1.70.0 591 + sass-embedded: ^1.70.0 592 + stylus: '>=0.54.8' 593 + sugarss: ^5.0.0 594 + terser: ^5.16.0 595 + tsx: ^4.8.1 596 + yaml: ^2.4.2 597 + peerDependenciesMeta: 598 + '@types/node': 599 + optional: true 600 + '@vitejs/devtools': 601 + optional: true 602 + esbuild: 603 + optional: true 604 + jiti: 605 + optional: true 606 + less: 607 + optional: true 608 + sass: 609 + optional: true 610 + sass-embedded: 611 + optional: true 612 + stylus: 613 + optional: true 614 + sugarss: 615 + optional: true 616 + terser: 617 + optional: true 618 + tsx: 619 + optional: true 620 + yaml: 621 + optional: true 622 + 623 + vitest@4.1.10: 624 + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} 625 + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} 626 + hasBin: true 627 + peerDependencies: 628 + '@edge-runtime/vm': '*' 629 + '@opentelemetry/api': ^1.9.0 630 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 631 + '@vitest/browser-playwright': 4.1.10 632 + '@vitest/browser-preview': 4.1.10 633 + '@vitest/browser-webdriverio': 4.1.10 634 + '@vitest/coverage-istanbul': 4.1.10 635 + '@vitest/coverage-v8': 4.1.10 636 + '@vitest/ui': 4.1.10 637 + happy-dom: '*' 638 + jsdom: '*' 639 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 640 + peerDependenciesMeta: 641 + '@edge-runtime/vm': 642 + optional: true 643 + '@opentelemetry/api': 644 + optional: true 645 + '@types/node': 646 + optional: true 647 + '@vitest/browser-playwright': 648 + optional: true 649 + '@vitest/browser-preview': 650 + optional: true 651 + '@vitest/browser-webdriverio': 652 + optional: true 653 + '@vitest/coverage-istanbul': 654 + optional: true 655 + '@vitest/coverage-v8': 656 + optional: true 657 + '@vitest/ui': 658 + optional: true 659 + happy-dom: 660 + optional: true 661 + jsdom: 662 + optional: true 663 + 664 + why-is-node-running@2.3.0: 665 + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} 666 + engines: {node: '>=8'} 667 + hasBin: true 668 + 669 + snapshots: 670 + 671 + '@cloudflare/workers-types@5.20260710.1': {} 672 + 673 + '@emnapi/core@1.11.1': 674 + dependencies: 675 + '@emnapi/wasi-threads': 1.2.2 676 + tslib: 2.8.1 677 + optional: true 678 + 679 + '@emnapi/runtime@1.11.1': 680 + dependencies: 681 + tslib: 2.8.1 682 + optional: true 683 + 684 + '@emnapi/wasi-threads@1.2.2': 685 + dependencies: 686 + tslib: 2.8.1 687 + optional: true 688 + 689 + '@jridgewell/sourcemap-codec@1.5.5': {} 690 + 691 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': 692 + dependencies: 693 + '@emnapi/core': 1.11.1 694 + '@emnapi/runtime': 1.11.1 695 + '@tybys/wasm-util': 0.10.3 696 + optional: true 697 + 698 + '@oxc-project/types@0.139.0': {} 699 + 700 + '@rolldown/binding-android-arm64@1.1.5': 701 + optional: true 702 + 703 + '@rolldown/binding-darwin-arm64@1.1.5': 704 + optional: true 705 + 706 + '@rolldown/binding-darwin-x64@1.1.5': 707 + optional: true 708 + 709 + '@rolldown/binding-freebsd-x64@1.1.5': 710 + optional: true 711 + 712 + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': 713 + optional: true 714 + 715 + '@rolldown/binding-linux-arm64-gnu@1.1.5': 716 + optional: true 717 + 718 + '@rolldown/binding-linux-arm64-musl@1.1.5': 719 + optional: true 720 + 721 + '@rolldown/binding-linux-ppc64-gnu@1.1.5': 722 + optional: true 723 + 724 + '@rolldown/binding-linux-s390x-gnu@1.1.5': 725 + optional: true 726 + 727 + '@rolldown/binding-linux-x64-gnu@1.1.5': 728 + optional: true 729 + 730 + '@rolldown/binding-linux-x64-musl@1.1.5': 731 + optional: true 732 + 733 + '@rolldown/binding-openharmony-arm64@1.1.5': 734 + optional: true 735 + 736 + '@rolldown/binding-wasm32-wasi@1.1.5': 737 + dependencies: 738 + '@emnapi/core': 1.11.1 739 + '@emnapi/runtime': 1.11.1 740 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) 741 + optional: true 742 + 743 + '@rolldown/binding-win32-arm64-msvc@1.1.5': 744 + optional: true 745 + 746 + '@rolldown/binding-win32-x64-msvc@1.1.5': 747 + optional: true 748 + 749 + '@rolldown/pluginutils@1.0.1': {} 750 + 751 + '@standard-schema/spec@1.1.0': {} 752 + 753 + '@tybys/wasm-util@0.10.3': 754 + dependencies: 755 + tslib: 2.8.1 756 + optional: true 757 + 758 + '@types/chai@5.2.3': 759 + dependencies: 760 + '@types/deep-eql': 4.0.2 761 + assertion-error: 2.0.1 762 + 763 + '@types/deep-eql@4.0.2': {} 764 + 765 + '@types/estree@1.0.9': {} 766 + 767 + '@types/node@26.1.1': 768 + dependencies: 769 + undici-types: 8.3.0 770 + 771 + '@types/react-dom@19.2.3(@types/react@19.2.17)': 772 + dependencies: 773 + '@types/react': 19.2.17 774 + 775 + '@types/react@19.2.17': 776 + dependencies: 777 + csstype: 3.2.3 778 + 779 + '@typescript/typescript-aix-ppc64@7.0.2': 780 + optional: true 781 + 782 + '@typescript/typescript-darwin-arm64@7.0.2': 783 + optional: true 784 + 785 + '@typescript/typescript-darwin-x64@7.0.2': 786 + optional: true 787 + 788 + '@typescript/typescript-freebsd-arm64@7.0.2': 789 + optional: true 790 + 791 + '@typescript/typescript-freebsd-x64@7.0.2': 792 + optional: true 793 + 794 + '@typescript/typescript-linux-arm64@7.0.2': 795 + optional: true 796 + 797 + '@typescript/typescript-linux-arm@7.0.2': 798 + optional: true 799 + 800 + '@typescript/typescript-linux-loong64@7.0.2': 801 + optional: true 802 + 803 + '@typescript/typescript-linux-mips64el@7.0.2': 804 + optional: true 805 + 806 + '@typescript/typescript-linux-ppc64@7.0.2': 807 + optional: true 808 + 809 + '@typescript/typescript-linux-riscv64@7.0.2': 810 + optional: true 811 + 812 + '@typescript/typescript-linux-s390x@7.0.2': 813 + optional: true 814 + 815 + '@typescript/typescript-linux-x64@7.0.2': 816 + optional: true 817 + 818 + '@typescript/typescript-netbsd-arm64@7.0.2': 819 + optional: true 820 + 821 + '@typescript/typescript-netbsd-x64@7.0.2': 822 + optional: true 823 + 824 + '@typescript/typescript-openbsd-arm64@7.0.2': 825 + optional: true 826 + 827 + '@typescript/typescript-openbsd-x64@7.0.2': 828 + optional: true 829 + 830 + '@typescript/typescript-sunos-x64@7.0.2': 831 + optional: true 832 + 833 + '@typescript/typescript-win32-arm64@7.0.2': 834 + optional: true 835 + 836 + '@typescript/typescript-win32-x64@7.0.2': 837 + optional: true 838 + 839 + '@vitejs/plugin-react@6.0.3(vite@8.1.4(@types/node@26.1.1))': 840 + dependencies: 841 + '@rolldown/pluginutils': 1.0.1 842 + vite: 8.1.4(@types/node@26.1.1) 843 + 844 + '@vitest/expect@4.1.10': 845 + dependencies: 846 + '@standard-schema/spec': 1.1.0 847 + '@types/chai': 5.2.3 848 + '@vitest/spy': 4.1.10 849 + '@vitest/utils': 4.1.10 850 + chai: 6.2.2 851 + tinyrainbow: 3.1.0 852 + 853 + '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@26.1.1))': 854 + dependencies: 855 + '@vitest/spy': 4.1.10 856 + estree-walker: 3.0.3 857 + magic-string: 0.30.21 858 + optionalDependencies: 859 + vite: 8.1.4(@types/node@26.1.1) 860 + 861 + '@vitest/pretty-format@4.1.10': 862 + dependencies: 863 + tinyrainbow: 3.1.0 864 + 865 + '@vitest/runner@4.1.10': 866 + dependencies: 867 + '@vitest/utils': 4.1.10 868 + pathe: 2.0.3 869 + 870 + '@vitest/snapshot@4.1.10': 871 + dependencies: 872 + '@vitest/pretty-format': 4.1.10 873 + '@vitest/utils': 4.1.10 874 + magic-string: 0.30.21 875 + pathe: 2.0.3 876 + 877 + '@vitest/spy@4.1.10': {} 878 + 879 + '@vitest/utils@4.1.10': 880 + dependencies: 881 + '@vitest/pretty-format': 4.1.10 882 + convert-source-map: 2.0.0 883 + tinyrainbow: 3.1.0 884 + 885 + assertion-error@2.0.1: {} 886 + 887 + chai@6.2.2: {} 888 + 889 + convert-source-map@2.0.0: {} 890 + 891 + cookie@1.1.1: {} 892 + 893 + csstype@3.2.3: {} 894 + 895 + detect-libc@2.1.2: {} 896 + 897 + es-module-lexer@2.3.0: {} 898 + 899 + estree-walker@3.0.3: 900 + dependencies: 901 + '@types/estree': 1.0.9 902 + 903 + expect-type@1.4.0: {} 904 + 905 + fdir@6.5.0(picomatch@4.0.5): 906 + optionalDependencies: 907 + picomatch: 4.0.5 908 + 909 + fsevents@2.3.3: 910 + optional: true 911 + 912 + lightningcss-android-arm64@1.32.0: 913 + optional: true 914 + 915 + lightningcss-darwin-arm64@1.32.0: 916 + optional: true 917 + 918 + lightningcss-darwin-x64@1.32.0: 919 + optional: true 920 + 921 + lightningcss-freebsd-x64@1.32.0: 922 + optional: true 923 + 924 + lightningcss-linux-arm-gnueabihf@1.32.0: 925 + optional: true 926 + 927 + lightningcss-linux-arm64-gnu@1.32.0: 928 + optional: true 929 + 930 + lightningcss-linux-arm64-musl@1.32.0: 931 + optional: true 932 + 933 + lightningcss-linux-x64-gnu@1.32.0: 934 + optional: true 935 + 936 + lightningcss-linux-x64-musl@1.32.0: 937 + optional: true 938 + 939 + lightningcss-win32-arm64-msvc@1.32.0: 940 + optional: true 941 + 942 + lightningcss-win32-x64-msvc@1.32.0: 943 + optional: true 944 + 945 + lightningcss@1.32.0: 946 + dependencies: 947 + detect-libc: 2.1.2 948 + optionalDependencies: 949 + lightningcss-android-arm64: 1.32.0 950 + lightningcss-darwin-arm64: 1.32.0 951 + lightningcss-darwin-x64: 1.32.0 952 + lightningcss-freebsd-x64: 1.32.0 953 + lightningcss-linux-arm-gnueabihf: 1.32.0 954 + lightningcss-linux-arm64-gnu: 1.32.0 955 + lightningcss-linux-arm64-musl: 1.32.0 956 + lightningcss-linux-x64-gnu: 1.32.0 957 + lightningcss-linux-x64-musl: 1.32.0 958 + lightningcss-win32-arm64-msvc: 1.32.0 959 + lightningcss-win32-x64-msvc: 1.32.0 960 + 961 + magic-string@0.30.21: 962 + dependencies: 963 + '@jridgewell/sourcemap-codec': 1.5.5 964 + 965 + nanoid@3.3.15: {} 966 + 967 + obug@2.1.3: {} 968 + 969 + pathe@2.0.3: {} 970 + 971 + picocolors@1.1.1: {} 972 + 973 + picomatch@4.0.5: {} 974 + 975 + postcss@8.5.16: 976 + dependencies: 977 + nanoid: 3.3.15 978 + picocolors: 1.1.1 979 + source-map-js: 1.2.1 980 + 981 + react-dom@19.2.7(react@19.2.7): 982 + dependencies: 983 + react: 19.2.7 984 + scheduler: 0.27.0 985 + 986 + react-router-dom@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): 987 + dependencies: 988 + react: 19.2.7 989 + react-dom: 19.2.7(react@19.2.7) 990 + react-router: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) 991 + 992 + react-router@7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7): 993 + dependencies: 994 + cookie: 1.1.1 995 + react: 19.2.7 996 + set-cookie-parser: 2.7.2 997 + optionalDependencies: 998 + react-dom: 19.2.7(react@19.2.7) 999 + 1000 + react@19.2.7: {} 1001 + 1002 + rolldown@1.1.5: 1003 + dependencies: 1004 + '@oxc-project/types': 0.139.0 1005 + '@rolldown/pluginutils': 1.0.1 1006 + optionalDependencies: 1007 + '@rolldown/binding-android-arm64': 1.1.5 1008 + '@rolldown/binding-darwin-arm64': 1.1.5 1009 + '@rolldown/binding-darwin-x64': 1.1.5 1010 + '@rolldown/binding-freebsd-x64': 1.1.5 1011 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 1012 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 1013 + '@rolldown/binding-linux-arm64-musl': 1.1.5 1014 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 1015 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 1016 + '@rolldown/binding-linux-x64-gnu': 1.1.5 1017 + '@rolldown/binding-linux-x64-musl': 1.1.5 1018 + '@rolldown/binding-openharmony-arm64': 1.1.5 1019 + '@rolldown/binding-wasm32-wasi': 1.1.5 1020 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 1021 + '@rolldown/binding-win32-x64-msvc': 1.1.5 1022 + 1023 + scheduler@0.27.0: {} 1024 + 1025 + set-cookie-parser@2.7.2: {} 1026 + 1027 + siginfo@2.0.0: {} 1028 + 1029 + source-map-js@1.2.1: {} 1030 + 1031 + stackback@0.0.2: {} 1032 + 1033 + std-env@4.2.0: {} 1034 + 1035 + tinybench@2.9.0: {} 1036 + 1037 + tinyexec@1.2.4: {} 1038 + 1039 + tinyglobby@0.2.17: 1040 + dependencies: 1041 + fdir: 6.5.0(picomatch@4.0.5) 1042 + picomatch: 4.0.5 1043 + 1044 + tinyrainbow@3.1.0: {} 1045 + 1046 + tslib@2.8.1: 1047 + optional: true 1048 + 1049 + typescript@7.0.2: 1050 + optionalDependencies: 1051 + '@typescript/typescript-aix-ppc64': 7.0.2 1052 + '@typescript/typescript-darwin-arm64': 7.0.2 1053 + '@typescript/typescript-darwin-x64': 7.0.2 1054 + '@typescript/typescript-freebsd-arm64': 7.0.2 1055 + '@typescript/typescript-freebsd-x64': 7.0.2 1056 + '@typescript/typescript-linux-arm': 7.0.2 1057 + '@typescript/typescript-linux-arm64': 7.0.2 1058 + '@typescript/typescript-linux-loong64': 7.0.2 1059 + '@typescript/typescript-linux-mips64el': 7.0.2 1060 + '@typescript/typescript-linux-ppc64': 7.0.2 1061 + '@typescript/typescript-linux-riscv64': 7.0.2 1062 + '@typescript/typescript-linux-s390x': 7.0.2 1063 + '@typescript/typescript-linux-x64': 7.0.2 1064 + '@typescript/typescript-netbsd-arm64': 7.0.2 1065 + '@typescript/typescript-netbsd-x64': 7.0.2 1066 + '@typescript/typescript-openbsd-arm64': 7.0.2 1067 + '@typescript/typescript-openbsd-x64': 7.0.2 1068 + '@typescript/typescript-sunos-x64': 7.0.2 1069 + '@typescript/typescript-win32-arm64': 7.0.2 1070 + '@typescript/typescript-win32-x64': 7.0.2 1071 + 1072 + undici-types@8.3.0: {} 1073 + 1074 + vite@8.1.4(@types/node@26.1.1): 1075 + dependencies: 1076 + lightningcss: 1.32.0 1077 + picomatch: 4.0.5 1078 + postcss: 8.5.16 1079 + rolldown: 1.1.5 1080 + tinyglobby: 0.2.17 1081 + optionalDependencies: 1082 + '@types/node': 26.1.1 1083 + fsevents: 2.3.3 1084 + 1085 + vitest@4.1.10(@types/node@26.1.1)(vite@8.1.4(@types/node@26.1.1)): 1086 + dependencies: 1087 + '@vitest/expect': 4.1.10 1088 + '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@26.1.1)) 1089 + '@vitest/pretty-format': 4.1.10 1090 + '@vitest/runner': 4.1.10 1091 + '@vitest/snapshot': 4.1.10 1092 + '@vitest/spy': 4.1.10 1093 + '@vitest/utils': 4.1.10 1094 + es-module-lexer: 2.3.0 1095 + expect-type: 1.4.0 1096 + magic-string: 0.30.21 1097 + obug: 2.1.3 1098 + pathe: 2.0.3 1099 + picomatch: 4.0.5 1100 + std-env: 4.2.0 1101 + tinybench: 2.9.0 1102 + tinyexec: 1.2.4 1103 + tinyglobby: 0.2.17 1104 + tinyrainbow: 3.1.0 1105 + vite: 8.1.4(@types/node@26.1.1) 1106 + why-is-node-running: 2.3.0 1107 + optionalDependencies: 1108 + '@types/node': 26.1.1 1109 + transitivePeerDependencies: 1110 + - msw 1111 + 1112 + why-is-node-running@2.3.0: 1113 + dependencies: 1114 + siginfo: 2.0.0 1115 + stackback: 0.0.2
+2
public/_redirects
··· 1 + # Cloudflare Pages SPA fallback. Pages Functions under /api/* are evaluated separately. 2 + /* /index.html 200
+21
src/App.tsx
··· 1 + import { Route, Routes } from 'react-router-dom'; 2 + import { AppShell } from './layout/AppShell'; 3 + import { FeedPage } from './pages/FeedPage'; 4 + import { DiscussionPage } from './pages/DiscussionPage'; 5 + import { SettingsPage } from './pages/SettingsPage'; 6 + import { NotFoundPage } from './pages/NotFoundPage'; 7 + import { useSettings } from './hooks/useSettings'; 8 + import './styles.css'; 9 + 10 + function App() { 11 + const { settings, setSettings, reset } = useSettings(); 12 + return <AppShell settings={settings}> 13 + <Routes> 14 + <Route path="/" element={<FeedPage settings={settings} onChange={setSettings}/>} /> 15 + <Route path="/story/:source/:id" element={<DiscussionPage settings={settings} onChange={setSettings}/>} /> 16 + <Route path="/settings" element={<SettingsPage settings={settings} onChange={setSettings} onReset={reset}/>} /> 17 + <Route path="*" element={<NotFoundPage/>} /> 18 + </Routes> 19 + </AppShell>; 20 + } 21 + export default App;
+41
src/api/hn.ts
··· 1 + import type { CommentNode, HnFeed, Story } from '../types'; 2 + import { checkedJson } from './http'; 3 + import { hnFirebaseEndpoint } from '../lib/sources'; 4 + import { normalizeHnFirebaseStory } from '../feed/normalizeStories'; 5 + import { normalizeHnComments } from '../comments/normalize'; 6 + 7 + const HN_FIREBASE = 'https://hacker-news.firebaseio.com/v0'; 8 + const HN_ALGOLIA = 'https://hn.algolia.com/api/v1'; 9 + 10 + interface AlgoliaItem { 11 + id?: number | string; 12 + author?: string | null; 13 + text?: string | null; 14 + created_at?: string | null; 15 + children?: AlgoliaItem[]; 16 + } 17 + 18 + export async function fetchHackerNewsStories(feed: HnFeed, limit = 45, signal?: AbortSignal): Promise<Story[]> { 19 + const ids = await checkedJson<unknown>(`${HN_FIREBASE}/${hnFirebaseEndpoint(feed)}.json`, { signal }); 20 + if (!Array.isArray(ids)) throw new Error('Hacker News returned invalid story ids'); 21 + const selectedIds = ids.slice(0, limit); 22 + 23 + const items = await Promise.all( 24 + selectedIds.map(async (id, index) => { 25 + try { 26 + const item = await checkedJson<unknown>(`${HN_FIREBASE}/item/${encodeURIComponent(String(id))}.json`, { signal }); 27 + return normalizeHnFirebaseStory(item, index); 28 + } catch (error) { 29 + if (error instanceof DOMException && error.name === 'AbortError') throw error; 30 + return null; 31 + } 32 + }) 33 + ); 34 + 35 + return items.filter((story): story is Story => Boolean(story)); 36 + } 37 + 38 + export async function fetchHackerNewsComments(story: Story, signal?: AbortSignal): Promise<CommentNode[]> { 39 + const root = await checkedJson<AlgoliaItem>(`${HN_ALGOLIA}/items/${encodeURIComponent(story.sourceId)}`, { signal }); 40 + return normalizeHnComments(root.children ?? []); 41 + }
+22
src/api/http.ts
··· 1 + export async function checkedJson<T>(url: URL | string, init?: RequestInit): Promise<T> { 2 + let response: Response; 3 + try { 4 + response = await fetch(url, init); 5 + } catch (error) { 6 + if (error instanceof DOMException && error.name === 'AbortError') throw error; 7 + throw new Error(`Could not reach ${url.toString()}`); 8 + } 9 + if (!response.ok) { 10 + let detail = ''; 11 + try { 12 + const payload = await response.json() as { error?: unknown }; 13 + detail = typeof payload.error === 'string' ? payload.error : ''; 14 + } catch { /* ignore non-json errors */ } 15 + throw new Error(`Request failed with ${response.status}${detail ? `: ${detail}` : ''}`); 16 + } 17 + try { 18 + return await response.json() as T; 19 + } catch { 20 + throw new Error('Response was not valid JSON'); 21 + } 22 + }
+52
src/api/index.ts
··· 1 + import { fetchHackerNewsComments, fetchHackerNewsStories } from './hn'; 2 + import { fetchLobstersComments, fetchLobstersStories } from './lobsters'; 3 + import { checkedJson } from './http'; 4 + import { appendSourceEnabled } from '../lib/sources'; 5 + import type { FeedResult, ReaderSettings, Source, Story } from '../types'; 6 + 7 + export type { FeedResult } from '../types'; 8 + 9 + function feedApiUrl(settings: ReaderSettings): URL { 10 + const configured = import.meta.env.VITE_FEED_API_URL?.trim(); 11 + const url = new URL(configured || '/api/feed', window.location.origin); 12 + url.searchParams.set('hn', settings.hnFeed); 13 + url.searchParams.set('lobsters', settings.lobstersFeed); 14 + appendSourceEnabled(url.searchParams, settings.sourceEnabled); 15 + return url; 16 + } 17 + 18 + export async function fetchCombinedFeed(settings: ReaderSettings, signal?: AbortSignal): Promise<FeedResult> { 19 + try { 20 + const payload = await checkedJson<FeedResult>(feedApiUrl(settings), { signal }); 21 + if (Array.isArray(payload.stories)) { 22 + return { 23 + stories: payload.stories.filter(story => settings.sourceEnabled[story.source]), 24 + errors: Object.fromEntries(Object.entries(payload.errors ?? {}).filter(([source]) => settings.sourceEnabled[source as Source])) as FeedResult['errors'] 25 + }; 26 + } 27 + } catch (error) { 28 + if (error instanceof DOMException && error.name === 'AbortError') throw error; 29 + // Pure static hosts fall back to public APIs below. 30 + } 31 + 32 + const results = await Promise.allSettled([ 33 + settings.sourceEnabled.hn ? fetchHackerNewsStories(settings.hnFeed, 24, signal) : Promise.resolve([]), 34 + settings.sourceEnabled.lobsters ? fetchLobstersStories(settings.lobstersFeed, 30, signal) : Promise.resolve([]) 35 + ]); 36 + 37 + const errors: Partial<Record<Source, string>> = {}; 38 + const stories: Story[] = []; 39 + 40 + const [hnResult, lobstersResult] = results; 41 + if (hnResult.status === 'fulfilled') stories.push(...hnResult.value); 42 + else errors.hn = hnResult.reason instanceof Error ? hnResult.reason.message : 'Hacker News failed'; 43 + 44 + if (lobstersResult.status === 'fulfilled') stories.push(...lobstersResult.value); 45 + else errors.lobsters = lobstersResult.reason instanceof Error ? lobstersResult.reason.message : 'Lobsters failed'; 46 + 47 + return { stories, errors }; 48 + } 49 + 50 + export async function fetchComments(story: Story, signal?: AbortSignal) { 51 + return story.source === 'hn' ? fetchHackerNewsComments(story, signal) : fetchLobstersComments(story, signal); 52 + }
+39
src/api/lobsters.ts
··· 1 + import type { CommentNode, LobstersFeed, Story } from '../types'; 2 + import { checkedJson } from './http'; 3 + import { normalizeLobstersJsonStory } from '../feed/normalizeStories'; 4 + import { normalizeFlatLobstersComments, normalizeNestedLobstersComments, record } from '../comments/normalize'; 5 + import { lobstersJsonPath } from '../lib/sources'; 6 + 7 + const DEFAULT_LOBSTERS_PROXY = '/api/lobsters'; 8 + 9 + function proxyBase(): string { 10 + const configured = import.meta.env.VITE_API_PROXY_URL?.trim(); 11 + return (configured || DEFAULT_LOBSTERS_PROXY).replace(/\/+$/, ''); 12 + } 13 + function lobstersUrl(path: string): string { return `${proxyBase()}/${path.replace(/^\//, '')}`; } 14 + 15 + async function proxyJson(path: string, signal?: AbortSignal): Promise<unknown> { 16 + try { 17 + return await checkedJson<unknown>(lobstersUrl(path), { signal }); 18 + } catch (error) { 19 + if (error instanceof DOMException && error.name === 'AbortError') throw error; 20 + throw new Error(error instanceof Error ? error.message.replace('Request failed', 'Lobsters proxy returned') : `Could not reach the Lobsters proxy at ${proxyBase()}`); 21 + } 22 + } 23 + 24 + export async function fetchLobstersStories(feed: LobstersFeed, limit = 45, signal?: AbortSignal): Promise<Story[]> { 25 + const payload = await proxyJson(lobstersJsonPath(feed), signal); 26 + if (!Array.isArray(payload)) throw new Error('Lobsters proxy returned an unexpected feed payload'); 27 + return payload.slice(0, limit).map(normalizeLobstersJsonStory).filter((story): story is Story => story !== null); 28 + } 29 + 30 + export async function fetchLobstersComments(story: Story, signal?: AbortSignal): Promise<CommentNode[]> { 31 + const detail = record(await proxyJson(`s/${encodeURIComponent(story.sourceId)}.json`, signal)); 32 + if (!detail) throw new Error('Lobsters proxy returned an unexpected discussion payload'); 33 + const comments = Array.isArray(detail.comments) ? detail.comments : []; 34 + const flat = comments.every(comment => record(comment)) && comments.some(comment => { 35 + const item = record(comment)!; 36 + return 'parent_comment' in item || 'parent_comment_id' in item; 37 + }); 38 + return flat ? normalizeFlatLobstersComments(comments) : normalizeNestedLobstersComments(comments); 39 + }
+34
src/comments/normalize.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { normalizeFlatLobstersComments, normalizeHnComments, normalizeNestedLobstersComments } from './normalize'; 3 + 4 + describe('comment normalization', () => { 5 + it('normalizes HN empty parents with children', () => { 6 + const comments = normalizeHnComments([{ id: 1, author: null, text: '', children: [{ id: 2, author: 'a', text: '<p>reply</p>', children: [] }] }]); 7 + expect(comments).toHaveLength(1); 8 + expect(comments[0].children[0].author).toBe('a'); 9 + }); 10 + 11 + it('builds deterministic flat Lobsters trees with commenting_user', () => { 12 + const payload = [ 13 + { short_id: 'a', parent_comment: null, comment: '<p>root</p>', commenting_user: 'alice' }, 14 + { short_id: 'b', parent_comment: 'a', comment: '<p>child</p>', commenting_user: 'bob' } 15 + ]; 16 + const first = normalizeFlatLobstersComments(payload); 17 + const second = normalizeFlatLobstersComments(payload); 18 + expect(first).toEqual(second); 19 + expect(first[0].children[0]).toMatchObject({ id: 'b', author: 'bob' }); 20 + }); 21 + 22 + it('keeps Lobsters orphans as roots and disambiguates duplicate ids', () => { 23 + const comments = normalizeFlatLobstersComments([ 24 + { short_id: 'dup', parent_comment: 'missing', comment: '<p>orphan</p>' }, 25 + { short_id: 'dup', parent_comment: null, comment: '<p>duplicate</p>' } 26 + ]); 27 + expect(comments.map(comment => comment.id)).toEqual(['dup', 'dup-2']); 28 + }); 29 + 30 + it('uses stable fallback ids for nested Lobsters comments', () => { 31 + const payload = [{ comment: '<p>no id</p>', commenting_user: 'zoe', comments: [] }]; 32 + expect(normalizeNestedLobstersComments(payload)[0].id).toBe(normalizeNestedLobstersComments(payload)[0].id); 33 + }); 34 + });
+119
src/comments/normalize.ts
··· 1 + import type { CommentNode } from '../types'; 2 + 3 + type UnknownRecord = Record<string, unknown>; 4 + 5 + export function record(value: unknown): UnknownRecord | null { 6 + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : null; 7 + } 8 + export function string(value: unknown): string | undefined { 9 + if (typeof value === 'string') return value; 10 + if (typeof value === 'number') return String(value); 11 + return undefined; 12 + } 13 + 14 + function stableGeneratedId(prefix: string, path: readonly number[], html: string, author?: string): string { 15 + let hash = 5381; 16 + const input = `${author ?? ''}|${html}|${path.join('.')}`; 17 + for (let i = 0; i < input.length; i += 1) hash = ((hash << 5) + hash) ^ input.charCodeAt(i); 18 + return `${prefix}-${path.join('-') || 'root'}-${(hash >>> 0).toString(36)}`; 19 + } 20 + 21 + function uniqueId(base: string, seen: Map<string, number>): string { 22 + const count = seen.get(base) ?? 0; 23 + seen.set(base, count + 1); 24 + return count === 0 ? base : `${base}-${count + 1}`; 25 + } 26 + 27 + interface AlgoliaItem { 28 + id?: number | string; 29 + author?: string | null; 30 + text?: string | null; 31 + created_at?: string | null; 32 + children?: AlgoliaItem[]; 33 + } 34 + 35 + export function normalizeHnComments(comments: AlgoliaItem[], path: number[] = [], seen = new Map<string, number>()): CommentNode[] { 36 + return comments.map((comment, index): CommentNode | null => { 37 + const currentPath = [...path, index]; 38 + const html = comment.text ?? ''; 39 + const children = normalizeHnComments(comment.children ?? [], currentPath, seen); 40 + if (!html.trim() && children.length === 0) return null; 41 + const rawId = comment.id !== undefined ? String(comment.id) : stableGeneratedId('hn-comment', currentPath, html, comment.author ?? undefined); 42 + return { 43 + id: uniqueId(rawId, seen), 44 + author: comment.author ?? undefined, 45 + html, 46 + createdAt: comment.created_at ?? undefined, 47 + children 48 + } satisfies CommentNode; 49 + }).filter((comment): comment is CommentNode => comment !== null); 50 + } 51 + 52 + export function normalizeNestedLobstersComments(comments: unknown[], path: number[] = [], seen = new Map<string, number>()): CommentNode[] { 53 + return comments.map((value, index): CommentNode | null => { 54 + const comment = record(value); 55 + if (!comment || path.length > 100) return null; 56 + const currentPath = [...path, index]; 57 + const html = string(comment.comment) ?? string(comment.text) ?? ''; 58 + const rawChildren = Array.isArray(comment.comments) ? comment.comments : Array.isArray(comment.children) ? comment.children : []; 59 + const children = normalizeNestedLobstersComments(rawChildren, currentPath, seen); 60 + if (!html.trim() && children.length === 0) return null; 61 + const author = string(comment.commenting_user) ?? string(comment.commenter_user) ?? string(comment.user); 62 + const rawId = string(comment.short_id) ?? string(comment.id) ?? stableGeneratedId('lobsters-comment', currentPath, html, author); 63 + return { id: uniqueId(rawId, seen), author, html, createdAt: string(comment.created_at), children } satisfies CommentNode; 64 + }).filter((comment): comment is CommentNode => comment !== null); 65 + } 66 + 67 + export function normalizeFlatLobstersComments(comments: unknown[]): CommentNode[] { 68 + const sourceRows = comments.map(record).filter((comment): comment is UnknownRecord => comment !== null); 69 + const nodes = new Map<string, CommentNode>(); 70 + const parents = new Map<string, string | undefined>(); 71 + const order: string[] = []; 72 + const seen = new Map<string, number>(); 73 + 74 + sourceRows.forEach((comment, index) => { 75 + const html = string(comment.comment) ?? string(comment.text) ?? ''; 76 + const author = string(comment.commenting_user) ?? string(comment.commenter_user) ?? string(comment.user); 77 + const rawId = string(comment.short_id) ?? string(comment.id) ?? stableGeneratedId('lobsters-comment', [index], html, author); 78 + const id = uniqueId(rawId, seen); 79 + const node = { id, author, html, createdAt: string(comment.created_at), children: [] } satisfies CommentNode; 80 + nodes.set(id, node); 81 + order.push(id); 82 + parents.set(id, string(comment.parent_comment) ?? string(comment.parent_comment_id)); 83 + }); 84 + 85 + const roots: CommentNode[] = []; 86 + const attached = new Set<string>(); 87 + for (const id of order) { 88 + const node = nodes.get(id); 89 + if (!node) continue; 90 + const parent = parents.get(id); 91 + if (parent && parent !== id && nodes.has(parent) && !wouldCreateCycle(parent, id, parents)) { 92 + nodes.get(parent)!.children.push(node); 93 + attached.add(id); 94 + } 95 + } 96 + for (const id of order) { 97 + const node = nodes.get(id); 98 + if (!node || attached.has(id)) continue; 99 + if (!node.html.trim() && node.children.length === 0) continue; 100 + roots.push(node); 101 + } 102 + return pruneEmpty(roots); 103 + } 104 + 105 + function wouldCreateCycle(parent: string, child: string, parents: Map<string, string | undefined>): boolean { 106 + let current: string | undefined = parent; 107 + const seen = new Set<string>([child]); 108 + while (current) { 109 + if (seen.has(current)) return true; 110 + seen.add(current); 111 + current = parents.get(current); 112 + } 113 + return false; 114 + } 115 + 116 + function pruneEmpty(nodes: CommentNode[]): CommentNode[] { 117 + return nodes.map(node => ({ ...node, children: pruneEmpty(node.children) })) 118 + .filter(node => node.html.trim() || node.children.length > 0); 119 + }
+20
src/components/SettingsPanel.tsx
··· 1 + import type { ReaderSettings } from '../types'; 2 + import { AppearanceSettings } from './settings/AppearanceSettings'; 3 + import { SourceSettings } from './settings/SourceSettings'; 4 + import { RankingSettings } from './settings/RankingSettings'; 5 + 6 + interface SettingsPanelProps { 7 + settings: ReaderSettings; 8 + onChange: (settings: ReaderSettings) => void; 9 + onReset: () => void; 10 + } 11 + 12 + export function SettingsPanel({ settings, onChange, onReset }: SettingsPanelProps) { 13 + return <section className="settings-panel" aria-labelledby="settings-title"> 14 + <div className="settings-header"><p className="eyebrow">Local Preferences</p><h1 id="settings-title">Reading settings</h1><p className="settings-subtitle">Stored locally in your browser. These settings adjust layout, typography, and feed ranking.</p></div> 15 + <AppearanceSettings settings={settings} onChange={onChange}/> 16 + <SourceSettings settings={settings} onChange={onChange}/> 17 + <RankingSettings settings={settings} onChange={onChange}/> 18 + <div className="settings-actions"><button type="button" className="reset-button" onClick={onReset}>Reset all preferences</button></div> 19 + </section>; 20 + }
+26
src/components/StoryCard.tsx
··· 1 + import { Link } from 'react-router-dom'; 2 + import type { RankedStory } from '../types'; 3 + import { hostnameLabel, articleUrl, sourceLabel } from '../lib/sources'; 4 + import { timeAgo } from '../lib/format'; 5 + 6 + interface StoryCardProps { story: RankedStory; featured?: boolean; onReadHere?: (story: RankedStory) => void; } 7 + 8 + export function StoryCard({ story, featured = false, onReadHere }: StoryCardProps) { 9 + const host = hostnameLabel(story.url); 10 + const hasExternalUrl = Boolean(articleUrl(story)); 11 + return <article className={`story-card ${featured ? 'featured' : ''} source-${story.source}`}> 12 + <div className="story-kicker"><span className={`source-badge badge-${story.source}`}>{sourceLabel(story.source)}</span><span className="story-points">{story.points} pts</span></div> 13 + <h2 className="story-title"> 14 + <a href={story.url ?? story.discussionUrl} target="_blank" rel="noreferrer noopener" className="story-link">{story.title}</a> 15 + {host && <span className="title-host">({host})</span>} 16 + {story.tags && story.tags.length > 1 ? <span className="title-tags">{story.tags.slice(1, 5).map(tag => <span key={tag} className={`tag tag-${tag.toLowerCase()}`}>{tag}</span>)}</span> : null} 17 + </h2> 18 + <div className="story-meta"> 19 + <span className="story-author">by {story.author ?? 'unknown'}</span> 20 + <span className="story-time">{timeAgo(story.createdAt)}</span> 21 + <span className="meta-separator">•</span> 22 + <Link to={`/story/${story.source}/${encodeURIComponent(story.sourceId)}`} state={{ story }} className="story-comments-link">{story.commentsCount} comments</Link> 23 + {hasExternalUrl ? <><span className="meta-separator">•</span><button type="button" className="story-read-link" onClick={() => onReadHere?.(story)}>read here</button></> : null} 24 + </div> 25 + </article>; 26 + }
+40
src/components/discussion/CommentThread.tsx
··· 1 + import type { MutableRefObject } from 'react'; 2 + import type { CommentNode, Source } from '../../types'; 3 + import { SOURCE_ORIGIN } from '../../lib/sources'; 4 + import { timeAgo } from '../../lib/format'; 5 + import { sanitizeHtml } from '../../sanitize'; 6 + 7 + interface CommentThreadProps { 8 + node: CommentNode; 9 + depth: number; 10 + collapsed: Set<string>; 11 + active: string | null; 12 + visibleIds: string[]; 13 + refs: MutableRefObject<Map<string, HTMLElement>>; 14 + toggle: (id: string) => void; 15 + moveFrom: (id: string, direction: number) => void; 16 + storyAuthor?: string; 17 + source: Source; 18 + } 19 + 20 + export function CommentThread({ node, depth, collapsed, active, visibleIds, refs, toggle, moveFrom, storyAuthor, source }: CommentThreadProps) { 21 + const hasChildren = node.children.length > 0; 22 + const isCollapsed = collapsed.has(node.id); 23 + const isAuthor = Boolean(node.author && storyAuthor && node.author === storyAuthor); 24 + const visibleIndex = visibleIds.indexOf(node.id); 25 + const hasPrevious = visibleIndex > 0; 26 + const hasNext = visibleIndex >= 0 && visibleIndex < visibleIds.length - 1; 27 + return <li className={`comment ${active === node.id ? 'active' : ''}`}> 28 + <div className="comment-content" ref={element => { if (element) refs.current.set(node.id, element); }} tabIndex={-1}> 29 + <div className="comment-byline"> 30 + <button className="comment-toggle" onClick={() => toggle(node.id)} aria-expanded={!isCollapsed} title={isCollapsed ? 'Expand comment' : 'Collapse comment'}>{isCollapsed ? '+' : '−'}</button> 31 + <strong className={isAuthor ? 'comment-author op' : 'comment-author'}>{node.author ?? 'anonymous'}{isAuthor && <span className="op-badge">OP</span>}</strong> 32 + <time>{timeAgo(node.createdAt)}</time> 33 + {hasPrevious && <button onClick={() => moveFrom(node.id, -1)} aria-label={`Previous comment before ${node.author ?? 'anonymous'}`}>↑ prev</button>} 34 + {hasNext && <button onClick={() => moveFrom(node.id, 1)} aria-label={`Next comment after ${node.author ?? 'anonymous'}`}>↓ next</button>} 35 + </div> 36 + {!isCollapsed && <div className="comment-body" dangerouslySetInnerHTML={{ __html: sanitizeHtml(node.html, SOURCE_ORIGIN[source]) }}/>} 37 + </div> 38 + {hasChildren && !isCollapsed && (depth >= 40 ? <p className="muted">Further replies are available on the original thread.</p> : <ol className="comment-list">{node.children.map(child => <CommentThread key={child.id} node={child} depth={depth + 1} collapsed={collapsed} active={active} visibleIds={visibleIds} refs={refs} toggle={toggle} moveFrom={moveFrom} storyAuthor={storyAuthor} source={source}/>)}</ol>)} 39 + </li>; 40 + }
+23
src/components/discussion/DiscussionToolbar.tsx
··· 1 + import type { ReaderSettings } from '../../types'; 2 + import { QuickSettings } from '../reader/QuickSettings'; 3 + 4 + interface DiscussionToolbarProps { 5 + commentsCount: number; 6 + readHere: boolean; 7 + settings: ReaderSettings; 8 + onChange: (settings: ReaderSettings) => void; 9 + onCloseReader: () => void; 10 + } 11 + 12 + export function DiscussionToolbar({ commentsCount, readHere, settings, onChange, onCloseReader }: DiscussionToolbarProps) { 13 + return <div className="discussion-heading"> 14 + <h2>Discussion</h2> 15 + <div className="discussion-meta-group"> 16 + <span className="comments-count">{commentsCount} comments</span> 17 + <QuickSettings settings={settings} onChange={onChange} /> 18 + {readHere && <button className="reader-close-btn" onClick={onCloseReader} title="Close split reader" aria-label="Close reader and return to feed"> 19 + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="close-icon" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> 20 + </button>} 21 + </div> 22 + </div>; 23 + }
+65
src/components/discussion/DiscussionView.tsx
··· 1 + import { useEffect, useRef, useState } from 'react'; 2 + import { Link } from 'react-router-dom'; 3 + import type { ReaderSettings, Story } from '../../types'; 4 + import { articleUrl, sourceLabel } from '../../lib/sources'; 5 + import { useComments } from '../../hooks/useComments'; 6 + import { useCommentNavigation } from '../../hooks/useCommentNavigation'; 7 + import { CommentThread } from './CommentThread'; 8 + import { DiscussionToolbar } from './DiscussionToolbar'; 9 + import { SplitReader } from '../reader/SplitReader'; 10 + 11 + interface DiscussionViewProps { 12 + story: Story; 13 + settings: ReaderSettings; 14 + onChange: (settings: ReaderSettings) => void; 15 + initialReadHere?: boolean; 16 + overlay?: boolean; 17 + onClose?: () => void; 18 + } 19 + 20 + export function DiscussionView({ story, settings, onChange, initialReadHere = false, overlay = false, onClose }: DiscussionViewProps) { 21 + const [collapsed, setCollapsed] = useState(new Set<string>()); 22 + const [readHere, setReadHere] = useState(initialReadHere); 23 + const dialogRef = useRef<HTMLDivElement>(null); 24 + const { comments, error, loading } = useComments(story); 25 + const { active, refs, visibleIds, moveFrom } = useCommentNavigation(comments, collapsed); 26 + const currentArticleUrl = articleUrl(story); 27 + 28 + useEffect(() => setReadHere(initialReadHere), [story.source, story.sourceId, initialReadHere]); 29 + 30 + useEffect(() => { 31 + if (!overlay) return; 32 + dialogRef.current?.focus(); 33 + const onKeyDown = (event: KeyboardEvent) => { 34 + if (event.key === 'Escape') onClose?.(); 35 + }; 36 + window.addEventListener('keydown', onKeyDown); 37 + return () => window.removeEventListener('keydown', onKeyDown); 38 + }, [overlay, onClose]); 39 + 40 + const toggle = (commentId: string) => setCollapsed(old => { 41 + const next = new Set(old); 42 + next.has(commentId) ? next.delete(commentId) : next.add(commentId); 43 + return next; 44 + }); 45 + const closeReader = () => onClose ? onClose() : setReadHere(false); 46 + 47 + const discussionColumn = <section className="discussion-column" aria-label="Discussion" style={readHere ? { flex: 1 } : undefined}> 48 + <DiscussionToolbar commentsCount={visibleIds.length} readHere={readHere} settings={settings} onChange={onChange} onCloseReader={closeReader} /> 49 + {loading ? <p className="loader">Fetching discussion…</p> : error ? <p className="error-card">{error}</p> : comments.length ? <ol className="comment-list">{comments.map(node => <CommentThread key={node.id} node={node} depth={0} collapsed={collapsed} active={active} visibleIds={visibleIds} refs={refs} toggle={toggle} moveFrom={moveFrom} storyAuthor={story.author} source={story.source}/>)}</ol> : <p className="muted">No comments yet.</p>} 50 + </section>; 51 + 52 + return <div className={`discussion-page ${readHere ? 'reading' : ''} ${overlay ? 'discussion-overlay' : ''}`} ref={dialogRef} role={overlay ? 'dialog' : undefined} aria-modal={overlay ? true : undefined} aria-label={overlay ? `${story.title} reader and discussion` : undefined} tabIndex={overlay ? -1 : undefined}> 53 + {!overlay && <header className="story-header"> 54 + <Link to="/" className="back">← Front page</Link> 55 + <p className="section-label">{sourceLabel(story.source)}</p> 56 + <h1>{story.title}</h1> 57 + <div className="story-actions"> 58 + {currentArticleUrl && <button className={readHere ? 'selected' : ''} onClick={() => setReadHere(value => !value)}>{readHere ? 'Close reader' : 'Read here'}</button>} 59 + {currentArticleUrl && <a href={currentArticleUrl} target="_blank" rel="noreferrer">Open article ↗</a>} 60 + <a href={story.discussionUrl} target="_blank" rel="noreferrer">Original discussion ↗</a> 61 + </div> 62 + </header>} 63 + {readHere && currentArticleUrl ? <SplitReader articleUrl={currentArticleUrl} title={story.title}>{discussionColumn}</SplitReader> : <div className="reading-layout">{discussionColumn}</div>} 64 + </div>; 65 + }
+43
src/components/reader/QuickSettings.tsx
··· 1 + import { useEffect, useRef, useState } from 'react'; 2 + import type { ReaderSettings } from '../../types'; 3 + 4 + interface QuickSettingsProps { 5 + settings: ReaderSettings; 6 + onChange: (settings: ReaderSettings) => void; 7 + } 8 + 9 + export function QuickSettings({ settings, onChange }: QuickSettingsProps) { 10 + const [showSettings, setShowSettings] = useState(false); 11 + const popoverRef = useRef<HTMLDivElement>(null); 12 + 13 + useEffect(() => { 14 + const handleClickOutside = (event: MouseEvent) => { 15 + if (showSettings && popoverRef.current && !popoverRef.current.contains(event.target as Node)) setShowSettings(false); 16 + }; 17 + document.addEventListener('mousedown', handleClickOutside); 18 + return () => document.removeEventListener('mousedown', handleClickOutside); 19 + }, [showSettings]); 20 + 21 + return <div className="quick-settings-container" ref={popoverRef}> 22 + <button className={`quick-settings-trigger ${showSettings ? 'active' : ''}`} onClick={() => setShowSettings(value => !value)} title="Typography & Theme options" aria-haspopup="true" aria-expanded={showSettings}> 23 + <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="gear-icon" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg> 24 + </button> 25 + {showSettings && <div className="quick-settings-popover"> 26 + <div className="popover-row"><span className="popover-label">Theme</span><div className="quick-settings-group themes" role="group" aria-label="Theme selection"> 27 + <button className={`quick-settings-btn theme-light-btn ${settings.theme === 'light' ? 'active' : ''}`} onClick={() => onChange({ ...settings, theme: 'light' })} title="Light Theme">A</button> 28 + <button className={`quick-settings-btn theme-sepia-btn ${settings.theme === 'sepia' ? 'active' : ''}`} onClick={() => onChange({ ...settings, theme: 'sepia' })} title="Sepia Theme">A</button> 29 + <button className={`quick-settings-btn theme-dark-btn ${settings.theme === 'dark' ? 'active' : ''}`} onClick={() => onChange({ ...settings, theme: 'dark' })} title="Dark Theme">A</button> 30 + </div></div> 31 + <div className="popover-row"><span className="popover-label">Font</span><div className="quick-settings-group fonts" role="group" aria-label="Font selection"> 32 + <button className={`quick-settings-btn ${settings.font === 'serif' ? 'active' : ''}`} onClick={() => onChange({ ...settings, font: 'serif' })}>Serif</button> 33 + <button className={`quick-settings-btn ${settings.font === 'system' ? 'active' : ''}`} onClick={() => onChange({ ...settings, font: 'system' })}>Sans</button> 34 + <button className={`quick-settings-btn ${settings.font === 'mono' ? 'active' : ''}`} onClick={() => onChange({ ...settings, font: 'mono' })}>Mono</button> 35 + </div></div> 36 + <div className="popover-row"><span className="popover-label">Size</span><div className="quick-settings-group sizes" role="group" aria-label="Font size adjustment"> 37 + <button className="quick-settings-btn" onClick={() => onChange({ ...settings, typeScale: Math.max(0.85, settings.typeScale - 0.05) })} title="Decrease font size">A−</button> 38 + <span className="current-scale">{Math.round(settings.typeScale * 100)}%</span> 39 + <button className="quick-settings-btn" onClick={() => onChange({ ...settings, typeScale: Math.min(1.30, settings.typeScale + 0.05) })} title="Increase font size">A+</button> 40 + </div></div> 41 + </div>} 42 + </div>; 43 + }
+24
src/components/reader/SplitReader.tsx
··· 1 + import { useRef, type ReactNode } from 'react'; 2 + import { useSplitPane } from '../../hooks/useSplitPane'; 3 + 4 + interface SplitReaderProps { 5 + articleUrl: string; 6 + title: string; 7 + children: ReactNode; 8 + } 9 + 10 + export function SplitReader({ articleUrl, title, children }: SplitReaderProps) { 11 + const rootRef = useRef<HTMLDivElement>(null); 12 + const { splitWidth, startResizing, nudgeSplit } = useSplitPane(60); 13 + return <div className="reading-layout" ref={rootRef}> 14 + <section className="article-pane" aria-label="Original article" style={{ width: `${splitWidth}%` }}> 15 + <div className="iframe-note"><span>Some publishers block embedded viewing.</span><a href={articleUrl} target="_blank" rel="noreferrer" className="open-tab-link"><span>Open in a new tab</span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="external-icon" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg></a></div> 16 + <iframe src={articleUrl} title={title} sandbox="allow-scripts allow-same-origin allow-forms allow-popups" /> 17 + </section> 18 + <div className="resize-handle" role="separator" aria-orientation="vertical" aria-label="Resize article and discussion panes" aria-valuemin={20} aria-valuemax={80} aria-valuenow={Math.round(splitWidth)} tabIndex={0} onMouseDown={event => startResizing(event, rootRef.current)} onKeyDown={event => { 19 + if (event.key === 'ArrowLeft') { event.preventDefault(); nudgeSplit(-5); } 20 + if (event.key === 'ArrowRight') { event.preventDefault(); nudgeSplit(5); } 21 + }} /> 22 + {children} 23 + </div>; 24 + }
+14
src/components/settings/AppearanceSettings.tsx
··· 1 + import type { Density, FontChoice, LayoutChoice, ReaderSettings, Theme } from '../../types'; 2 + import { numberValue } from './settingsHelpers'; 3 + 4 + export function AppearanceSettings({ settings, onChange }: { settings: ReaderSettings; onChange: (settings: ReaderSettings) => void }) { 5 + return <div className="settings-section"><h2 className="section-title">Appearance</h2><div className="settings-grid"> 6 + <div className="settings-control"><span className="control-label">Theme</span><select value={settings.theme} onChange={e => onChange({ ...settings, theme: e.target.value as Theme })}><option value="light">Light</option><option value="dark">Dark</option><option value="sepia">Sepia</option></select></div> 7 + <div className="settings-control"><span className="control-label">Font Family</span><select value={settings.font} onChange={e => onChange({ ...settings, font: e.target.value as FontChoice })}><option value="serif">Editorial serif</option><option value="system">System sans</option><option value="mono">Monospace</option></select></div> 8 + <div className="settings-control"><div className="control-header"><span className="control-label">Type scale</span><output className="control-output">{settings.typeScale.toFixed(2)}×</output></div><input type="range" min="0.85" max="1.3" step="0.05" value={settings.typeScale} onChange={e => onChange({ ...settings, typeScale: numberValue(e.target.value, settings.typeScale) })}/></div> 9 + <div className="settings-control"><span className="control-label">Density</span><select value={settings.density} onChange={e => onChange({ ...settings, density: e.target.value as Density })}><option value="relaxed">Relaxed</option><option value="comfortable">Comfortable</option><option value="compact">Compact</option></select></div> 10 + <div className="settings-control"><div className="control-header"><span className="control-label">Columns (Grid Layout)</span><output className="control-output">{settings.columns}</output></div><input type="range" min="1" max="5" value={settings.columns} disabled={settings.layout === 'list'} onChange={e => onChange({ ...settings, columns: numberValue(e.target.value, settings.columns) })}/></div> 11 + <div className="settings-control"><span className="control-label">Default Layout</span><select value={settings.layout} onChange={e => onChange({ ...settings, layout: e.target.value as LayoutChoice })}><option value="list">Classic List (HN/Lobsters)</option><option value="newspaper">Newspaper Grid</option></select></div> 12 + <div className="settings-control"><span className="control-label">Thread Highlight</span><label className="checkbox-container"><input type="checkbox" checked={settings.highlightThreads} onChange={e => onChange({ ...settings, highlightThreads: e.target.checked })}/><span className="checkbox-label">Highlight threaded guide rails on hover</span></label></div> 13 + </div></div>; 14 + }
+9
src/components/settings/RankingSettings.tsx
··· 1 + import type { ReaderSettings } from '../../types'; 2 + import { numberValue } from './settingsHelpers'; 3 + 4 + export function RankingSettings({ settings, onChange }: { settings: ReaderSettings; onChange: (settings: ReaderSettings) => void }) { 5 + return <div className="settings-section"><h2 className="section-title">Algorithm Tuning</h2><p className="section-description">Tune the combined feed ranking score based on age and discussion activity.</p><div className="settings-grid"> 6 + <div className="settings-control"><div className="control-header"><span className="control-label">Decay half-life</span><output className="control-output">{settings.halfLifeHours}h</output></div><input type="range" min="4" max="72" value={settings.halfLifeHours} onChange={e => onChange({ ...settings, halfLifeHours: numberValue(e.target.value, settings.halfLifeHours) })}/></div> 7 + <div className="settings-control"><div className="control-header"><span className="control-label">Comment engagement weight</span><output className="control-output">{settings.commentWeight.toFixed(2)}</output></div><input type="range" min="0" max="1.5" step="0.05" value={settings.commentWeight} onChange={e => onChange({ ...settings, commentWeight: numberValue(e.target.value, settings.commentWeight) })}/></div> 8 + </div></div>; 9 + }
+23
src/components/settings/SourceSettings.tsx
··· 1 + import type { ReaderSettings, Source } from '../../types'; 2 + import { SOURCES, sourceLabel } from '../../lib/sources'; 3 + import { parseCommaList, toggleSource } from '../../settings'; 4 + import { numberValue } from './settingsHelpers'; 5 + 6 + export function SourceSettings({ settings, onChange }: { settings: ReaderSettings; onChange: (settings: ReaderSettings) => void }) { 7 + return <div className="settings-section"><h2 className="section-title">Feeds & Sources</h2><div className="settings-grid"> 8 + <div className="settings-control"><span className="control-label">Hacker News feed</span><select value={settings.hnFeed} onChange={e => onChange({ ...settings, hnFeed: e.target.value as ReaderSettings['hnFeed'] })}><option value="top">Top</option><option value="best">Best</option><option value="new">New</option></select></div> 9 + <div className="settings-control"><span className="control-label">Lobsters feed</span><select value={settings.lobstersFeed} onChange={e => onChange({ ...settings, lobstersFeed: e.target.value as ReaderSettings['lobstersFeed'] })}><option value="hottest">Hottest</option><option value="newest">Newest</option></select></div> 10 + <label className="settings-control"><span className="control-label">Hide domains</span><input type="text" defaultValue={settings.blockedDomains.join(', ')} placeholder="example.com, news.example.org" onBlur={e => onChange({ ...settings, blockedDomains: parseCommaList(e.target.value) })}/><span className="control-help">Comma-separated. Subdomains are hidden too.</span></label> 11 + <label className="settings-control"><span className="control-label">Only Lobsters tags</span><input type="text" defaultValue={settings.lobstersTags.join(', ')} placeholder="programming, security" onBlur={e => onChange({ ...settings, lobstersTags: parseCommaList(e.target.value) })}/><span className="control-help">Comma-separated. Leave empty to show every Lobsters tag.</span></label> 12 + </div> 13 + <fieldset className="sources-fieldset"><legend className="fieldset-legend">Active sources</legend><p className="section-description">Choose which communities appear in your feed. At least one source must remain active.</p>{SOURCES.map(source => <SourceControl key={source} source={source} settings={settings} onChange={onChange}/>)}</fieldset> 14 + </div>; 15 + } 16 + 17 + function SourceControl({ source, settings, onChange }: { source: Source; settings: ReaderSettings; onChange: (settings: ReaderSettings) => void }) { 18 + const otherSource = source === 'hn' ? 'lobsters' : 'hn'; 19 + return <div className="source-control"> 20 + <label className="checkbox-container"><input type="checkbox" checked={settings.sourceEnabled[source]} disabled={settings.sourceEnabled[source] && !settings.sourceEnabled[otherSource]} onChange={e => onChange(toggleSource(settings, source, e.target.checked))}/><span className="checkbox-label">{sourceLabel(source)}</span></label> 21 + <div className="slider-container"><input aria-label={`${source} source weight`} type="range" min="0" max="2" step="0.1" value={settings.sourceWeight[source]} disabled={!settings.sourceEnabled[source]} onChange={e => onChange({ ...settings, sourceWeight: { ...settings.sourceWeight, [source]: numberValue(e.target.value, 1) } })}/><output className="control-output">{settings.sourceWeight[source].toFixed(1)}×</output></div> 22 + </div>; 23 + }
+1
src/components/settings/settingsHelpers.ts
··· 1 + export const numberValue = (value: string, fallback: number) => Number.isFinite(Number(value)) ? Number(value) : fallback;
+31
src/feed/filterStories.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { defaultSettings } from '../settings'; 3 + import type { Story } from '../types'; 4 + import { domainIsBlocked, filterStories } from './filterStories'; 5 + 6 + const baseStory: Story = { id: 'hn-1', source: 'hn', sourceId: '1', sourceRank: 0, title: 'Story', url: 'https://example.com/a', discussionUrl: 'https://news.ycombinator.com/item?id=1', points: 1, commentsCount: 0, createdAt: new Date(0).toISOString(), tags: ['HN'] }; 7 + const lobstersStory: Story = { ...baseStory, id: 'lobsters-a', source: 'lobsters', sourceId: 'a', discussionUrl: 'https://lobste.rs/s/a', tags: ['Lobsters', 'programming', 'security'] }; 8 + 9 + describe('filterStories', () => { 10 + it('blocks exact domains, www, and subdomains without blocking lookalikes', () => { 11 + expect(domainIsBlocked('example.com', ['example.com'])).toBe(true); 12 + expect(domainIsBlocked('www.example.com', ['example.com'])).toBe(true); 13 + expect(domainIsBlocked('blog.example.com', ['example.com'])).toBe(true); 14 + expect(domainIsBlocked('badexample.com', ['example.com'])).toBe(false); 15 + }); 16 + 17 + it('keeps malformed or self-post urls visible', () => { 18 + const story = { ...baseStory, url: 'not a url' }; 19 + expect(filterStories([story], { ...defaultSettings, blockedDomains: ['example.com'] })).toHaveLength(1); 20 + }); 21 + 22 + it('honors source disabling', () => { 23 + expect(filterStories([baseStory, lobstersStory], { ...defaultSettings, sourceEnabled: { hn: false, lobsters: true } })).toEqual([lobstersStory]); 24 + }); 25 + 26 + it('filters Lobsters by matching any selected tag case-insensitively', () => { 27 + expect(filterStories([baseStory, lobstersStory], { ...defaultSettings, lobstersTags: ['SECURITY'] })).toContain(lobstersStory); 28 + expect(filterStories([lobstersStory], { ...defaultSettings, lobstersTags: ['linux'] })).toHaveLength(0); 29 + expect(filterStories([lobstersStory], { ...defaultSettings, lobstersTags: [] })).toHaveLength(1); 30 + }); 31 + });
+30
src/feed/filterStories.ts
··· 1 + import type { ReaderSettings, Story } from '../types'; 2 + import { normalizeHostname } from '../lib/sources'; 3 + 4 + function normalizeBlockedDomain(domain: string): string { 5 + return domain.trim().toLowerCase().replace(/^www\./, ''); 6 + } 7 + 8 + export function domainIsBlocked(hostname: string | undefined, blockedDomains: readonly string[]): boolean { 9 + if (!hostname) return false; 10 + const normalized = normalizeBlockedDomain(hostname); 11 + return blockedDomains.some(blocked => { 12 + const domain = normalizeBlockedDomain(blocked); 13 + return Boolean(domain) && (normalized === domain || normalized.endsWith(`.${domain}`)); 14 + }); 15 + } 16 + 17 + export function storyMatchesLobstersTags(story: Story, selectedTags: readonly string[]): boolean { 18 + if (story.source !== 'lobsters' || selectedTags.length === 0) return true; 19 + const tags = new Set((story.tags ?? []).map(tag => tag.toLowerCase())); 20 + return selectedTags.some(tag => tags.has(tag.toLowerCase())); 21 + } 22 + 23 + export function filterStories(stories: readonly Story[], settings: ReaderSettings): Story[] { 24 + return stories.filter(story => { 25 + if (!settings.sourceEnabled[story.source]) return false; 26 + if (domainIsBlocked(normalizeHostname(story.url), settings.blockedDomains)) return false; 27 + if (!storyMatchesLobstersTags(story, settings.lobstersTags)) return false; 28 + return true; 29 + }); 30 + }
+9
src/feed/normalizeStories.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { normalizeHnFirebaseStory, normalizeLobstersJsonStory } from './normalizeStories'; 3 + 4 + describe('story JSON normalizers', () => { 5 + it('uses deterministic conservative timestamps when JSON time is missing', () => { 6 + expect(normalizeHnFirebaseStory({ id: 1, type: 'story', title: 'HN' }, 0)?.createdAt).toBe(new Date(0).toISOString()); 7 + expect(normalizeLobstersJsonStory({ short_id: 'a', title: 'Lobsters' }, 0)?.createdAt).toBe(new Date(0).toISOString()); 8 + }); 9 + });
+71
src/feed/normalizeStories.ts
··· 1 + import type { Story } from '../types'; 2 + import { SOURCE_ORIGIN } from '../lib/sources'; 3 + 4 + type UnknownRecord = Record<string, unknown>; 5 + 6 + export function record(value: unknown): UnknownRecord | null { 7 + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : null; 8 + } 9 + 10 + export function string(value: unknown): string | undefined { 11 + if (typeof value === 'string') return value; 12 + if (typeof value === 'number') return String(value); 13 + return undefined; 14 + } 15 + 16 + export function number(value: unknown, fallback = 0): number { 17 + if (typeof value === 'number' && Number.isFinite(value)) return value; 18 + if (typeof value === 'string' && Number.isFinite(Number(value))) return Number(value); 19 + return fallback; 20 + } 21 + 22 + function isoFromUnixSeconds(value: unknown): string { 23 + const parsed = number(value, 0); 24 + return new Date(parsed > 0 ? parsed * 1000 : 0).toISOString(); 25 + } 26 + 27 + export function normalizeHnFirebaseStory(value: unknown, index: number): Story | null { 28 + const item = record(value); 29 + if (!item || item.deleted || item.dead || item.type !== 'story') return null; 30 + const id = string(item.id); 31 + const title = string(item.title); 32 + if (!id || !title) return null; 33 + return { 34 + id: `hn-${id}`, 35 + source: 'hn', 36 + sourceId: id, 37 + sourceRank: index, 38 + title, 39 + url: string(item.url) ?? `${SOURCE_ORIGIN.hn}/item?id=${id}`, 40 + discussionUrl: `${SOURCE_ORIGIN.hn}/item?id=${id}`, 41 + author: string(item.by), 42 + points: number(item.score), 43 + commentsCount: number(item.descendants, Array.isArray(item.kids) ? item.kids.length : 0), 44 + createdAt: isoFromUnixSeconds(item.time), 45 + tags: ['HN'] 46 + }; 47 + } 48 + 49 + export function normalizeLobstersJsonStory(value: unknown, index: number): Story | null { 50 + const item = record(value); 51 + if (!item) return null; 52 + const sourceId = string(item.short_id) ?? string(item.id); 53 + const title = string(item.title); 54 + if (!sourceId || !title) return null; 55 + const tags = Array.isArray(item.tags) ? item.tags.filter((tag): tag is string => typeof tag === 'string') : []; 56 + const discussionUrl = string(item.comments_url) ?? string(item.short_id_url) ?? `${SOURCE_ORIGIN.lobsters}/s/${sourceId}`; 57 + return { 58 + id: `lobsters-${sourceId}`, 59 + source: 'lobsters', 60 + sourceId, 61 + sourceRank: index, 62 + title, 63 + url: string(item.url) ?? discussionUrl, 64 + discussionUrl, 65 + author: string(item.submitter_user) ?? string(item.user), 66 + points: number(item.score), 67 + commentsCount: number(item.comments_count, number(item.comment_count, Array.isArray(item.comments) ? item.comments.length : 0)), 68 + createdAt: string(item.created_at) ?? new Date(0).toISOString(), 69 + tags: ['Lobsters', ...tags] 70 + }; 71 + }
+33
src/hooks/useCommentNavigation.ts
··· 1 + import { useMemo, useRef, useState } from 'react'; 2 + import type { CommentNode } from '../types'; 3 + 4 + export function useCommentNavigation(comments: CommentNode[], collapsed: Set<string>) { 5 + const [active, setActive] = useState<string | null>(null); 6 + const refs = useRef(new Map<string, HTMLElement>()); 7 + const visibleIds = useMemo(() => { 8 + const result: string[] = []; 9 + const visit = (nodes: CommentNode[]) => nodes.forEach(node => { 10 + result.push(node.id); 11 + if (!collapsed.has(node.id)) visit(node.children); 12 + }); 13 + visit(comments); 14 + return result; 15 + }, [comments, collapsed]); 16 + 17 + const focusId = (commentId: string) => { 18 + setActive(commentId); 19 + requestAnimationFrame(() => { 20 + const element = refs.current.get(commentId); 21 + element?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); 22 + element?.focus({ preventScroll: true }); 23 + }); 24 + }; 25 + 26 + const moveFrom = (commentId: string, direction: number) => { 27 + const current = visibleIds.indexOf(commentId); 28 + const next = visibleIds[Math.max(0, Math.min(visibleIds.length - 1, current + direction))]; 29 + if (next) focusId(next); 30 + }; 31 + 32 + return { active, refs, visibleIds, moveFrom }; 33 + }
+27
src/hooks/useComments.ts
··· 1 + import { useEffect, useState } from 'react'; 2 + import { fetchComments } from '../api'; 3 + import type { CommentNode, Story } from '../types'; 4 + 5 + export function useComments(story: Story) { 6 + const [comments, setComments] = useState<CommentNode[]>([]); 7 + const [error, setError] = useState<string>(); 8 + const [loading, setLoading] = useState(true); 9 + 10 + useEffect(() => { 11 + const controller = new AbortController(); 12 + setLoading(true); 13 + setError(undefined); 14 + fetchComments(story, controller.signal) 15 + .then(setComments) 16 + .catch(reason => { 17 + if (reason instanceof DOMException && reason.name === 'AbortError') return; 18 + setError(reason instanceof Error ? reason.message : 'Could not load comments'); 19 + }) 20 + .finally(() => { 21 + if (!controller.signal.aborted) setLoading(false); 22 + }); 23 + return () => controller.abort(); 24 + }, [story.source, story.sourceId]); 25 + 26 + return { comments, error, loading }; 27 + }
+29
src/hooks/useFeed.ts
··· 1 + import { useCallback, useEffect, useMemo, useState } from 'react'; 2 + import { fetchCombinedFeed, type FeedResult } from '../api'; 3 + import { filterStories } from '../feed/filterStories'; 4 + import type { ReaderSettings } from '../types'; 5 + 6 + export function useFeed(settings: ReaderSettings) { 7 + const [feed, setFeed] = useState<FeedResult>({ stories: [], errors: {} }); 8 + const [loading, setLoading] = useState(true); 9 + const [refreshToken, setRefreshToken] = useState(0); 10 + const refresh = useCallback(() => setRefreshToken(value => value + 1), []); 11 + 12 + useEffect(() => { 13 + const controller = new AbortController(); 14 + setLoading(true); 15 + fetchCombinedFeed(settings, controller.signal) 16 + .then(result => setFeed(result)) 17 + .catch(error => { 18 + if (error instanceof DOMException && error.name === 'AbortError') return; 19 + setFeed({ stories: [], errors: { hn: error instanceof Error ? error.message : 'Feed failed' } }); 20 + }) 21 + .finally(() => { 22 + if (!controller.signal.aborted) setLoading(false); 23 + }); 24 + return () => controller.abort(); 25 + }, [settings.hnFeed, settings.lobstersFeed, settings.sourceEnabled.hn, settings.sourceEnabled.lobsters, refreshToken]); 26 + 27 + const filteredStories = useMemo(() => filterStories(feed.stories, settings), [feed.stories, settings]); 28 + return { feed, filteredStories, loading, refresh }; 29 + }
+13
src/hooks/useSettings.ts
··· 1 + import { useEffect, useState } from 'react'; 2 + import type { ReaderSettings } from '../types'; 3 + import { defaultSettings, loadSettings, resetSettings, saveSettings } from '../settings'; 4 + 5 + export function useSettings() { 6 + const [settings, setSettings] = useState<ReaderSettings>(loadSettings); 7 + useEffect(() => saveSettings(settings), [settings]); 8 + const reset = () => { 9 + resetSettings(); 10 + setSettings(defaultSettings); 11 + }; 12 + return { settings, setSettings, reset }; 13 + }
+44
src/hooks/useSplitPane.ts
··· 1 + import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'; 2 + 3 + export function useSplitPane(initial = 60, min = 20, max = 80) { 4 + const [splitWidth, setSplitWidth] = useState(initial); 5 + const resizing = useRef(false); 6 + const readerRoot = useRef<HTMLElement | null>(null); 7 + 8 + const cleanupResizeState = () => { 9 + if (!resizing.current) return; 10 + resizing.current = false; 11 + document.body.style.cursor = ''; 12 + document.body.classList.remove('resizing'); 13 + readerRoot.current?.querySelectorAll('iframe').forEach(iframe => { iframe.style.pointerEvents = ''; }); 14 + }; 15 + 16 + const startResizing = (event: ReactMouseEvent, root?: HTMLElement | null) => { 17 + event.preventDefault(); 18 + resizing.current = true; 19 + readerRoot.current = root ?? null; 20 + document.body.style.cursor = 'col-resize'; 21 + document.body.classList.add('resizing'); 22 + readerRoot.current?.querySelectorAll('iframe').forEach(iframe => { iframe.style.pointerEvents = 'none'; }); 23 + }; 24 + 25 + useEffect(() => { 26 + const handleMouseMove = (event: MouseEvent) => { 27 + if (!resizing.current) return; 28 + const percentage = (event.clientX / window.innerWidth) * 100; 29 + setSplitWidth(Math.max(min, Math.min(max, percentage))); 30 + }; 31 + const handleMouseUp = () => cleanupResizeState(); 32 + window.addEventListener('mousemove', handleMouseMove); 33 + window.addEventListener('mouseup', handleMouseUp); 34 + return () => { 35 + window.removeEventListener('mousemove', handleMouseMove); 36 + window.removeEventListener('mouseup', handleMouseUp); 37 + cleanupResizeState(); 38 + }; 39 + }, [min, max]); 40 + 41 + const nudgeSplit = (delta: number) => setSplitWidth(value => Math.max(min, Math.min(max, value + delta))); 42 + 43 + return { splitWidth, startResizing, nudgeSplit }; 44 + }
+24
src/layout/AppShell.tsx
··· 1 + import { useEffect, type ReactNode } from 'react'; 2 + import { Link } from 'react-router-dom'; 3 + import type { ReaderSettings } from '../types'; 4 + import { fontFamily } from '../lib/format'; 5 + 6 + export function AppShell({ settings, children }: { settings: ReaderSettings; children: ReactNode }) { 7 + const style = { '--reader-font': fontFamily(settings.font), '--type-scale': settings.typeScale, '--column-count': settings.columns } as React.CSSProperties; 8 + 9 + useEffect(() => { 10 + const root = document.documentElement; 11 + root.className = `theme-${settings.theme} density-${settings.density} ${settings.highlightThreads ? 'thread-highlight-enabled' : 'thread-highlight-disabled'}`; 12 + root.style.setProperty('--reader-font', fontFamily(settings.font)); 13 + root.style.setProperty('--type-scale', settings.typeScale.toString()); 14 + root.style.setProperty('--column-count', settings.columns.toString()); 15 + }, [settings.theme, settings.density, settings.font, settings.typeScale, settings.columns, settings.highlightThreads]); 16 + 17 + return <div className={`app theme-${settings.theme} density-${settings.density}`} style={style}> 18 + <header className="masthead"> 19 + <Link className="wordmark" to="/">Threadline</Link> 20 + <nav aria-label="Main navigation"><Link to="/">Latest</Link><Link to="/settings">Options</Link></nav> 21 + </header> 22 + {children} 23 + </div>; 24 + }
+27
src/lib/format.ts
··· 1 + import type { FontChoice } from '../types'; 2 + 3 + export function fontFamily(font: FontChoice): string { 4 + if (font === 'system') return '"Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'; 5 + if (font === 'mono') return '"JetBrains Mono", ui-monospace, SFMono-Regular, Roboto Mono, monospace'; 6 + return '"Lora", "Iowan Old Style", Georgia, serif'; 7 + } 8 + 9 + export function timeAgo(date?: string): string { 10 + if (!date) return ''; 11 + const timestamp = new Date(date).getTime(); 12 + if (!Number.isFinite(timestamp)) return ''; 13 + const seconds = Math.max(1, Math.floor((Date.now() - timestamp) / 1000)); 14 + if (seconds < 60) return 'just now'; 15 + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; 16 + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; 17 + return `${Math.floor(seconds / 86400)}d ago`; 18 + } 19 + 20 + export function editionDate(date = new Date()): string { 21 + return new Intl.DateTimeFormat(undefined, { 22 + weekday: 'long', 23 + month: 'long', 24 + day: 'numeric', 25 + year: 'numeric' 26 + }).format(date); 27 + }
+40
src/lib/sources.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { appendSourceEnabled, hnHtmlPath, isAllowedLobstersProxyPath, isHnFeed, isLobstersFeed, lobstersHtmlPath, normalizeSourceEnabled, sourceEnabledFromSearch, sourceFromParam } from './sources'; 3 + 4 + describe('source and feed helpers', () => { 5 + it('validates source and feed params without silently coercing invalid values', () => { 6 + expect(sourceFromParam('hn')).toBe('hn'); 7 + expect(sourceFromParam('lobsters')).toBe('lobsters'); 8 + expect(sourceFromParam('reddit')).toBeUndefined(); 9 + expect(isHnFeed('best')).toBe(true); 10 + expect(isHnFeed('hottest')).toBe(false); 11 + expect(isLobstersFeed('newest')).toBe(true); 12 + expect(isLobstersFeed('new')).toBe(false); 13 + }); 14 + 15 + it('maps feed modes to frontend paths', () => { 16 + expect(hnHtmlPath('top')).toBe('news'); 17 + expect(hnHtmlPath('best')).toBe('best'); 18 + expect(hnHtmlPath('new')).toBe('newest'); 19 + expect(lobstersHtmlPath('hottest')).toBe(''); 20 + expect(lobstersHtmlPath('newest')).toBe('newest'); 21 + }); 22 + 23 + it('keeps at least one enabled source when parsing API source intent', () => { 24 + const disabledBoth = new URLSearchParams('hnEnabled=0&lobstersEnabled=0'); 25 + expect(sourceEnabledFromSearch(disabledBoth)).toEqual({ hn: true, lobsters: false }); 26 + expect(normalizeSourceEnabled({ hn: false, lobsters: true })).toEqual({ hn: false, lobsters: true }); 27 + 28 + const search = new URLSearchParams(); 29 + appendSourceEnabled(search, { hn: false, lobsters: true }); 30 + expect(search.toString()).toBe('hnEnabled=0&lobstersEnabled=1'); 31 + }); 32 + 33 + it('allows only safe Lobsters proxy paths', () => { 34 + expect(isAllowedLobstersProxyPath('hottest.json')).toBe(true); 35 + expect(isAllowedLobstersProxyPath('newest.json')).toBe(true); 36 + expect(isAllowedLobstersProxyPath('s/abc123.json')).toBe(true); 37 + expect(isAllowedLobstersProxyPath('s/../../x.json')).toBe(false); 38 + expect(isAllowedLobstersProxyPath('domains/example.com')).toBe(false); 39 + }); 40 + });
+112
src/lib/sources.ts
··· 1 + import type { HnFeed, LobstersFeed, Source, Story } from '../types'; 2 + 3 + export const SOURCES = ['hn', 'lobsters'] as const satisfies readonly Source[]; 4 + export const HN_FEEDS = ['top', 'best', 'new'] as const satisfies readonly HnFeed[]; 5 + export const LOBSTERS_FEEDS = ['hottest', 'newest'] as const satisfies readonly LobstersFeed[]; 6 + 7 + export type SourceEnabled = Record<Source, boolean>; 8 + 9 + export const SOURCE_LABEL: Record<Source, string> = { 10 + hn: 'Hacker News', 11 + lobsters: 'Lobsters' 12 + }; 13 + 14 + export const SOURCE_ORIGIN: Record<Source, string> = { 15 + hn: 'https://news.ycombinator.com', 16 + lobsters: 'https://lobste.rs' 17 + }; 18 + 19 + export function isSource(value: unknown): value is Source { 20 + return typeof value === 'string' && (SOURCES as readonly string[]).includes(value); 21 + } 22 + 23 + export function isHnFeed(value: unknown): value is HnFeed { 24 + return typeof value === 'string' && (HN_FEEDS as readonly string[]).includes(value); 25 + } 26 + 27 + export function isLobstersFeed(value: unknown): value is LobstersFeed { 28 + return typeof value === 'string' && (LOBSTERS_FEEDS as readonly string[]).includes(value); 29 + } 30 + 31 + export function hnHtmlPath(feed: HnFeed): string { 32 + if (feed === 'new') return 'newest'; 33 + if (feed === 'best') return 'best'; 34 + return 'news'; 35 + } 36 + 37 + export function hnFirebaseEndpoint(feed: HnFeed): string { 38 + if (feed === 'best') return 'beststories'; 39 + if (feed === 'new') return 'newstories'; 40 + return 'topstories'; 41 + } 42 + 43 + export function lobstersHtmlPath(feed: LobstersFeed): string { 44 + return feed === 'newest' ? 'newest' : ''; 45 + } 46 + 47 + export function lobstersJsonPath(feed: LobstersFeed): string { 48 + return `${feed}.json`; 49 + } 50 + 51 + export function isAllowedLobstersProxyPath(path: string): boolean { 52 + return path === 'hottest.json' || path === 'newest.json' || /^s\/[a-z0-9]+\.json$/i.test(path); 53 + } 54 + 55 + export function normalizeSourceEnabled(value: Partial<Record<Source, boolean>> | undefined): SourceEnabled { 56 + const enabled: SourceEnabled = { 57 + hn: value?.hn !== false, 58 + lobsters: value?.lobsters !== false 59 + }; 60 + if (!enabled.hn && !enabled.lobsters) enabled.hn = true; 61 + return enabled; 62 + } 63 + 64 + export function sourceEnabledFromSearch(search: URLSearchParams): SourceEnabled { 65 + return normalizeSourceEnabled({ 66 + hn: search.get('hnEnabled') !== '0', 67 + lobsters: search.get('lobstersEnabled') !== '0' 68 + }); 69 + } 70 + 71 + export function appendSourceEnabled(search: URLSearchParams, enabled: SourceEnabled): void { 72 + search.set('hnEnabled', enabled.hn ? '1' : '0'); 73 + search.set('lobstersEnabled', enabled.lobsters ? '1' : '0'); 74 + } 75 + 76 + export function normalizeHostname(url?: string): string | undefined { 77 + try { 78 + return url ? new URL(url).hostname.toLowerCase().replace(/^www\./, '') : undefined; 79 + } catch { 80 + return undefined; 81 + } 82 + } 83 + 84 + export function hostnameLabel(url?: string): string | undefined { 85 + return normalizeHostname(url); 86 + } 87 + 88 + export function isDiscussionUrl(url?: string): boolean { 89 + if (!url) return false; 90 + return url.includes('news.ycombinator.com/item') || url.includes('lobste.rs/s/'); 91 + } 92 + 93 + export function articleUrl(story: Story): string | undefined { 94 + return story.url && !isDiscussionUrl(story.url) ? story.url : undefined; 95 + } 96 + 97 + export function sourceLabel(source: Source): string { 98 + return SOURCE_LABEL[source]; 99 + } 100 + 101 + export function absolutizeUrl(url: string | undefined, origin: string): string | undefined { 102 + if (!url) return undefined; 103 + try { 104 + return new URL(url, origin).toString(); 105 + } catch { 106 + return undefined; 107 + } 108 + } 109 + 110 + export function sourceFromParam(source: string | undefined): Source | undefined { 111 + return isSource(source) ? source : undefined; 112 + }
+5
src/main.tsx
··· 1 + import { StrictMode } from 'react'; 2 + import { createRoot } from 'react-dom/client'; 3 + import { BrowserRouter } from 'react-router-dom'; 4 + import App from './App'; 5 + createRoot(document.getElementById('root')!).render(<StrictMode><BrowserRouter><App /></BrowserRouter></StrictMode>);
+27
src/pages/DiscussionPage.tsx
··· 1 + import { useLocation, useParams } from 'react-router-dom'; 2 + import { DiscussionView } from '../components/discussion/DiscussionView'; 3 + import { NotFoundPage } from './NotFoundPage'; 4 + import { sourceFromParam, SOURCE_ORIGIN } from '../lib/sources'; 5 + import type { ReaderSettings, Story } from '../types'; 6 + 7 + export function DiscussionPage({ settings, onChange }: { settings: ReaderSettings; onChange: (settings: ReaderSettings) => void }) { 8 + const { source, id } = useParams(); 9 + const location = useLocation(); 10 + const sourceName = sourceFromParam(source); 11 + if (!sourceName || !id) return <NotFoundPage />; 12 + const autoRead = new URLSearchParams(location.search).get('read') === '1' || Boolean((location.state as { autoRead?: boolean } | null)?.autoRead); 13 + const passed = (location.state as { story?: Story } | null)?.story; 14 + const story: Story = passed && passed.sourceId === id ? passed : { 15 + id: `${sourceName}-${id}`, 16 + source: sourceName, 17 + sourceId: id, 18 + sourceRank: 0, 19 + title: 'Discussion', 20 + discussionUrl: sourceName === 'lobsters' ? `${SOURCE_ORIGIN.lobsters}/s/${id}` : `${SOURCE_ORIGIN.hn}/item?id=${id}`, 21 + points: 0, 22 + commentsCount: 0, 23 + createdAt: new Date().toISOString() 24 + }; 25 + 26 + return <main><DiscussionView story={story} settings={settings} onChange={onChange} initialReadHere={autoRead} /></main>; 27 + }
+44
src/pages/FeedPage.tsx
··· 1 + import { useMemo, useRef, useState } from 'react'; 2 + import { StoryCard } from '../components/StoryCard'; 3 + import { DiscussionView } from '../components/discussion/DiscussionView'; 4 + import { editionDate } from '../lib/format'; 5 + import { rankStories } from '../ranking'; 6 + import { useFeed } from '../hooks/useFeed'; 7 + import type { ReaderSettings, Story } from '../types'; 8 + 9 + export function FeedPage({ settings, onChange }: { settings: ReaderSettings; onChange: (settings: ReaderSettings) => void }) { 10 + const [readerStory, setReaderStory] = useState<Story | null>(null); 11 + const readerReturnFocus = useRef<HTMLElement | null>(null); 12 + const { feed, filteredStories, loading, refresh } = useFeed(settings); 13 + const openReader = (story: Story) => { 14 + readerReturnFocus.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; 15 + setReaderStory(story); 16 + }; 17 + const closeReader = () => { 18 + setReaderStory(null); 19 + requestAnimationFrame(() => readerReturnFocus.current?.focus()); 20 + }; 21 + const ranked = useMemo(() => rankStories(filteredStories, settings), [filteredStories, settings]); 22 + 23 + return <main className="front-page"> 24 + <div className="edition-bar"> 25 + <strong>{editionDate()}</strong> 26 + <div className="edition-actions"> 27 + <div className="layout-switcher" role="group" aria-label="Layout view selection"> 28 + <button className={`layout-btn ${settings.layout === 'list' ? 'active' : ''}`} onClick={() => onChange({ ...settings, layout: 'list' })} title="Classic List view">List</button> 29 + <button className={`layout-btn ${settings.layout === 'newspaper' ? 'active' : ''}`} onClick={() => onChange({ ...settings, layout: 'newspaper' })} title="Newspaper Grid view">Grid</button> 30 + </div> 31 + <button className="text-button refresh-btn" onClick={refresh}>Refresh</button> 32 + </div> 33 + </div> 34 + {Object.entries(feed.errors).map(([source, error]) => <aside className="notice" key={source}><strong>{source === 'hn' ? 'Hacker News' : 'Lobsters'} unavailable.</strong> {error}</aside>)} 35 + {loading ? <p className="loader">Loading today’s stories…</p> : ranked.length ? ( 36 + settings.layout === 'list' ? <section className="list-layout" aria-label="Combined news feed"> 37 + {ranked.map((story, index) => <div key={story.id} className="list-row"><span className="story-rank">{index + 1}</span><StoryCard story={story} featured={false} onReadHere={openReader}/></div>)} 38 + </section> : <section className="newspaper-grid" aria-label="Combined news feed"> 39 + {ranked.map((story, index) => <StoryCard key={story.id} story={story} featured={index === 0} onReadHere={openReader}/>) } 40 + </section> 41 + ) : <section className="empty-state"><h1>No stories available</h1><p>Enable a source or check the Lobsters proxy.</p></section>} 42 + {readerStory && <DiscussionView story={readerStory} settings={settings} onChange={onChange} initialReadHere overlay onClose={closeReader} />} 43 + </main>; 44 + }
+5
src/pages/NotFoundPage.tsx
··· 1 + import { Link } from 'react-router-dom'; 2 + 3 + export function NotFoundPage() { 4 + return <main className="empty-state"><h1>Not found</h1><Link to="/">Return to front page</Link></main>; 5 + }
+6
src/pages/SettingsPage.tsx
··· 1 + import { SettingsPanel } from '../components/SettingsPanel'; 2 + import type { ReaderSettings } from '../types'; 3 + 4 + export function SettingsPage({ settings, onChange, onReset }: { settings: ReaderSettings; onChange: (settings: ReaderSettings) => void; onReset: () => void }) { 5 + return <main className="settings-page"><SettingsPanel settings={settings} onChange={onChange} onReset={onReset}/></main>; 6 + }
+90
src/ranking.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { defaultSettings } from './settings'; 3 + import { rankStories } from './ranking'; 4 + import type { Story } from './types'; 5 + 6 + const now = Date.parse('2026-07-11T12:00:00Z'); 7 + 8 + function story(overrides: Partial<Story>): Story { 9 + return { 10 + id: 'hn-1', 11 + source: 'hn', 12 + sourceId: '1', 13 + sourceRank: 0, 14 + title: 'Example', 15 + url: 'https://example.com', 16 + discussionUrl: 'https://news.ycombinator.com/item?id=1', 17 + author: 'alice', 18 + points: 10, 19 + commentsCount: 2, 20 + createdAt: '2026-07-11T11:00:00Z', 21 + ...overrides 22 + }; 23 + } 24 + 25 + describe('rankStories', () => { 26 + it('keeps ranking source-relative instead of letting raw HN scores dominate', () => { 27 + const ranked = rankStories( 28 + [ 29 + story({ id: 'hn-1', source: 'hn', sourceId: '1', points: 500, commentsCount: 80, sourceRank: 0 }), 30 + story({ id: 'lobsters-a', source: 'lobsters', sourceId: 'a', points: 25, commentsCount: 10, sourceRank: 0 }) 31 + ], 32 + defaultSettings, 33 + now 34 + ); 35 + 36 + expect(ranked.map((item) => item.source)).toEqual(['hn', 'lobsters']); 37 + expect(ranked[1].rankScore).toBeGreaterThan(ranked[0].rankScore * 0.85); 38 + }); 39 + 40 + it('honors source weights', () => { 41 + const ranked = rankStories( 42 + [ 43 + story({ id: 'hn-1', source: 'hn', sourceId: '1', points: 100, sourceRank: 0 }), 44 + story({ id: 'lobsters-a', source: 'lobsters', sourceId: 'a', points: 8, sourceRank: 0 }) 45 + ], 46 + { ...defaultSettings, sourceWeight: { hn: 0.25, lobsters: 2 } }, 47 + now 48 + ); 49 + 50 + expect(ranked[0].source).toBe('lobsters'); 51 + }); 52 + 53 + it('filters disabled sources', () => { 54 + const ranked = rankStories( 55 + [story({ id: 'hn-1', source: 'hn' }), story({ id: 'lobsters-a', source: 'lobsters', sourceId: 'a' })], 56 + { ...defaultSettings, sourceEnabled: { hn: false, lobsters: true } }, 57 + now 58 + ); 59 + 60 + expect(ranked).toHaveLength(1); 61 + expect(ranked[0].source).toBe('lobsters'); 62 + }); 63 + 64 + it('fair-merges enabled sources after strong runs from one source', () => { 65 + const ranked = rankStories( 66 + [ 67 + story({ id: 'hn-1', source: 'hn', sourceId: '1', points: 500, sourceRank: 0 }), 68 + story({ id: 'hn-2', source: 'hn', sourceId: '2', points: 450, sourceRank: 1 }), 69 + story({ id: 'hn-3', source: 'hn', sourceId: '3', points: 400, sourceRank: 2 }), 70 + story({ id: 'hn-4', source: 'hn', sourceId: '4', points: 350, sourceRank: 3 }), 71 + story({ id: 'lobsters-a', source: 'lobsters', sourceId: 'a', points: 5, sourceRank: 4 }) 72 + ], 73 + defaultSettings, 74 + now 75 + ); 76 + 77 + expect(ranked.slice(0, 4).map((item) => item.source)).toEqual(['hn', 'hn', 'hn', 'lobsters']); 78 + }); 79 + 80 + it('boosts active comment threads when comment weight increases', () => { 81 + const quiet = story({ id: 'hn-quiet', sourceId: 'quiet', commentsCount: 0, points: 50, sourceRank: 1 }); 82 + const busy = story({ id: 'hn-busy', sourceId: 'busy', commentsCount: 80, points: 20, sourceRank: 1 }); 83 + 84 + const low = rankStories([quiet, busy], { ...defaultSettings, commentWeight: 0 }, now); 85 + const high = rankStories([quiet, busy], { ...defaultSettings, commentWeight: 1.2 }, now); 86 + 87 + expect(low[0].id).toBe('hn-quiet'); 88 + expect(high[0].id).toBe('hn-busy'); 89 + }); 90 + });
+93
src/ranking.ts
··· 1 + import type { RankedStory, ReaderSettings, Source, Story } from './types'; 2 + 3 + function clamp(value: number, min: number, max: number): number { 4 + return Math.min(max, Math.max(min, value)); 5 + } 6 + 7 + function hoursSince(isoDate: string, nowMs: number): number { 8 + const created = Date.parse(isoDate); 9 + if (Number.isNaN(created)) return 0; 10 + return Math.max(0, (nowMs - created) / 3_600_000); 11 + } 12 + 13 + function sourceMaxima(stories: Story[]): Record<Source, { engagement: number; count: number }> { 14 + const maxima: Record<Source, { engagement: number; count: number }> = { 15 + hn: { engagement: 1, count: 0 }, 16 + lobsters: { engagement: 1, count: 0 } 17 + }; 18 + 19 + for (const story of stories) { 20 + const rawEngagement = Math.log1p(story.points) + Math.log1p(story.commentsCount); 21 + maxima[story.source].engagement = Math.max(maxima[story.source].engagement, rawEngagement); 22 + maxima[story.source].count += 1; 23 + } 24 + 25 + return maxima; 26 + } 27 + 28 + export function fairMerge(sortedStories: RankedStory[], maxConsecutivePerSource = 3): RankedStory[] { 29 + const remaining = [...sortedStories]; 30 + const merged: RankedStory[] = []; 31 + const maxRun = Math.max(1, maxConsecutivePerSource); 32 + 33 + while (remaining.length > 0) { 34 + const recent = merged.slice(-maxRun); 35 + const blockedSource = recent.length === maxRun && recent.every((story) => story.source === recent[0].source) ? recent[0].source : null; 36 + const nextIndex = blockedSource === null ? 0 : remaining.findIndex((story) => story.source !== blockedSource); 37 + const index = nextIndex === -1 ? 0 : nextIndex; 38 + const [next] = remaining.splice(index, 1); 39 + merged.push(next); 40 + } 41 + 42 + return merged; 43 + } 44 + 45 + export function rankStories( 46 + stories: Story[], 47 + settings: Pick<ReaderSettings, 'sourceEnabled' | 'sourceWeight' | 'halfLifeHours' | 'commentWeight'>, 48 + nowMs = Date.now() 49 + ): RankedStory[] { 50 + const visible = stories.filter((story) => settings.sourceEnabled[story.source]); 51 + const maxima = sourceMaxima(visible); 52 + const halfLife = Math.max(1, settings.halfLifeHours); 53 + const commentWeight = clamp(settings.commentWeight, 0, 2); 54 + 55 + const scored = visible 56 + .map((story) => { 57 + const sourceRankPart = 1 / Math.sqrt(story.sourceRank + 1); 58 + const engagementRaw = Math.log1p(Math.max(0, story.points)) + commentWeight * Math.log1p(Math.max(0, story.commentsCount)); 59 + const engagementPart = clamp(engagementRaw / maxima[story.source].engagement, 0, 1.5); 60 + const ageHours = hoursSince(story.createdAt, nowMs); 61 + const recencyPart = 1 / (1 + ageHours / 24); 62 + const decay = Math.pow(0.5, ageHours / halfLife); 63 + const sourceWeight = Math.max(0, settings.sourceWeight[story.source] ?? 1); 64 + const rankScore = sourceWeight * decay * (0.58 * sourceRankPart + 0.3 * engagementPart + 0.12 * recencyPart); 65 + 66 + return { 67 + ...story, 68 + rankScore, 69 + rankParts: { 70 + sourceRank: sourceRankPart, 71 + engagement: engagementPart, 72 + recency: recencyPart, 73 + decay 74 + } 75 + }; 76 + }) 77 + .sort((a, b) => { 78 + if (b.rankScore !== a.rankScore) return b.rankScore - a.rankScore; 79 + return Date.parse(b.createdAt) - Date.parse(a.createdAt); 80 + }); 81 + 82 + return fairMerge(scored); 83 + } 84 + 85 + export function sourceCounts(stories: Story[]): Record<Source, number> { 86 + return stories.reduce<Record<Source, number>>( 87 + (counts, story) => { 88 + counts[story.source] += 1; 89 + return counts; 90 + }, 91 + { hn: 0, lobsters: 0 } 92 + ); 93 + }
+18
src/sanitize.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { safeAbsoluteHref, sanitizeHtml } from './sanitize'; 3 + 4 + describe('sanitize helpers', () => { 5 + it('resolves safe relative comment links against the source origin', () => { 6 + expect(safeAbsoluteHref('item?id=1', 'https://news.ycombinator.com')).toBe('https://news.ycombinator.com/item?id=1'); 7 + expect(safeAbsoluteHref('/s/abc/story', 'https://lobste.rs')).toBe('https://lobste.rs/s/abc/story'); 8 + }); 9 + 10 + it('rejects unsafe href protocols', () => { 11 + expect(safeAbsoluteHref('javascript:alert(1)', 'https://news.ycombinator.com')).toBeUndefined(); 12 + expect(safeAbsoluteHref('data:text/html,hello', 'https://lobste.rs')).toBeUndefined(); 13 + }); 14 + 15 + it('escapes html in non-DOM environments', () => { 16 + expect(sanitizeHtml('<img src=x onerror=alert(1)>hi')).toBe('&lt;img src=x onerror=alert(1)&gt;hi'); 17 + }); 18 + });
+69
src/sanitize.ts
··· 1 + const ALLOWED_TAGS = new Set(['A', 'B', 'BLOCKQUOTE', 'BR', 'CODE', 'EM', 'I', 'LI', 'OL', 'P', 'PRE', 'STRONG', 'UL']); 2 + 3 + function escapeHtml(input: string): string { 4 + return input 5 + .replace(/&/g, '&amp;') 6 + .replace(/</g, '&lt;') 7 + .replace(/>/g, '&gt;') 8 + .replace(/"/g, '&quot;') 9 + .replace(/'/g, '&#039;'); 10 + } 11 + 12 + export function safeAbsoluteHref(value: string, origin: string): string | undefined { 13 + try { 14 + const url = new URL(value, origin); 15 + return ['http:', 'https:', 'mailto:'].includes(url.protocol) ? url.toString() : undefined; 16 + } catch { 17 + return undefined; 18 + } 19 + } 20 + 21 + function cleanNode(node: Node, document: Document, linkOrigin: string): Node | null { 22 + if (node.nodeType === Node.TEXT_NODE) return document.createTextNode(node.textContent ?? ''); 23 + 24 + if (node.nodeType !== Node.ELEMENT_NODE) return null; 25 + 26 + const element = node as Element; 27 + const tagName = element.tagName.toUpperCase(); 28 + const children = Array.from(element.childNodes) 29 + .map((child) => cleanNode(child, document, linkOrigin)) 30 + .filter((child): child is Node => Boolean(child)); 31 + 32 + if (!ALLOWED_TAGS.has(tagName)) { 33 + const fragment = document.createDocumentFragment(); 34 + for (const child of children) fragment.appendChild(child); 35 + return fragment; 36 + } 37 + 38 + const clean = document.createElement(tagName.toLowerCase()); 39 + if (tagName === 'A') { 40 + const href = safeAbsoluteHref(element.getAttribute('href') ?? '', linkOrigin); 41 + if (href) { 42 + clean.setAttribute('href', href); 43 + clean.setAttribute('target', '_blank'); 44 + clean.setAttribute('rel', 'noreferrer noopener'); 45 + } 46 + } 47 + 48 + for (const child of children) clean.appendChild(child); 49 + return clean; 50 + } 51 + 52 + export function sanitizeHtml(input: string, linkOrigin = typeof window === 'undefined' ? 'https://example.invalid' : window.location.origin): string { 53 + if (!input) return ''; 54 + if (typeof window === 'undefined' || typeof DOMParser === 'undefined') return escapeHtml(input); 55 + 56 + const parser = new DOMParser(); 57 + const doc = parser.parseFromString(`<body>${input}</body>`, 'text/html'); 58 + const output = document.implementation.createHTMLDocument('sanitized'); 59 + const fragment = output.createDocumentFragment(); 60 + 61 + for (const node of Array.from(doc.body.childNodes)) { 62 + const clean = cleanNode(node, output, linkOrigin); 63 + if (clean) fragment.appendChild(clean); 64 + } 65 + 66 + const container = output.createElement('div'); 67 + container.appendChild(fragment); 68 + return container.innerHTML; 69 + }
+22
src/server/feed/fetchFeed.test.ts
··· 1 + import { afterEach, describe, expect, it, vi } from 'vitest'; 2 + import lobsters from './fixtures/lobsters.html?raw'; 3 + import { fetchAggregatedFeed } from './fetchFeed'; 4 + 5 + describe('fetchAggregatedFeed source selection', () => { 6 + afterEach(() => vi.restoreAllMocks()); 7 + 8 + it('skips disabled upstreams and omits disabled-source errors', async () => { 9 + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { 10 + const url = String(input); 11 + if (url.startsWith('https://lobste.rs/')) return new Response(lobsters, { status: 200 }); 12 + return new Response('disabled source should not be fetched', { status: 500 }); 13 + }); 14 + 15 + const result = await fetchAggregatedFeed({ hn: 'top', lobsters: 'hottest', sourceEnabled: { hn: false, lobsters: true }, minimumHtmlStories: 1 }); 16 + 17 + expect(result.errors).toEqual({}); 18 + expect(result.stories.every(story => story.source === 'lobsters')).toBe(true); 19 + expect(fetchMock).toHaveBeenCalledTimes(1); 20 + expect(String(fetchMock.mock.calls[0][0])).toBe('https://lobste.rs/'); 21 + }); 22 + });
+69
src/server/feed/fetchFeed.ts
··· 1 + import type { FeedResult, HnFeed, LobstersFeed, Source, Story } from '../../types'; 2 + import { hnHtmlPath, lobstersHtmlPath, normalizeSourceEnabled, SOURCE_ORIGIN, type SourceEnabled } from '../../lib/sources'; 3 + import { parseHnHtml, parseLobstersHtml } from './parseHtml'; 4 + import { fetchHnFirebaseFallback, fetchLobstersJsonFallback } from './normalize'; 5 + 6 + export interface FetchFeedOptions { 7 + hn: HnFeed; 8 + lobsters: LobstersFeed; 9 + minimumHtmlStories?: number; 10 + sourceEnabled?: Partial<SourceEnabled>; 11 + } 12 + 13 + async function fetchText(url: string): Promise<string> { 14 + const response = await fetch(url, { headers: { Accept: 'text/html,application/xhtml+xml' } }); 15 + if (!response.ok) throw new Error(`${url} returned ${response.status}`); 16 + return response.text(); 17 + } 18 + 19 + async function scrapeOrFallback( 20 + source: Source, 21 + scrape: () => Promise<Story[]>, 22 + fallback: () => Promise<Story[]>, 23 + minimumHtmlStories: number 24 + ): Promise<{ stories: Story[]; error?: string }> { 25 + try { 26 + const stories = await scrape(); 27 + if (stories.length >= minimumHtmlStories) return { stories }; 28 + throw new Error(`${source} parser returned too few stories`); 29 + } catch (scrapeError) { 30 + try { 31 + return { stories: await fallback() }; 32 + } catch (fallbackError) { 33 + const error = fallbackError instanceof Error ? fallbackError.message : scrapeError instanceof Error ? scrapeError.message : `${source} unavailable`; 34 + return { stories: [], error }; 35 + } 36 + } 37 + } 38 + 39 + export async function fetchAggregatedFeed(options: FetchFeedOptions): Promise<FeedResult> { 40 + const minimumHtmlStories = options.minimumHtmlStories ?? 10; 41 + const sourceEnabled = normalizeSourceEnabled(options.sourceEnabled); 42 + const jobs: Promise<[Source, { stories: Story[]; error?: string }]>[] = []; 43 + 44 + if (sourceEnabled.hn) { 45 + jobs.push(scrapeOrFallback( 46 + 'hn', 47 + () => fetchText(`${SOURCE_ORIGIN.hn}/${hnHtmlPath(options.hn)}`).then(parseHnHtml), 48 + () => fetchHnFirebaseFallback(options.hn), 49 + minimumHtmlStories 50 + ).then(result => ['hn', result] as const)); 51 + } 52 + if (sourceEnabled.lobsters) { 53 + jobs.push(scrapeOrFallback( 54 + 'lobsters', 55 + () => fetchText(`${SOURCE_ORIGIN.lobsters}/${lobstersHtmlPath(options.lobsters)}`).then(parseLobstersHtml), 56 + () => fetchLobstersJsonFallback(options.lobsters), 57 + minimumHtmlStories 58 + ).then(result => ['lobsters', result] as const)); 59 + } 60 + 61 + const results = await Promise.all(jobs); 62 + const errors: FeedResult['errors'] = {}; 63 + const stories: Story[] = []; 64 + for (const [source, result] of results) { 65 + stories.push(...result.stories); 66 + if (result.error) errors[source] = result.error; 67 + } 68 + return { stories, errors }; 69 + }
+1
src/server/feed/fixtures/hn.html
··· 1 + <html><body><tr class="athing submission" id="1"><td class="title"><span class="titleline"><a href="https://example.com/a?x=1&amp;y=2">Example &amp; Entity</a><span class="sitebit comhead"> (<span class="sitestr">example.com</span>)</span></span></td></tr><tr><td class="subtext"><span class="score" id="score_1">42 points</span> by <a href="user?id=alice" class="hnuser">alice</a> <span class="age" title="2026-01-01T00:00:00 1767225600"><a href="item?id=1">1 hour ago</a></span> | <a href="item?id=1">7&nbsp;comments</a></td></tr><tr class="athing submission" id="2"><td class="title"><span class="titleline"><a href="item?id=2">Ask HN: Self post</a></span></td></tr><tr><td class="subtext"><span class="score" id="score_2">5 points</span> by <a href="user?id=bob" class="hnuser">bob</a> <span class="age" title="2026-01-01T00:01:00 1767225660"><a href="item?id=2">1 hour ago</a></span> | <a href="item?id=2">0 comments</a></td></tr></body></html>
+1
src/server/feed/fixtures/lobsters.html
··· 1 + <ol class="stories list"><li id="story_abc123" data-shortid="abc123"><div class="story_liner h-entry"><div class="voters"><a class="upvoter" href="/login">13</a></div><div class="details"><span role="heading" aria-level="1" class="link h-cite u-repost-of"><a class="u-url" href="https://lob.example/post" rel="ugc noreferrer">Lob &amp; Story</a></span><ul class="tags" aria-label="Tags"><li><a class="tag tag_programming" href="/t/programming">programming</a></li><li><a class="tag tag_security" href="/t/security">security</a></li></ul><a class="domain" href="/domains/lob.example">lob.example</a><div class="byline"><a href="/~carol">carol</a> <time title="2026-01-01 00:00:00" datetime="2026-01-01 00:00:00" data-at-unix="1767225600">1 hour ago</time><span class="comments_label"><span aria-hidden="true"> | </span><a role="heading" aria-level="2" href="/s/abc123/lob_story"> 4 comments </a></span></div></div></div></li><li id="story_def456" data-shortid="def456"><div class="story_liner h-entry"><div class="voters"><a class="upvoter" href="/login">1</a></div><div class="details"><span role="heading" aria-level="1" class="link h-cite u-repost-of"><a class="u-url" href="/s/def456/self" rel="ugc noreferrer">Self post</a></span><div class="byline"><a href="/~dan">dan</a> <time data-at-unix="1767225660">1 hour ago</time></div></div></div></li></ol>
+24
src/server/feed/normalize.ts
··· 1 + import type { HnFeed, LobstersFeed, Story } from '../../types'; 2 + import { hnFirebaseEndpoint, lobstersJsonPath, SOURCE_ORIGIN } from '../../lib/sources'; 3 + import { normalizeHnFirebaseStory, normalizeLobstersJsonStory } from '../../feed/normalizeStories'; 4 + 5 + export async function fetchHnFirebaseFallback(feed: HnFeed, limit = 30): Promise<Story[]> { 6 + const idsResponse = await fetch(`https://hacker-news.firebaseio.com/v0/${hnFirebaseEndpoint(feed)}.json`); 7 + if (!idsResponse.ok) throw new Error(`Hacker News returned ${idsResponse.status}`); 8 + const ids = await idsResponse.json() as unknown; 9 + if (!Array.isArray(ids)) throw new Error('Hacker News returned invalid story ids'); 10 + const items = await Promise.all(ids.slice(0, limit).map(async (id, index) => { 11 + const response = await fetch(`https://hacker-news.firebaseio.com/v0/item/${encodeURIComponent(String(id))}.json`); 12 + if (!response.ok) return null; 13 + return normalizeHnFirebaseStory(await response.json(), index); 14 + })); 15 + return items.filter((story): story is Story => story !== null); 16 + } 17 + 18 + export async function fetchLobstersJsonFallback(feed: LobstersFeed, limit = 30): Promise<Story[]> { 19 + const response = await fetch(`${SOURCE_ORIGIN.lobsters}/${lobstersJsonPath(feed)}`, { headers: { Accept: 'application/json' } }); 20 + if (!response.ok) throw new Error(`Lobsters returned ${response.status}`); 21 + const payload = await response.json() as unknown; 22 + if (!Array.isArray(payload)) throw new Error('Lobsters returned invalid story list'); 23 + return payload.slice(0, limit).map(normalizeLobstersJsonStory).filter((story): story is Story => story !== null); 24 + }
+27
src/server/feed/parseHtml.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import hn from './fixtures/hn.html?raw'; 3 + import lobsters from './fixtures/lobsters.html?raw'; 4 + import { parseHnHtml, parseLobstersHtml } from './parseHtml'; 5 + 6 + describe('feed html parsers', () => { 7 + it('parses Hacker News rows', () => { 8 + const stories = parseHnHtml(hn); 9 + expect(stories).toHaveLength(2); 10 + expect(stories[0]).toMatchObject({ id: 'hn-1', sourceId: '1', title: 'Example & Entity', url: 'https://example.com/a?x=1&y=2', author: 'alice', points: 42, commentsCount: 7 }); 11 + expect(stories[1].url).toBe('https://news.ycombinator.com/item?id=2'); 12 + }); 13 + 14 + it('uses deterministic epoch timestamps when upstream omits times', () => { 15 + const stories = parseHnHtml('<tr class="athing submission" id="9"><td class="title"><span class="titleline"><a href="item?id=9">No time</a></span></td></tr><tr><td class="subtext"><span class="score">1 point</span></td></tr>'); 16 + expect(stories[0].createdAt).toBe(new Date(0).toISOString()); 17 + }); 18 + 19 + it('parses Lobsters stories with tags and comments', () => { 20 + const stories = parseLobstersHtml(lobsters); 21 + expect(stories).toHaveLength(2); 22 + expect(stories[0]).toMatchObject({ id: 'lobsters-abc123', sourceId: 'abc123', title: 'Lob & Story', author: 'carol', points: 13, commentsCount: 4 }); 23 + expect(stories[0].tags).toEqual(['Lobsters', 'programming', 'security']); 24 + expect(stories[0].discussionUrl).toBe('https://lobste.rs/s/abc123/lob_story'); 25 + expect(stories[1].url).toBe('https://lobste.rs/s/def456/self'); 26 + }); 27 + });
+87
src/server/feed/parseHtml.ts
··· 1 + import type { Story } from '../../types'; 2 + import { absolutizeUrl, SOURCE_ORIGIN } from '../../lib/sources'; 3 + 4 + const HTML_ENTITIES: Record<string, string> = { 5 + amp: '&', 6 + lt: '<', 7 + gt: '>', 8 + quot: '"', 9 + apos: "'", 10 + '#x27': "'", 11 + '#39': "'", 12 + nbsp: ' ' 13 + }; 14 + 15 + export function decodeHtml(value = ''): string { 16 + return value 17 + .replace(/<[^>]*>/g, '') 18 + .replace(/&(#x?[\da-f]+|\w+);/gi, (_, key: string) => { 19 + const normalized = key.toLowerCase(); 20 + if (HTML_ENTITIES[normalized]) return HTML_ENTITIES[normalized]; 21 + if (normalized.startsWith('#x')) return String.fromCodePoint(Number.parseInt(normalized.slice(2), 16)); 22 + if (normalized.startsWith('#')) return String.fromCodePoint(Number.parseInt(normalized.slice(1), 10)); 23 + return `&${key};`; 24 + }) 25 + .replace(/\s+/g, ' ') 26 + .trim(); 27 + } 28 + 29 + function isoFromUnixSeconds(value: string | undefined): string { 30 + const parsed = Number(value); 31 + return new Date(Number.isFinite(parsed) && parsed > 0 ? parsed * 1000 : 0).toISOString(); 32 + } 33 + 34 + export function parseHnHtml(html: string): Story[] { 35 + const rows = [...html.matchAll(/<tr class="athing[^>]*" id="(\d+)"[^>]*>([\s\S]*?)<\/tr>\s*<tr>([\s\S]*?)<\/tr>/g)]; 36 + return rows.map((match, index): Story | null => { 37 + const [, id, titleRow, subRow] = match; 38 + const link = titleRow.match(/class="titleline">\s*<a href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/); 39 + const title = decodeHtml(link?.[2]); 40 + if (!title) return null; 41 + const rawUrl = decodeHtml(link?.[1] ?? `item?id=${id}`); 42 + const url = absolutizeUrl(rawUrl, SOURCE_ORIGIN.hn) ?? `${SOURCE_ORIGIN.hn}/item?id=${id}`; 43 + const epoch = subRow.match(/class="age" title="[^"]*\s(\d+)"/); 44 + return { 45 + id: `hn-${id}`, 46 + source: 'hn' as const, 47 + sourceId: id, 48 + sourceRank: index, 49 + title, 50 + url, 51 + discussionUrl: `${SOURCE_ORIGIN.hn}/item?id=${id}`, 52 + author: decodeHtml(subRow.match(/class="hnuser"[^>]*>([\s\S]*?)<\/a>/)?.[1]) || undefined, 53 + points: Number(subRow.match(/class="score"[^>]*>(\d+)/)?.[1] ?? 0), 54 + commentsCount: Number(subRow.match(/>(\d+)(?:&nbsp;|\s)+comments?<\/a>/)?.[1] ?? 0), 55 + createdAt: isoFromUnixSeconds(epoch?.[1]), 56 + tags: ['HN'] 57 + } satisfies Story; 58 + }).filter((story): story is Story => story !== null); 59 + } 60 + 61 + export function parseLobstersHtml(html: string): Story[] { 62 + const blocks = [...html.matchAll(/<li id="story_([a-z0-9]+)"([\s\S]*?)(?=<li id="story_|<\/ol>)/gi)]; 63 + return blocks.map((match, index): Story | null => { 64 + const id = match[1]; 65 + const block = match[0]; 66 + const link = block.match(/<a class="u-url" href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/); 67 + const title = decodeHtml(link?.[2]); 68 + if (!title) return null; 69 + const discussion = block.match(/class="comments_label"[\s\S]*?<a[^>]+href="([^"]+)"/); 70 + const epoch = block.match(/data-at-unix="(\d+)"/); 71 + const tags = [...block.matchAll(/class="tag [^"]+"[^>]*>([^<]+)<\/a>/g)].map(tag => decodeHtml(tag[1])).filter(Boolean); 72 + return { 73 + id: `lobsters-${id}`, 74 + source: 'lobsters' as const, 75 + sourceId: id, 76 + sourceRank: index, 77 + title, 78 + url: absolutizeUrl(decodeHtml(link?.[1] ?? `/s/${id}`), SOURCE_ORIGIN.lobsters) ?? `${SOURCE_ORIGIN.lobsters}/s/${id}`, 79 + discussionUrl: absolutizeUrl(decodeHtml(discussion?.[1] ?? `/s/${id}`), SOURCE_ORIGIN.lobsters) ?? `${SOURCE_ORIGIN.lobsters}/s/${id}`, 80 + author: decodeHtml(block.match(/<a href="\/~[^"]+">([^<]+)<\/a>\s*<time/)?.[1]) || undefined, 81 + points: Number(block.match(/class="upvoter"[^>]*>(\d+)/)?.[1] ?? 0), 82 + commentsCount: Number(block.match(/class="comments_label"[\s\S]*?>\s*(\d+)\s+comments?/)?.[1] ?? 0), 83 + createdAt: isoFromUnixSeconds(epoch?.[1]), 84 + tags: ['Lobsters', ...tags] 85 + } satisfies Story; 86 + }).filter((story): story is Story => story !== null); 87 + }
+26
src/settings.test.ts
··· 1 + import { describe, expect, it } from 'vitest'; 2 + import { defaultSettings, normalizeSettings, normalizeSourceEnabled, parseCommaList, STORAGE_KEY, toggleSource } from './settings'; 3 + 4 + describe('settings normalization', () => { 5 + it('documents storage compatibility key', () => { 6 + expect(STORAGE_KEY).toBe('hnster.preferences.v1'); 7 + }); 8 + 9 + it('falls back from corrupt shapes and clamps values', () => { 10 + const settings = normalizeSettings({ theme: 'bad', columns: 99, typeScale: 2, sourceWeight: { hn: -1, lobsters: 10 } }); 11 + expect(settings.theme).toBe(defaultSettings.theme); 12 + expect(settings.columns).toBe(5); 13 + expect(settings.typeScale).toBe(1.3); 14 + expect(settings.sourceWeight).toEqual({ hn: 0, lobsters: 2 }); 15 + }); 16 + 17 + it('deduplicates and lowercases list preferences', () => { 18 + expect(normalizeSettings({ blockedDomains: [' Example.COM ', 'example.com', '', 1], lobstersTags: ['Security', 'security'] }).blockedDomains).toEqual(['example.com']); 19 + expect(parseCommaList('A, b, a')).toEqual(['a', 'b']); 20 + }); 21 + 22 + it('never allows all sources disabled', () => { 23 + expect(normalizeSourceEnabled({ hn: false, lobsters: false })).toEqual({ hn: true, lobsters: false }); 24 + expect(toggleSource({ ...defaultSettings, sourceEnabled: { hn: true, lobsters: false } }, 'hn', false).sourceEnabled).toEqual({ hn: true, lobsters: false }); 25 + }); 26 + });
+106
src/settings.ts
··· 1 + import type { Density, FontChoice, LayoutChoice, ReaderSettings, Source, Theme } from './types'; 2 + import { SOURCES, normalizeSourceEnabled as normalizeSharedSourceEnabled } from './lib/sources'; 3 + 4 + export const defaultSettings: ReaderSettings = { 5 + theme: 'light', 6 + font: 'serif', 7 + typeScale: 1, 8 + density: 'comfortable', 9 + columns: 3, 10 + layout: 'list', 11 + sourceEnabled: { hn: true, lobsters: true }, 12 + sourceWeight: { hn: 1, lobsters: 1 }, 13 + halfLifeHours: 18, 14 + commentWeight: 0.35, 15 + hnFeed: 'top', 16 + lobstersFeed: 'hottest', 17 + blockedDomains: [], 18 + lobstersTags: [], 19 + highlightThreads: true 20 + }; 21 + 22 + // Compatibility key from the original HNster name. Do not change without migration. 23 + export const STORAGE_KEY = 'hnster.preferences.v1'; 24 + 25 + function isRecord(value: unknown): value is Record<string, unknown> { 26 + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); 27 + } 28 + 29 + export function clampNumber(value: unknown, fallback: number, min: number, max: number): number { 30 + const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : Number.NaN; 31 + if (!Number.isFinite(parsed)) return fallback; 32 + return Math.min(max, Math.max(min, parsed)); 33 + } 34 + 35 + export function enumValue<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T { 36 + return typeof value === 'string' && allowed.includes(value as T) ? (value as T) : fallback; 37 + } 38 + 39 + export function normalizeStringList(value: unknown): string[] { 40 + if (!Array.isArray(value)) return []; 41 + return [...new Set(value.filter((item): item is string => typeof item === 'string').map(item => item.trim().toLowerCase()).filter(Boolean))].slice(0, 100); 42 + } 43 + 44 + export function parseCommaList(value: string): string[] { 45 + return normalizeStringList(value.split(',')); 46 + } 47 + 48 + export function normalizeSourceEnabled(value: unknown): Record<Source, boolean> { 49 + const record = isRecord(value) ? value : {}; 50 + const enabled = SOURCES.reduce<Record<Source, boolean>>((result, source) => { 51 + result[source] = typeof record[source] === 'boolean' ? record[source] : defaultSettings.sourceEnabled[source]; 52 + return result; 53 + }, { ...defaultSettings.sourceEnabled }); 54 + return normalizeSharedSourceEnabled(enabled); 55 + } 56 + 57 + function sourceWeights(value: unknown): Record<Source, number> { 58 + const record = isRecord(value) ? value : {}; 59 + return SOURCES.reduce<Record<Source, number>>((result, source) => { 60 + result[source] = clampNumber(record[source], defaultSettings.sourceWeight[source], 0, 2); 61 + return result; 62 + }, { ...defaultSettings.sourceWeight }); 63 + } 64 + 65 + export function normalizeSettings(value: unknown): ReaderSettings { 66 + const record = isRecord(value) ? value : {}; 67 + return { 68 + theme: enumValue<Theme>(record.theme, ['light', 'dark', 'sepia'], defaultSettings.theme), 69 + font: enumValue<FontChoice>(record.font, ['system', 'serif', 'mono'], defaultSettings.font), 70 + typeScale: clampNumber(record.typeScale, defaultSettings.typeScale, 0.85, 1.3), 71 + density: enumValue<Density>(record.density, ['relaxed', 'comfortable', 'compact'], defaultSettings.density), 72 + columns: Math.round(clampNumber(record.columns, defaultSettings.columns, 1, 5)), 73 + layout: enumValue<LayoutChoice>(record.layout, ['newspaper', 'list'], defaultSettings.layout), 74 + sourceEnabled: normalizeSourceEnabled(record.sourceEnabled), 75 + sourceWeight: sourceWeights(record.sourceWeight), 76 + halfLifeHours: Math.round(clampNumber(record.halfLifeHours, defaultSettings.halfLifeHours, 4, 72)), 77 + commentWeight: clampNumber(record.commentWeight, defaultSettings.commentWeight, 0, 1.5), 78 + hnFeed: enumValue<ReaderSettings['hnFeed']>(record.hnFeed, ['top', 'best', 'new'], defaultSettings.hnFeed), 79 + lobstersFeed: enumValue<ReaderSettings['lobstersFeed']>(record.lobstersFeed, ['hottest', 'newest'], defaultSettings.lobstersFeed), 80 + blockedDomains: normalizeStringList(record.blockedDomains), 81 + lobstersTags: normalizeStringList(record.lobstersTags), 82 + highlightThreads: typeof record.highlightThreads === 'boolean' ? record.highlightThreads : defaultSettings.highlightThreads 83 + }; 84 + } 85 + 86 + export function toggleSource(settings: ReaderSettings, source: Source, enabled: boolean): ReaderSettings { 87 + const next = normalizeSourceEnabled({ ...settings.sourceEnabled, [source]: enabled }); 88 + return { ...settings, sourceEnabled: next }; 89 + } 90 + 91 + export function loadSettings(): ReaderSettings { 92 + try { 93 + const raw = localStorage.getItem(STORAGE_KEY); 94 + return raw ? normalizeSettings(JSON.parse(raw) as unknown) : defaultSettings; 95 + } catch { 96 + return defaultSettings; 97 + } 98 + } 99 + 100 + export function saveSettings(settings: ReaderSettings): void { 101 + localStorage.setItem(STORAGE_KEY, JSON.stringify(normalizeSettings(settings))); 102 + } 103 + 104 + export function resetSettings(): void { 105 + localStorage.removeItem(STORAGE_KEY); 106 + }
+11
src/styles.css
··· 1 + @import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Lora:ital,wght@0,400..700;1,400..700&family=Merriweather:ital,wght@0,300..900;1,300..900&display=swap'); 2 + @import './styles/00-theme.css'; 3 + @import './styles/01-base.css'; 4 + @import './styles/02-layout.css'; 5 + @import './styles/03-feed.css'; 6 + @import './styles/04-story-card.css'; 7 + @import './styles/05-discussion.css'; 8 + @import './styles/06-comments.css'; 9 + @import './styles/07-settings.css'; 10 + @import './styles/08-utilities.css'; 11 + @import './styles/09-responsive.css';
+92
src/styles/00-theme.css
··· 1 + /* ========================================================================== 2 + 1. Core Variables & Theme Definitions 3 + ========================================================================== */ 4 + 5 + :root { 6 + /* Transition timings */ 7 + --transition-fast: 0.15s ease; 8 + --transition-normal: 0.25s ease; 9 + 10 + /* Font Family Stacks */ 11 + --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 12 + --font-serif: "Merriweather", "Lora", "Iowan Old Style", Charter, Georgia, serif; 13 + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Roboto Mono, monospace; 14 + } 15 + 16 + /* Light Theme Variables */ 17 + :root, 18 + .theme-light { 19 + --bg-app: hsl(210, 20%, 98%); 20 + --bg-card: hsl(0, 0%, 100%); 21 + --bg-card-hover: hsl(354, 100%, 99.5%); 22 + --color-text: hsl(215, 25%, 12%); 23 + --color-text-strong: hsl(215, 30%, 8%); 24 + --color-muted: hsl(215, 12%, 46%); 25 + 26 + --color-accent: hsl(354, 85%, 48%); 27 + --color-accent-hn: hsl(25, 95%, 45%); 28 + --color-accent-lobsters: hsl(354, 85%, 48%); 29 + 30 + --bg-badge-hn: hsl(25, 95%, 93%); 31 + --bg-badge-lobsters: hsl(354, 90%, 95%); 32 + 33 + --color-border: hsl(215, 15%, 88%); 34 + --color-border-soft: hsl(215, 15%, 93%); 35 + --color-border-active: hsl(354, 85%, 48%); 36 + 37 + --color-active-bg: hsl(354, 85%, 97%); 38 + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.03); 39 + --shadow-md: 0 4px 12px 0 rgba(0, 0, 0, 0.04); 40 + } 41 + 42 + .theme-dark { 43 + /* Deep blue-charcoal surfaces are softer than neutral black while keeping 44 + long-form text crisp. Muted text remains above WCAG AA on every surface. */ 45 + color-scheme: dark; 46 + --bg-app: hsl(198, 19%, 9%); 47 + --bg-card: hsl(198, 17%, 13%); 48 + --bg-card-hover: hsl(198, 16%, 17%); 49 + --color-text: hsl(195, 14%, 86%); 50 + --color-text-strong: hsl(190, 20%, 96%); 51 + --color-muted: hsl(195, 10%, 65%); 52 + 53 + --color-accent: hsl(7, 86%, 72%); 54 + --color-accent-hn: hsl(31, 88%, 67%); 55 + --color-accent-lobsters: hsl(7, 86%, 72%); 56 + 57 + --bg-badge-hn: hsl(31, 38%, 19%); 58 + --bg-badge-lobsters: hsl(7, 36%, 19%); 59 + 60 + --color-border: hsl(196, 13%, 27%); 61 + --color-border-soft: hsl(197, 14%, 20%); 62 + --color-border-active: hsl(7, 86%, 72%); 63 + 64 + --color-active-bg: hsl(7, 34%, 19%); 65 + --shadow-sm: 0 2px 5px rgba(0, 0, 0, 0.28); 66 + --shadow-md: 0 10px 30px rgba(0, 0, 0, 0.38); 67 + } 68 + 69 + /* Sepia Theme Variables */ 70 + .theme-sepia { 71 + --bg-app: hsl(36, 32%, 90%); 72 + --bg-card: hsl(36, 32%, 93%); 73 + --bg-card-hover: hsl(36, 32%, 95%); 74 + --color-text: hsl(36, 45%, 16%); 75 + --color-text-strong: hsl(36, 50%, 10%); 76 + --color-muted: hsl(36, 25%, 38%); 77 + 78 + --color-accent: hsl(10, 75%, 40%); 79 + --color-accent-hn: hsl(25, 80%, 40%); 80 + --color-accent-lobsters: hsl(5, 75%, 40%); 81 + 82 + --bg-badge-hn: hsl(25, 60%, 86%); 83 + --bg-badge-lobsters: hsl(5, 60%, 86%); 84 + 85 + --color-border: hsl(36, 20%, 80%); 86 + --color-border-soft: hsl(36, 20%, 85%); 87 + --color-border-active: hsl(10, 75%, 40%); 88 + 89 + --color-active-bg: hsl(10, 60%, 86%); 90 + --shadow-sm: 0 1px 3px 0 rgba(40, 20, 0, 0.05); 91 + --shadow-md: 0 6px 18px 0 rgba(40, 20, 0, 0.08); 92 + }
+92
src/styles/01-base.css
··· 1 + /* ========================================================================== 2 + 2. Density Adjustments 3 + ========================================================================== */ 4 + 5 + .density-relaxed { 6 + --story-padding: 1.5rem 1.75rem; 7 + --story-gap: 1.15rem; 8 + --line-height-body: 1.65; 9 + --font-size-base: 16px; 10 + --comment-padding: 1rem; 11 + } 12 + 13 + .density-comfortable { 14 + --story-padding: 1.1rem 1.35rem; 15 + --story-gap: 0.85rem; 16 + --line-height-body: 1.55; 17 + --font-size-base: 15px; 18 + --comment-padding: 0.75rem; 19 + } 20 + 21 + .density-compact { 22 + --story-padding: 0.65rem 0.9rem; 23 + --story-gap: 0.5rem; 24 + --line-height-body: 1.45; 25 + --font-size-base: 14px; 26 + --comment-padding: 0.45rem; 27 + } 28 + 29 + /* ========================================================================== 30 + 3. Base Layout & Typography Defaults 31 + ========================================================================== */ 32 + 33 + * { 34 + box-sizing: border-box; 35 + } 36 + 37 + html { 38 + font-size: calc(var(--font-size-base, 15px) * var(--type-scale, 1.0)); 39 + scroll-behavior: smooth; 40 + height: 100%; 41 + transition: font-size var(--transition-normal); 42 + } 43 + 44 + body { 45 + margin: 0; 46 + padding: 0; 47 + min-width: 320px; 48 + height: 100%; 49 + font-family: var(--font-sans); 50 + background-color: var(--bg-app); 51 + color: var(--color-text); 52 + text-rendering: optimizeLegibility; 53 + -webkit-font-smoothing: antialiased; 54 + -moz-osx-font-smoothing: grayscale; 55 + } 56 + 57 + .app { 58 + font-family: var(--reader-font); 59 + font-size: 1rem; 60 + color: var(--color-text); 61 + background-color: var(--bg-app); 62 + min-height: 100vh; 63 + padding: 0 clamp(1rem, 4vw, 4rem) 5rem; 64 + transition: background-color var(--transition-normal), color var(--transition-normal); 65 + } 66 + 67 + a { 68 + color: inherit; 69 + text-decoration: none; 70 + transition: color var(--transition-fast), border-color var(--transition-fast); 71 + } 72 + 73 + button, 74 + select, 75 + input { 76 + font-family: inherit; 77 + font-size: inherit; 78 + color: inherit; 79 + } 80 + 81 + button { 82 + cursor: pointer; 83 + border: none; 84 + background: none; 85 + padding: 0; 86 + } 87 + 88 + .masthead, 89 + main { 90 + max-width: 1280px; 91 + margin: 0 auto; 92 + }
+77
src/styles/02-layout.css
··· 1 + /* ========================================================================== 2 + 4. Masthead (Navigation Bar) 3 + ========================================================================== */ 4 + 5 + .masthead { 6 + display: flex; 7 + justify-content: space-between; 8 + align-items: center; 9 + min-height: 52px; 10 + padding: 0.65rem 0; 11 + border-bottom: 1px solid var(--color-border); 12 + margin-bottom: 0.75rem; 13 + } 14 + 15 + .wordmark { 16 + font-family: var(--font-serif); 17 + font-size: clamp(1.35rem, 2vw, 1.65rem); 18 + font-weight: 700; 19 + letter-spacing: -0.04em; 20 + color: var(--color-text-strong); 21 + line-height: 1; 22 + } 23 + 24 + .wordmark:hover { 25 + color: var(--color-accent); 26 + } 27 + 28 + .masthead > p { 29 + margin: 0; 30 + font-size: 0.75rem; 31 + font-weight: 600; 32 + color: var(--color-muted); 33 + text-transform: uppercase; 34 + letter-spacing: 0.12em; 35 + display: inline-block; 36 + padding: 0.25rem 0.75rem; 37 + border-radius: 4px; 38 + background: var(--bg-card); 39 + border: 1px solid var(--color-border-soft); 40 + } 41 + 42 + .masthead nav { 43 + display: flex; 44 + gap: 1.1rem; 45 + } 46 + 47 + .masthead nav a { 48 + font-size: 0.85rem; 49 + font-weight: 600; 50 + text-transform: uppercase; 51 + letter-spacing: 0.06em; 52 + color: var(--color-muted); 53 + position: relative; 54 + padding: 0.25rem 0; 55 + } 56 + 57 + .masthead nav a:hover { 58 + color: var(--color-text-strong); 59 + } 60 + 61 + .masthead nav a::after { 62 + content: ''; 63 + position: absolute; 64 + bottom: 0; 65 + left: 0; 66 + width: 100%; 67 + height: 2px; 68 + background-color: var(--color-accent); 69 + transform: scaleX(0); 70 + transform-origin: right; 71 + transition: transform var(--transition-fast); 72 + } 73 + 74 + .masthead nav a:hover::after { 75 + transform: scaleX(1); 76 + transform-origin: left; 77 + }
+183
src/styles/03-feed.css
··· 1 + /* ========================================================================== 2 + 5. Front Page & Newspaper Feed Grid 3 + ========================================================================== */ 4 + 5 + .edition-bar { 6 + display: flex; 7 + justify-content: space-between; 8 + align-items: center; 9 + padding: 0.75rem 1rem; 10 + background: var(--bg-card); 11 + border: 1px solid var(--color-border); 12 + border-radius: 6px; 13 + margin-bottom: 2rem; 14 + box-shadow: var(--shadow-sm); 15 + font-size: 0.75rem; 16 + font-weight: 600; 17 + text-transform: uppercase; 18 + letter-spacing: 0.05em; 19 + color: var(--color-muted); 20 + } 21 + 22 + .edition-bar strong { 23 + color: var(--color-text-strong); 24 + } 25 + 26 + .edition-bar button.text-button { 27 + color: var(--color-accent); 28 + font-weight: 700; 29 + border: 1px solid var(--color-border-soft); 30 + padding: 0.35rem 0.75rem; 31 + border-radius: 4px; 32 + transition: background-color var(--transition-fast), border-color var(--transition-fast); 33 + } 34 + 35 + .edition-bar button.text-button:hover { 36 + background-color: var(--color-active-bg); 37 + border-color: var(--color-accent); 38 + } 39 + 40 + .edition-actions { 41 + display: flex; 42 + align-items: center; 43 + gap: 0.75rem; 44 + } 45 + 46 + .layout-switcher { 47 + display: inline-flex; 48 + background-color: var(--bg-app); 49 + border: 1px solid var(--color-border); 50 + padding: 2px; 51 + border-radius: 6px; 52 + box-shadow: var(--shadow-sm); 53 + } 54 + 55 + .layout-btn { 56 + font-size: 0.65rem; 57 + font-weight: 700; 58 + text-transform: uppercase; 59 + color: var(--color-muted); 60 + border: 1px solid transparent; 61 + background: transparent; 62 + padding: 0.25rem 0.65rem; 63 + border-radius: 4px; 64 + cursor: pointer; 65 + transition: all var(--transition-fast); 66 + user-select: none; 67 + } 68 + 69 + .layout-btn:hover { 70 + color: var(--color-text-strong); 71 + } 72 + 73 + .layout-btn.active { 74 + color: var(--color-text-strong); 75 + background-color: var(--bg-card); 76 + border-color: var(--color-border); 77 + box-shadow: var(--shadow-sm); 78 + } 79 + 80 + .edition-bar button.text-button.refresh-btn { 81 + border-color: var(--color-border); 82 + background-color: var(--bg-card); 83 + color: var(--color-muted); 84 + } 85 + 86 + .edition-bar button.text-button.refresh-btn:hover { 87 + color: var(--color-text-strong); 88 + border-color: var(--color-muted); 89 + background-color: var(--bg-card-hover); 90 + } 91 + 92 + .notice { 93 + background-color: var(--color-active-bg); 94 + border: 1px solid var(--color-accent); 95 + border-radius: 6px; 96 + padding: 1rem; 97 + margin-bottom: 1.5rem; 98 + font-size: 0.9rem; 99 + color: var(--color-text-strong); 100 + } 101 + 102 + .notice strong { 103 + color: var(--color-accent); 104 + } 105 + 106 + /* Elegant 1px Grid Gap system for Newspaper layouts */ 107 + .newspaper-grid { 108 + display: grid; 109 + grid-template-columns: repeat(var(--column-count, 3), minmax(0, 1fr)); 110 + background-color: var(--color-border); 111 + gap: 1px; 112 + border: 1px solid var(--color-border); 113 + border-radius: 8px; 114 + overflow: hidden; 115 + box-shadow: var(--shadow-sm); 116 + } 117 + 118 + /* Classic Listing Layout (alternate interface like Lobsters/HN) */ 119 + .list-layout { 120 + display: flex; 121 + flex-direction: column; 122 + background-color: var(--bg-card); 123 + border: 1px solid var(--color-border); 124 + border-radius: 8px; 125 + overflow: hidden; 126 + box-shadow: var(--shadow-sm); 127 + margin-top: 1.5rem; 128 + } 129 + 130 + .list-row { 131 + display: flex; 132 + align-items: flex-start; 133 + border-bottom: 1px solid var(--color-border-soft); 134 + padding: 0.65rem 1rem; 135 + transition: background-color var(--transition-fast); 136 + } 137 + 138 + .list-row:last-child { 139 + border-bottom: none; 140 + } 141 + 142 + .list-row:hover { 143 + background-color: var(--bg-card-hover); 144 + } 145 + 146 + .story-rank { 147 + font-size: 0.85em; 148 + font-weight: 700; 149 + color: var(--color-muted); 150 + min-width: 24px; 151 + margin-top: 0.2rem; 152 + text-align: right; 153 + padding-right: 0.65rem; 154 + user-select: none; 155 + } 156 + 157 + /* Make StoryCard render on a single, clean line block inside lists */ 158 + .list-row .story-card { 159 + background-color: transparent !important; 160 + padding: 0 !important; 161 + min-height: auto !important; 162 + border: none !important; 163 + flex: 1; 164 + } 165 + 166 + .list-row .story-card .story-kicker { 167 + margin-bottom: 0.15rem; 168 + } 169 + 170 + .list-row .story-card .story-title { 171 + margin-bottom: 0.15rem; 172 + font-size: 1.05em; 173 + font-weight: 600; 174 + } 175 + 176 + .list-row .story-card .story-meta { 177 + margin-top: 0.15rem; 178 + font-size: 0.72em; 179 + } 180 + 181 + .list-row .story-card .tag-row { 182 + margin-top: 0.25rem; 183 + }
+171
src/styles/04-story-card.css
··· 1 + /* ========================================================================== 2 + 6. Story Cards 3 + ========================================================================== */ 4 + 5 + .story-card { 6 + background-color: var(--bg-card); 7 + padding: var(--story-padding); 8 + display: flex; 9 + flex-direction: column; 10 + justify-content: flex-start; 11 + transition: background-color var(--transition-fast); 12 + min-height: 180px; 13 + } 14 + 15 + .story-card:hover { 16 + background-color: var(--bg-card-hover); 17 + } 18 + 19 + .story-card.featured { 20 + grid-column: span 2; 21 + min-height: 250px; 22 + } 23 + 24 + .story-kicker { 25 + display: flex; 26 + align-items: center; 27 + gap: 0.75rem; 28 + margin-bottom: var(--story-gap); 29 + font-size: 0.7rem; 30 + font-weight: 700; 31 + text-transform: uppercase; 32 + letter-spacing: 0.05em; 33 + color: var(--color-muted); 34 + } 35 + 36 + .source-badge { 37 + padding: 2px 8px; 38 + border-radius: 12px; 39 + font-weight: 800; 40 + font-size: 0.65rem; 41 + } 42 + 43 + .badge-hn { 44 + background-color: var(--bg-badge-hn); 45 + color: var(--color-accent-hn); 46 + } 47 + 48 + .badge-lobsters { 49 + background-color: var(--bg-badge-lobsters); 50 + color: var(--color-accent-lobsters); 51 + } 52 + 53 + .story-points { 54 + font-weight: 600; 55 + } 56 + 57 + .story-title { 58 + margin: 0 0 var(--story-gap) 0; 59 + font-family: var(--font-sans); 60 + font-size: 1.15rem; 61 + font-weight: 600; 62 + line-height: 1.35; 63 + letter-spacing: -0.01em; 64 + word-wrap: break-word; 65 + } 66 + 67 + .story-card.featured .story-title { 68 + font-size: 1.6rem; 69 + font-weight: 700; 70 + line-height: 1.25; 71 + font-family: var(--font-serif); 72 + } 73 + 74 + .story-link { 75 + color: var(--color-text-strong); 76 + } 77 + 78 + .story-link:hover { 79 + color: var(--color-accent); 80 + text-decoration: underline; 81 + text-decoration-thickness: 1px; 82 + text-underline-offset: 2px; 83 + } 84 + 85 + .title-host { 86 + font-size: 0.8rem; 87 + font-weight: 400; 88 + color: var(--color-muted); 89 + margin-left: 0.4rem; 90 + white-space: nowrap; 91 + } 92 + 93 + .story-meta { 94 + margin-top: auto; 95 + font-size: 0.75rem; 96 + color: var(--color-muted); 97 + display: flex; 98 + align-items: center; 99 + flex-wrap: wrap; 100 + gap: 0.35rem 0.5rem; 101 + } 102 + 103 + .story-author { 104 + font-weight: 500; 105 + color: var(--color-text); 106 + } 107 + 108 + .meta-separator { 109 + color: var(--color-border); 110 + } 111 + 112 + .story-comments-link, 113 + .story-read-link { 114 + font-weight: 600; 115 + color: var(--color-text); 116 + border-bottom: 1px solid transparent; 117 + } 118 + 119 + .story-comments-link:hover, 120 + .story-read-link:hover { 121 + color: var(--color-accent); 122 + border-bottom-color: var(--color-accent); 123 + } 124 + 125 + .tag-row { 126 + margin-top: 0.75rem; 127 + display: flex; 128 + flex-wrap: wrap; 129 + gap: 0.35rem; 130 + } 131 + 132 + .tag { 133 + display: inline-block; 134 + padding: 2px 6px; 135 + border-radius: 4px; 136 + font-size: 0.65rem; 137 + font-weight: 600; 138 + text-transform: lowercase; 139 + background-color: var(--bg-app); 140 + color: var(--color-muted); 141 + border: 1px solid var(--color-border-soft); 142 + transition: all var(--transition-fast); 143 + } 144 + 145 + .tag:hover { 146 + color: var(--color-text-strong); 147 + border-color: var(--color-muted); 148 + background-color: var(--bg-card); 149 + } 150 + 151 + .title-tags { 152 + display: inline-flex; 153 + gap: 0.25rem; 154 + margin-left: 0.5rem; 155 + vertical-align: middle; 156 + } 157 + 158 + .title-tags .tag { 159 + margin: 0; 160 + font-size: 0.55em; 161 + padding: 1px 4px; 162 + border-radius: 3px; 163 + } 164 + 165 + /* Custom Tag Aesthetics matching specific topics */ 166 + .tag-rust { color: hsl(15, 75%, 40%); border-color: hsl(15, 75%, 85%); background-color: hsl(15, 75%, 97%); } 167 + .tag-javascript, .tag-js { color: hsl(45, 75%, 35%); border-color: hsl(45, 75%, 85%); background-color: hsl(45, 75%, 96%); } 168 + .tag-go, .tag-golang { color: hsl(190, 75%, 30%); border-color: hsl(190, 75%, 85%); background-color: hsl(190, 75%, 96%); } 169 + .tag-python { color: hsl(210, 75%, 35%); border-color: hsl(210, 75%, 85%); background-color: hsl(210, 75%, 96%); } 170 + .tag-security { color: hsl(0, 75%, 40%); border-color: hsl(0, 75%, 85%); background-color: hsl(0, 75%, 96%); } 171 + .tag-ask, .tag-show { color: hsl(270, 60%, 40%); border-color: hsl(270, 60%, 85%); background-color: hsl(270, 60%, 96%); }
+445
src/styles/05-discussion.css
··· 1 + /* ========================================================================== 2 + 7. Discussion Page 3 + ========================================================================== */ 4 + 5 + .discussion-page { 6 + padding-top: 1.5rem; 7 + } 8 + 9 + .back { 10 + display: inline-flex; 11 + align-items: center; 12 + margin-bottom: 1.5rem; 13 + font-size: 0.8rem; 14 + font-weight: 700; 15 + text-transform: uppercase; 16 + letter-spacing: 0.05em; 17 + color: var(--color-muted); 18 + } 19 + 20 + .back:hover { 21 + color: var(--color-accent); 22 + transform: translateX(-3px); 23 + } 24 + 25 + .story-header { 26 + max-width: 800px; 27 + margin: 0 auto 2.5rem auto; 28 + border-bottom: 1px solid var(--color-border-soft); 29 + padding-bottom: 2rem; 30 + } 31 + 32 + .story-header .section-label { 33 + font-size: 0.75rem; 34 + font-weight: 800; 35 + text-transform: uppercase; 36 + letter-spacing: 0.1em; 37 + color: var(--color-accent); 38 + margin-bottom: 0.5rem; 39 + } 40 + 41 + .story-header h1 { 42 + font-family: var(--font-serif); 43 + font-size: clamp(1.8rem, 4vw, 2.75rem); 44 + line-height: 1.2; 45 + font-weight: 700; 46 + color: var(--color-text-strong); 47 + margin: 0.5rem 0 1.5rem 0; 48 + letter-spacing: -0.02em; 49 + } 50 + 51 + .story-actions { 52 + display: flex; 53 + flex-wrap: wrap; 54 + align-items: center; 55 + gap: 0.75rem 1rem; 56 + } 57 + 58 + .story-actions button, 59 + .story-actions a { 60 + font-size: 0.75rem; 61 + font-weight: 700; 62 + text-transform: uppercase; 63 + letter-spacing: 0.05em; 64 + padding: 0.5rem 1rem; 65 + border-radius: 4px; 66 + border: 1px solid var(--color-border); 67 + transition: all var(--transition-fast); 68 + } 69 + 70 + .story-actions button { 71 + background-color: var(--bg-card); 72 + color: var(--color-text-strong); 73 + } 74 + 75 + .story-actions button:hover, 76 + .story-actions button.selected { 77 + background-color: var(--color-accent); 78 + border-color: var(--color-accent); 79 + color: white; 80 + } 81 + 82 + .story-actions a { 83 + background-color: transparent; 84 + color: var(--color-muted); 85 + } 86 + 87 + .story-actions a:hover { 88 + border-color: var(--color-text-strong); 89 + color: var(--color-text-strong); 90 + } 91 + 92 + /* Reading Split Layout Frame */ 93 + .reading-layout { 94 + border-top: none; 95 + display: grid; 96 + grid-template-columns: minmax(0, 1fr); 97 + min-height: 60vh; 98 + } 99 + 100 + .discussion-page.reading .reading-layout { 101 + position: fixed; 102 + inset: 0; 103 + z-index: 100; 104 + display: flex; 105 + gap: 0; 106 + background-color: var(--bg-app); 107 + overflow: hidden; 108 + border-top: none; 109 + } 110 + 111 + /* Embedded Article Frame */ 112 + .article-pane { 113 + height: 100vh; 114 + display: flex; 115 + flex-direction: column; 116 + border-right: 1px solid var(--color-border); 117 + } 118 + 119 + .discussion-page.reading .article-pane { 120 + border-right: none; 121 + flex-grow: 0; 122 + flex-shrink: 0; 123 + } 124 + 125 + .iframe-note { 126 + height: 42px; 127 + padding: 0 1rem; 128 + display: flex; 129 + align-items: center; 130 + justify-content: space-between; 131 + background-color: var(--color-active-bg); 132 + border-bottom: 1px solid var(--color-border-soft); 133 + font-size: 0.72rem; 134 + color: var(--color-muted); 135 + user-select: none; 136 + } 137 + 138 + .iframe-note a { 139 + color: var(--color-accent); 140 + text-decoration: underline; 141 + } 142 + 143 + .article-pane iframe { 144 + flex: 1; 145 + width: 100%; 146 + border: 0; 147 + background-color: white; 148 + } 149 + 150 + /* Resizable divider drag handle */ 151 + .resize-handle { 152 + width: 6px; 153 + height: 100vh; 154 + background-color: var(--bg-app); 155 + cursor: col-resize; 156 + z-index: 110; 157 + position: relative; 158 + flex-shrink: 0; 159 + } 160 + 161 + /* Tiny vertical drag handle in the middle */ 162 + .resize-handle::after { 163 + content: ''; 164 + position: absolute; 165 + top: 50%; 166 + left: -2px; 167 + transform: translateY(-50%); 168 + width: 3px; 169 + height: 28px; 170 + background-color: var(--color-border-active); 171 + border-radius: 2px; 172 + transition: background-color var(--transition-fast), height var(--transition-fast), width var(--transition-fast); 173 + } 174 + 175 + .resize-handle:hover::after, 176 + .resize-handle:focus-visible::after, 177 + body.resizing .resize-handle::after { 178 + background-color: var(--color-accent); 179 + width: 4px; 180 + height: 36px; 181 + } 182 + 183 + .resize-handle:focus-visible { 184 + outline: 2px solid var(--color-accent); 185 + outline-offset: -2px; 186 + } 187 + 188 + /* Discussion / Comments Section */ 189 + .discussion-column { 190 + width: 100%; 191 + max-width: 800px; 192 + margin: 0 auto; 193 + padding: 1.5rem 0 5rem 0; 194 + } 195 + 196 + .discussion-page.reading .discussion-column { 197 + max-width: none; 198 + width: auto; 199 + margin: 0; 200 + height: 100vh; 201 + overflow-y: auto; 202 + padding: 1.5rem 2rem 5rem 2rem; 203 + background-color: var(--bg-app); 204 + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.05); 205 + } 206 + 207 + .discussion-heading { 208 + display: flex; 209 + justify-content: space-between; 210 + align-items: center; 211 + border-bottom: none; 212 + padding-bottom: 0; 213 + margin-bottom: 1rem; 214 + } 215 + 216 + .discussion-heading h2 { 217 + font-family: var(--font-serif); 218 + font-size: 1.5rem; 219 + font-weight: 700; 220 + margin: 0; 221 + color: var(--color-text-strong); 222 + } 223 + 224 + 225 + 226 + .reader-close-btn { 227 + display: inline-flex; 228 + align-items: center; 229 + justify-content: center; 230 + color: var(--color-muted); 231 + border: none; 232 + background: transparent; 233 + padding: 0.2rem; 234 + cursor: pointer; 235 + transition: all var(--transition-fast); 236 + user-select: none; 237 + outline: none; 238 + margin-left: 0.25rem; 239 + } 240 + 241 + .reader-close-btn .close-icon { 242 + display: block; 243 + transition: color var(--transition-fast), transform var(--transition-fast); 244 + } 245 + 246 + .reader-close-btn:hover .close-icon { 247 + color: var(--color-accent); 248 + transform: scale(1.1); 249 + } 250 + 251 + .open-tab-link { 252 + display: flex; 253 + align-items: center; 254 + gap: 0.35rem; 255 + color: var(--color-accent); 256 + font-weight: 700; 257 + text-decoration: none; 258 + transition: color var(--transition-fast); 259 + } 260 + 261 + .open-tab-link:hover { 262 + text-decoration: underline; 263 + color: var(--color-accent); 264 + } 265 + 266 + .external-icon { 267 + display: block; 268 + flex-shrink: 0; 269 + } 270 + 271 + .discussion-meta-group { 272 + display: flex; 273 + align-items: center; 274 + gap: 0.5rem; 275 + } 276 + 277 + .discussion-meta-group span.comments-count { 278 + font-size: 0.8rem; 279 + font-weight: 600; 280 + color: var(--color-muted); 281 + text-transform: uppercase; 282 + letter-spacing: 0.05em; 283 + } 284 + 285 + /* Quick Reader Settings in Discussion Header (Popover menu) */ 286 + .quick-settings-container { 287 + position: relative; 288 + display: inline-flex; 289 + align-items: center; 290 + } 291 + 292 + .quick-settings-trigger { 293 + display: inline-flex; 294 + align-items: center; 295 + justify-content: center; 296 + color: var(--color-muted); 297 + border: none; 298 + background: transparent; 299 + padding: 0.2rem; 300 + cursor: pointer; 301 + transition: all var(--transition-fast); 302 + user-select: none; 303 + outline: none; 304 + } 305 + 306 + .quick-settings-trigger .gear-icon { 307 + display: block; 308 + transition: transform 0.3s cubic-bezier(0.25, 1, 0.5, 1), color var(--transition-fast); 309 + } 310 + 311 + .quick-settings-trigger:hover .gear-icon, 312 + .quick-settings-trigger.active .gear-icon { 313 + color: var(--color-text-strong); 314 + transform: rotate(45deg); 315 + } 316 + 317 + .quick-settings-popover { 318 + position: absolute; 319 + top: calc(100% + 8px); 320 + right: 0; 321 + z-index: 150; 322 + width: 240px; 323 + background-color: var(--bg-card); 324 + border: 1px solid var(--color-border); 325 + border-radius: 8px; 326 + box-shadow: var(--shadow-md); 327 + padding: 0.85rem; 328 + display: flex; 329 + flex-direction: column; 330 + gap: 0.75rem; 331 + animation: popover-fade-in 0.15s ease-out; 332 + } 333 + 334 + @keyframes popover-fade-in { 335 + from { 336 + opacity: 0; 337 + transform: translateY(-4px); 338 + } 339 + to { 340 + opacity: 1; 341 + transform: translateY(0); 342 + } 343 + } 344 + 345 + .popover-row { 346 + display: flex; 347 + justify-content: space-between; 348 + align-items: center; 349 + gap: 0.5rem; 350 + } 351 + 352 + .popover-label { 353 + font-size: 0.75rem; 354 + font-weight: 700; 355 + text-transform: uppercase; 356 + color: var(--color-muted); 357 + user-select: none; 358 + } 359 + 360 + .quick-settings-group { 361 + display: flex; 362 + align-items: center; 363 + gap: 2px; 364 + } 365 + 366 + .quick-settings-btn { 367 + font-size: 0.7rem; 368 + font-weight: 700; 369 + text-transform: uppercase; 370 + padding: 0 0.5rem; 371 + border-radius: 6px; 372 + border: 1px solid transparent; 373 + color: var(--color-muted); 374 + background: transparent; 375 + transition: all var(--transition-fast); 376 + cursor: pointer; 377 + display: inline-flex; 378 + align-items: center; 379 + justify-content: center; 380 + min-width: 20px; 381 + height: 20px; 382 + user-select: none; 383 + } 384 + 385 + .quick-settings-btn:hover { 386 + color: var(--color-text-strong); 387 + background-color: var(--color-border-soft); 388 + } 389 + 390 + .quick-settings-btn.active { 391 + color: var(--color-text-strong); 392 + background-color: var(--bg-app); 393 + border-color: var(--color-border); 394 + box-shadow: var(--shadow-sm); 395 + } 396 + 397 + /* Theme Button Icons styling */ 398 + .themes .quick-settings-btn { 399 + font-size: 0.65rem; 400 + font-weight: 800; 401 + border-radius: 50%; 402 + width: 18px; 403 + height: 18px; 404 + min-width: 18px; 405 + padding: 0; 406 + border: 1px solid var(--color-border-soft); 407 + } 408 + 409 + .theme-light-btn { 410 + background-color: #ffffff !important; 411 + color: #171717 !important; 412 + border-color: #cbd5e1 !important; 413 + } 414 + 415 + .theme-sepia-btn { 416 + background-color: #f4ecd8 !important; 417 + color: #5b4636 !important; 418 + border-color: #e4dcd3 !important; 419 + } 420 + 421 + .theme-dark-btn { 422 + background-color: #242428 !important; 423 + color: #f1f5f9 !important; 424 + border-color: #323237 !important; 425 + } 426 + 427 + .themes .quick-settings-btn.active { 428 + box-shadow: 0 0 0 2px var(--color-accent) !important; 429 + } 430 + 431 + /* Sizes indicator styling */ 432 + .sizes .current-scale { 433 + font-size: 0.7rem; 434 + font-weight: 700; 435 + color: var(--color-text); 436 + padding: 0 0.25rem; 437 + min-width: 32px; 438 + text-align: center; 439 + user-select: none; 440 + } 441 + 442 + .sizes .quick-settings-btn { 443 + font-size: 0.8rem; 444 + font-weight: 700; 445 + }
+177
src/styles/06-comments.css
··· 1 + /* ========================================================================== 2 + 8. Nested Threaded Comments (Thoughtful design) 3 + ========================================================================== */ 4 + 5 + .comment-list { 6 + list-style: none; 7 + margin: 0; 8 + padding: 0; 9 + } 10 + 11 + /* Nested lists: indent and create interactive visual tree guide rails */ 12 + .comment-list .comment-list { 13 + border-left: 2px solid var(--color-border-soft); 14 + margin: 0.5rem 0 0 0.5rem; 15 + padding-left: clamp(0.75rem, 3.5vw, 1.75rem); 16 + transition: border-left-color var(--transition-fast); 17 + } 18 + 19 + /* Interactive highlight path: hovering a child list colors its visual guide rail */ 20 + .thread-highlight-enabled .comment-list .comment-list:hover { 21 + border-left-color: var(--color-border-active); 22 + } 23 + 24 + .comment { 25 + padding: var(--comment-padding) 0; 26 + outline: none; 27 + scroll-margin-top: 4rem; 28 + transition: background-color var(--transition-fast); 29 + } 30 + 31 + .comment-content { 32 + border-radius: 6px; 33 + transition: background-color var(--transition-fast); 34 + } 35 + 36 + .comment.active > .comment-content { 37 + animation: comment-highlight-pulse 1.6s cubic-bezier(0.25, 1, 0.5, 1) forwards; 38 + } 39 + 40 + @keyframes comment-highlight-pulse { 41 + 0%, 35% { 42 + background-color: var(--color-active-bg); 43 + } 44 + 100% { 45 + background-color: transparent; 46 + } 47 + } 48 + 49 + .comment-byline { 50 + display: flex; 51 + flex-wrap: wrap; 52 + align-items: center; 53 + gap: 0.35rem 0.75rem; 54 + font-size: 0.75rem; 55 + color: var(--color-muted); 56 + margin-bottom: 0.5rem; 57 + } 58 + 59 + .comment-byline strong { 60 + color: var(--color-text-strong); 61 + font-weight: 700; 62 + } 63 + 64 + .comment-author.op { 65 + color: var(--color-accent) !important; 66 + display: inline-flex; 67 + align-items: center; 68 + } 69 + 70 + .op-badge { 71 + display: inline-block; 72 + margin-left: 0.35rem; 73 + font-size: 0.6rem; 74 + font-weight: 800; 75 + text-transform: uppercase; 76 + padding: 1px 4px; 77 + border-radius: 3px; 78 + background-color: var(--color-accent); 79 + color: white; 80 + line-height: 1; 81 + } 82 + 83 + .comment-byline time { 84 + color: var(--color-muted); 85 + } 86 + 87 + 88 + 89 + .comment-byline button { 90 + font-size: 0.7rem; 91 + font-weight: 600; 92 + color: var(--color-muted); 93 + border: none; 94 + background-color: transparent; 95 + padding: 0; 96 + transition: color var(--transition-fast); 97 + } 98 + 99 + .comment-byline button:hover { 100 + color: var(--color-accent); 101 + } 102 + 103 + .comment-byline button.comment-toggle { 104 + display: inline-flex; 105 + align-items: center; 106 + justify-content: center; 107 + width: auto; 108 + height: auto; 109 + padding: 0; 110 + font-size: 0.95em; 111 + font-weight: 700; 112 + line-height: 1; 113 + color: var(--color-muted); 114 + border: none; 115 + background-color: transparent; 116 + margin-right: 0.2rem; 117 + } 118 + 119 + .comment-byline button.comment-toggle:hover { 120 + color: var(--color-accent); 121 + } 122 + 123 + 124 + .comment-body { 125 + font-size: 1.05rem; 126 + line-height: 1.6; 127 + font-weight: 500; 128 + color: var(--color-text); 129 + overflow-wrap: break-word; 130 + } 131 + 132 + .comment-body p { 133 + margin: 0.5rem 0; 134 + } 135 + 136 + .comment-body a { 137 + color: var(--color-accent); 138 + text-decoration: underline; 139 + text-decoration-thickness: 1px; 140 + } 141 + 142 + .comment-body a:hover { 143 + color: var(--color-text-strong); 144 + } 145 + 146 + .comment-body blockquote { 147 + margin: 0.75rem 0; 148 + padding-left: 1rem; 149 + border-left: 3px solid var(--color-border-active); 150 + color: var(--color-muted); 151 + font-style: italic; 152 + } 153 + 154 + .comment-body pre { 155 + font-family: var(--font-mono); 156 + font-size: 0.85rem; 157 + overflow-x: auto; 158 + padding: 0.85rem; 159 + background-color: var(--bg-app); 160 + border: 1px solid var(--color-border); 161 + border-radius: 6px; 162 + margin: 0.75rem 0; 163 + } 164 + 165 + .comment-body code { 166 + font-family: var(--font-mono); 167 + font-size: 0.85rem; 168 + padding: 2px 4px; 169 + background-color: var(--color-border-soft); 170 + border-radius: 4px; 171 + } 172 + 173 + .comment-body pre code { 174 + padding: 0; 175 + background-color: transparent; 176 + border-radius: 0; 177 + }
+330
src/styles/07-settings.css
··· 1 + /* ========================================================================== 2 + 9. Settings Page & Custom Dashboard Controls 3 + ========================================================================== */ 4 + 5 + .settings-page { 6 + padding-top: 1.5rem; 7 + } 8 + 9 + .settings-panel { 10 + max-width: 800px; 11 + margin: 0 auto; 12 + } 13 + 14 + .settings-header { 15 + margin-bottom: 2.5rem; 16 + border-bottom: 1px solid var(--color-border); 17 + padding-bottom: 1.5rem; 18 + } 19 + 20 + .settings-panel h1 { 21 + font-family: var(--font-serif); 22 + font-size: clamp(2rem, 5vw, 2.75rem); 23 + font-weight: 700; 24 + color: var(--color-text-strong); 25 + margin: 0.25rem 0 0.75rem 0; 26 + letter-spacing: -0.02em; 27 + } 28 + 29 + .settings-subtitle { 30 + font-size: 0.95rem; 31 + color: var(--color-muted); 32 + margin: 0; 33 + line-height: 1.4; 34 + } 35 + 36 + .settings-section { 37 + background-color: var(--bg-card); 38 + border: 1px solid var(--color-border); 39 + border-radius: 8px; 40 + padding: 1.75rem; 41 + margin-bottom: 1.5rem; 42 + box-shadow: var(--shadow-sm); 43 + } 44 + 45 + .section-title { 46 + font-size: 1.15rem; 47 + font-weight: 700; 48 + margin: 0 0 1rem 0; 49 + color: var(--color-text-strong); 50 + border-bottom: 1px solid var(--color-border-soft); 51 + padding-bottom: 0.5rem; 52 + } 53 + 54 + .section-description { 55 + font-size: 0.85rem; 56 + color: var(--color-muted); 57 + margin: -0.5rem 0 1.25rem 0; 58 + } 59 + 60 + .settings-grid { 61 + display: grid; 62 + grid-template-columns: repeat(2, minmax(0, 1fr)); 63 + gap: 1.25rem 1.75rem; 64 + } 65 + 66 + .settings-control { 67 + display: flex; 68 + flex-direction: column; 69 + gap: 0.5rem; 70 + } 71 + 72 + .control-header { 73 + display: flex; 74 + justify-content: space-between; 75 + align-items: center; 76 + } 77 + 78 + .control-label { 79 + font-size: 0.85rem; 80 + font-weight: 600; 81 + color: var(--color-text-strong); 82 + } 83 + 84 + .control-output { 85 + font-size: 0.8rem; 86 + font-weight: 700; 87 + color: var(--color-accent); 88 + background-color: var(--color-active-bg); 89 + padding: 1px 6px; 90 + border-radius: 4px; 91 + } 92 + 93 + /* Custom Select Dropdowns */ 94 + .settings-control select { 95 + appearance: none; 96 + width: 100%; 97 + background-color: var(--bg-app); 98 + border: 1px solid var(--color-border); 99 + border-radius: 6px; 100 + padding: 0.65rem 2.5rem 0.65rem 0.75rem; 101 + color: var(--color-text); 102 + font-weight: 500; 103 + font-size: 0.9rem; 104 + cursor: pointer; 105 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='currentColor' stroke-width='1.5'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M6 8l4 4 4-4'/%3E%3C/svg%3E"); 106 + background-repeat: no-repeat; 107 + background-position: right 0.75rem center; 108 + background-size: 1.25rem; 109 + transition: all var(--transition-fast); 110 + } 111 + 112 + .settings-control input[type="text"] { 113 + width: 100%; 114 + background-color: var(--bg-app); 115 + border: 1px solid var(--color-border); 116 + border-radius: 6px; 117 + padding: 0.65rem 0.75rem; 118 + color: var(--color-text); 119 + font-size: 0.9rem; 120 + } 121 + 122 + .settings-control input[type="text"]:focus { 123 + outline: none; 124 + border-color: var(--color-accent); 125 + box-shadow: 0 0 0 3px var(--color-active-bg); 126 + } 127 + 128 + .control-help { 129 + color: var(--color-muted); 130 + font-size: 0.75rem; 131 + line-height: 1.35; 132 + } 133 + 134 + .settings-control select:hover { 135 + border-color: var(--color-muted); 136 + } 137 + 138 + .settings-control select:focus { 139 + outline: none; 140 + border-color: var(--color-accent); 141 + box-shadow: 0 0 0 3px var(--color-active-bg); 142 + } 143 + 144 + /* Premium Range Sliders */ 145 + input[type="range"] { 146 + appearance: none; 147 + -webkit-appearance: none; 148 + width: 100%; 149 + height: 20px; 150 + background: transparent; 151 + cursor: pointer; 152 + display: block; 153 + } 154 + 155 + input[type="range"]:focus { 156 + outline: none; 157 + } 158 + 159 + /* Chrome, Safari, Opera, Edge */ 160 + input[type="range"]::-webkit-slider-runnable-track { 161 + width: 100%; 162 + height: 5px; 163 + background: var(--color-border); 164 + border-radius: 3px; 165 + transition: background var(--transition-fast); 166 + } 167 + 168 + input[type="range"]:hover::-webkit-slider-runnable-track { 169 + background: var(--color-muted); 170 + } 171 + 172 + input[type="range"]::-webkit-slider-thumb { 173 + appearance: none; 174 + -webkit-appearance: none; 175 + height: 16px; 176 + width: 16px; 177 + border-radius: 50%; 178 + background: var(--bg-card); 179 + border: 2.5px solid var(--color-accent); 180 + margin-top: -5.5px; 181 + box-shadow: var(--shadow-sm); 182 + transition: transform var(--transition-fast), background-color var(--transition-fast); 183 + } 184 + 185 + input[type="range"]::-webkit-slider-thumb:hover { 186 + transform: scale(1.15); 187 + background-color: var(--color-accent); 188 + } 189 + 190 + /* Firefox */ 191 + input[type="range"]::-moz-range-track { 192 + width: 100%; 193 + height: 5px; 194 + background: var(--color-border); 195 + border-radius: 3px; 196 + transition: background var(--transition-fast); 197 + } 198 + 199 + input[type="range"]:hover::-moz-range-track { 200 + background: var(--color-muted); 201 + } 202 + 203 + input[type="range"]::-moz-range-thumb { 204 + height: 16px; 205 + width: 16px; 206 + border-radius: 50%; 207 + background: var(--bg-card); 208 + border: 2.5px solid var(--color-accent); 209 + box-shadow: var(--shadow-sm); 210 + transition: transform var(--transition-fast), background-color var(--transition-fast); 211 + cursor: pointer; 212 + } 213 + 214 + input[type="range"]::-moz-range-thumb:hover { 215 + transform: scale(1.15); 216 + background-color: var(--color-accent); 217 + } 218 + 219 + 220 + /* Custom Checkbox Slide-Toggles */ 221 + .sources-fieldset { 222 + grid-column: span 2; 223 + border: 1px solid var(--color-border-soft); 224 + border-radius: 6px; 225 + padding: 1.25rem; 226 + margin: 0.5rem 0 0 0; 227 + } 228 + 229 + .fieldset-legend { 230 + font-size: 0.8rem; 231 + font-weight: 700; 232 + text-transform: uppercase; 233 + letter-spacing: 0.05em; 234 + color: var(--color-muted); 235 + padding: 0 0.5rem; 236 + } 237 + 238 + .source-control { 239 + display: flex; 240 + align-items: center; 241 + justify-content: space-between; 242 + padding: 0.65rem 0; 243 + border-bottom: 1px solid var(--color-border-soft); 244 + gap: 1.5rem; 245 + } 246 + 247 + .source-control:last-child { 248 + border-bottom: none; 249 + padding-bottom: 0; 250 + } 251 + 252 + .checkbox-container { 253 + display: flex; 254 + align-items: center; 255 + gap: 0.75rem; 256 + cursor: pointer; 257 + user-select: none; 258 + } 259 + 260 + .checkbox-container input[type="checkbox"] { 261 + appearance: none; 262 + width: 36px; 263 + height: 20px; 264 + background-color: var(--color-border); 265 + border-radius: 10px; 266 + position: relative; 267 + outline: none; 268 + cursor: pointer; 269 + transition: background-color var(--transition-normal); 270 + } 271 + 272 + .checkbox-container input[type="checkbox"]:checked { 273 + background-color: var(--color-accent); 274 + } 275 + 276 + .checkbox-container input[type="checkbox"]::after { 277 + content: ''; 278 + position: absolute; 279 + top: 2px; 280 + left: 2px; 281 + width: 16px; 282 + height: 16px; 283 + border-radius: 50%; 284 + background-color: var(--bg-card); 285 + transition: transform var(--transition-normal); 286 + box-shadow: var(--shadow-sm); 287 + } 288 + 289 + .checkbox-container input[type="checkbox"]:checked::after { 290 + transform: translateX(16px); 291 + } 292 + 293 + .checkbox-label { 294 + font-size: 0.9rem; 295 + font-weight: 600; 296 + color: var(--color-text-strong); 297 + } 298 + 299 + .slider-container { 300 + display: flex; 301 + align-items: center; 302 + gap: 1rem; 303 + flex: 1; 304 + max-width: 320px; 305 + } 306 + 307 + /* Actions Button Group */ 308 + .settings-actions { 309 + display: flex; 310 + justify-content: flex-end; 311 + margin-top: 2rem; 312 + } 313 + 314 + .reset-button { 315 + font-size: 0.85rem; 316 + font-weight: 700; 317 + text-transform: uppercase; 318 + letter-spacing: 0.05em; 319 + padding: 0.75rem 1.5rem; 320 + border-radius: 6px; 321 + color: var(--color-accent); 322 + border: 1px solid var(--color-accent); 323 + background-color: transparent; 324 + transition: all var(--transition-fast); 325 + } 326 + 327 + .reset-button:hover { 328 + background-color: var(--color-active-bg); 329 + box-shadow: 0 0 0 3px var(--color-active-bg); 330 + }
+55
src/styles/08-utilities.css
··· 1 + /* ========================================================================== 2 + 10. Common Utilities & Empty States 3 + ========================================================================== */ 4 + 5 + .empty-state { 6 + text-align: center; 7 + padding: 4rem 2rem; 8 + background-color: var(--bg-card); 9 + border: 1px solid var(--color-border); 10 + border-radius: 8px; 11 + margin-top: 2rem; 12 + box-shadow: var(--shadow-sm); 13 + } 14 + 15 + .empty-state h1 { 16 + font-family: var(--font-serif); 17 + font-size: 2rem; 18 + color: var(--color-text-strong); 19 + margin-bottom: 1rem; 20 + } 21 + 22 + .empty-state p { 23 + color: var(--color-muted); 24 + margin-bottom: 2rem; 25 + } 26 + 27 + .loader { 28 + text-align: center; 29 + padding: 3rem; 30 + color: var(--color-muted); 31 + font-weight: 500; 32 + font-size: 1.1rem; 33 + } 34 + 35 + .error-card { 36 + background-color: hsl(0, 100%, 98%); 37 + border: 1px solid hsl(0, 100%, 90%); 38 + color: hsl(0, 100%, 35%); 39 + padding: 1rem; 40 + border-radius: 6px; 41 + margin: 1rem 0; 42 + font-size: 0.9rem; 43 + } 44 + 45 + .theme-dark .error-card { 46 + background-color: hsl(0, 50%, 15%); 47 + border: 1px solid hsl(0, 50%, 25%); 48 + color: hsl(0, 100%, 75%); 49 + } 50 + 51 + .theme-sepia .error-card { 52 + background-color: hsl(0, 50%, 93%); 53 + border: 1px solid hsl(0, 50%, 80%); 54 + color: hsl(0, 100%, 25%); 55 + }
+105
src/styles/09-responsive.css
··· 1 + /* ========================================================================== 2 + 11. Responsive Layout Adjustments 3 + ========================================================================== */ 4 + 5 + @media (max-width: 960px) { 6 + .app { 7 + padding: 0 clamp(1rem, 2vw, 2rem) 3rem; 8 + } 9 + 10 + .newspaper-grid { 11 + grid-template-columns: repeat(min(2, var(--column-count, 3)), minmax(0, 1fr)); 12 + } 13 + 14 + .story-card.featured { 15 + grid-column: span 1; 16 + min-height: auto; 17 + } 18 + 19 + .story-card.featured .story-title { 20 + font-family: var(--font-sans); 21 + font-size: 1.15rem; 22 + font-weight: 600; 23 + line-height: 1.35; 24 + } 25 + 26 + .discussion-page.reading .reading-layout { 27 + grid-template-columns: minmax(0, 1.2fr) minmax(320px, 1fr); 28 + } 29 + } 30 + 31 + @media (max-width: 640px) { 32 + .masthead { 33 + flex-direction: row; 34 + gap: 0.75rem; 35 + align-items: center; 36 + padding: 0.55rem 0; 37 + } 38 + 39 + .masthead > p { 40 + display: none; 41 + } 42 + 43 + .masthead nav { 44 + width: auto; 45 + margin-left: auto; 46 + justify-content: flex-end; 47 + gap: 0.8rem; 48 + } 49 + 50 + .masthead nav a { 51 + font-size: 0.72rem; 52 + } 53 + 54 + .edition-bar { 55 + flex-direction: column; 56 + gap: 0.75rem; 57 + align-items: flex-start; 58 + padding: 1rem; 59 + } 60 + 61 + .edition-bar button.text-button { 62 + align-self: stretch; 63 + text-align: center; 64 + } 65 + 66 + .newspaper-grid { 67 + display: block; 68 + border-radius: 6px; 69 + } 70 + 71 + .story-card { 72 + border-bottom: 1px solid var(--color-border); 73 + min-height: auto; 74 + } 75 + 76 + .story-card:last-child { 77 + border-bottom: none; 78 + } 79 + 80 + .settings-grid { 81 + grid-template-columns: 1fr; 82 + } 83 + 84 + .sources-fieldset { 85 + grid-column: span 1; 86 + } 87 + 88 + .source-control { 89 + flex-direction: column; 90 + align-items: stretch; 91 + gap: 0.75rem; 92 + } 93 + 94 + .slider-container { 95 + max-width: none; 96 + } 97 + 98 + .discussion-page.reading .reading-layout { 99 + grid-template-columns: minmax(0, 1fr); 100 + } 101 + 102 + .discussion-page.reading .article-pane { 103 + display: none; /* Hide article pane on mobile reading mode to focus on discussions */ 104 + } 105 + }
+69
src/types.ts
··· 1 + export type Source = 'hn' | 'lobsters'; 2 + export type Theme = 'light' | 'dark' | 'sepia'; 3 + export type Density = 'relaxed' | 'comfortable' | 'compact'; 4 + export type FontChoice = 'system' | 'serif' | 'mono'; 5 + 6 + export type LayoutChoice = 'newspaper' | 'list'; 7 + export type HnFeed = 'top' | 'best' | 'new'; 8 + export type LobstersFeed = 'hottest' | 'newest'; 9 + 10 + export interface ReaderSettings { 11 + theme: Theme; 12 + font: FontChoice; 13 + typeScale: number; 14 + density: Density; 15 + columns: number; 16 + layout: LayoutChoice; 17 + sourceEnabled: Record<Source, boolean>; 18 + sourceWeight: Record<Source, number>; 19 + halfLifeHours: number; 20 + commentWeight: number; 21 + hnFeed: HnFeed; 22 + lobstersFeed: LobstersFeed; 23 + blockedDomains: string[]; 24 + lobstersTags: string[]; 25 + highlightThreads: boolean; 26 + } 27 + 28 + export interface Story { 29 + id: string; 30 + source: Source; 31 + sourceId: string; 32 + sourceRank: number; 33 + title: string; 34 + url?: string; 35 + discussionUrl: string; 36 + author?: string; 37 + points: number; 38 + commentsCount: number; 39 + createdAt: string; 40 + tags?: string[]; 41 + } 42 + 43 + export interface FeedResult { 44 + stories: Story[]; 45 + errors: Partial<Record<Source, string>>; 46 + } 47 + 48 + export interface RankedStory extends Story { 49 + rankScore: number; 50 + rankParts: { 51 + sourceRank: number; 52 + engagement: number; 53 + recency: number; 54 + decay: number; 55 + }; 56 + } 57 + 58 + export interface CommentNode { 59 + id: string; 60 + author?: string; 61 + html: string; 62 + createdAt?: string; 63 + children: CommentNode[]; 64 + } 65 + 66 + export interface StoryComments { 67 + story: Story; 68 + comments: CommentNode[]; 69 + }
+9
src/vite-env.d.ts
··· 1 + /// <reference types="vite/client" /> 2 + 3 + interface ImportMetaEnv { 4 + readonly VITE_API_PROXY_URL?: string; 5 + } 6 + 7 + interface ImportMeta { 8 + readonly env: ImportMetaEnv; 9 + }
+22
tsconfig.app.json
··· 1 + { 2 + "compilerOptions": { 3 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 + "target": "ES2022", 5 + "useDefineForClassFields": true, 6 + "lib": ["ES2022", "DOM", "DOM.Iterable"], 7 + "allowJs": false, 8 + "skipLibCheck": true, 9 + "esModuleInterop": true, 10 + "allowSyntheticDefaultImports": true, 11 + "strict": true, 12 + "forceConsistentCasingInFileNames": true, 13 + "module": "ESNext", 14 + "moduleResolution": "Bundler", 15 + "resolveJsonModule": true, 16 + "isolatedModules": true, 17 + "noEmit": true, 18 + "jsx": "react-jsx", 19 + "types": ["vite/client", "vitest/globals", "@cloudflare/workers-types"] 20 + }, 21 + "include": ["src", "functions"] 22 + }
+4
tsconfig.json
··· 1 + { 2 + "files": [], 3 + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] 4 + }
+15
tsconfig.node.json
··· 1 + { 2 + "compilerOptions": { 3 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 + "target": "ES2023", 5 + "lib": ["ES2023"], 6 + "module": "ESNext", 7 + "skipLibCheck": true, 8 + "moduleResolution": "Bundler", 9 + "allowSyntheticDefaultImports": true, 10 + "strict": true, 11 + "noEmit": true, 12 + "types": ["node", "@cloudflare/workers-types"] 13 + }, 14 + "include": ["vite.config.ts"] 15 + }
+67
vite.config.ts
··· 1 + import { defineConfig, type Plugin } from 'vitest/config'; 2 + import react from '@vitejs/plugin-react'; 3 + import { fetchAggregatedFeed } from './src/server/feed/fetchFeed'; 4 + import { isAllowedLobstersProxyPath, isHnFeed, isLobstersFeed, sourceEnabledFromSearch } from './src/lib/sources'; 5 + 6 + function feedDevProxy(): Plugin { 7 + return { 8 + name: 'feed-dev-proxy', 9 + configureServer(server) { 10 + server.middlewares.use('/api/feed', async (req, res) => { 11 + try { 12 + const query = new URL(req.url ?? '/', 'http://localhost').searchParams; 13 + const hn = query.get('hn') ?? 'top'; 14 + const lobsters = query.get('lobsters') ?? 'hottest'; 15 + if (!isHnFeed(hn) || !isLobstersFeed(lobsters)) { 16 + res.writeHead(400, { 'Content-Type': 'application/json' }); 17 + res.end(JSON.stringify({ error: 'Unsupported feed mode' })); 18 + return; 19 + } 20 + const sourceEnabled = sourceEnabledFromSearch(query); 21 + const payload = await fetchAggregatedFeed({ hn, lobsters, sourceEnabled }); 22 + res.writeHead(payload.stories.length ? 200 : 502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); 23 + res.end(JSON.stringify(payload)); 24 + } catch (error) { 25 + const query = new URL(req.url ?? '/', 'http://localhost').searchParams; 26 + const sourceEnabled = sourceEnabledFromSearch(query); 27 + const message = error instanceof Error ? error.message : 'Development feed proxy failed'; 28 + res.writeHead(502, { 'Content-Type': 'application/json' }); 29 + res.end(JSON.stringify({ stories: [], errors: Object.fromEntries(Object.entries(sourceEnabled).filter(([, enabled]) => enabled).map(([source]) => [source, message])) })); 30 + } 31 + }); 32 + } 33 + }; 34 + } 35 + 36 + function lobstersDevProxy(): Plugin { 37 + return { 38 + name: 'lobsters-dev-proxy', 39 + configureServer(server) { 40 + server.middlewares.use('/api/lobsters', async (req, res) => { 41 + const path = (req.url ?? '/').replace(/^\/+/, '').split('?')[0]; 42 + if (!isAllowedLobstersProxyPath(path)) { 43 + res.writeHead(404, { 'Content-Type': 'application/json' }); 44 + res.end(JSON.stringify({ error: 'Unsupported Lobsters API path' })); 45 + return; 46 + } 47 + try { 48 + const upstream = await fetch(`https://lobste.rs/${path}`, { headers: { Accept: 'application/json' } }); 49 + const body = await upstream.arrayBuffer(); 50 + res.writeHead(upstream.status, { 51 + 'Content-Type': upstream.headers.get('content-type') ?? 'application/json', 52 + 'Cache-Control': 'no-store' 53 + }); 54 + res.end(Buffer.from(body)); 55 + } catch { 56 + res.writeHead(502, { 'Content-Type': 'application/json' }); 57 + res.end(JSON.stringify({ error: 'Could not reach Lobsters from the Vite development proxy' })); 58 + } 59 + }); 60 + } 61 + }; 62 + } 63 + 64 + export default defineConfig({ 65 + plugins: [react(), feedDevProxy(), lobstersDevProxy()], 66 + test: { environment: 'node', include: ['src/**/*.test.ts'] } 67 + });