diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..019b742 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,88 @@ +# Design + +firsthour is a logbook: roles are entries stamped by age, and the page exists to show what arrived since you last looked. + +All values live in one token block at the top of `public/style.css`. `test/style.test.ts` fails on any color or length outside it. + +## Type + +One family: [Recursive](https://github.com/arrowtype/recursive), self-hosted at `public/fonts/recursive.woff2` (latin, `wght` + `MONO` axes, 73 KB, OFL in `recursive-OFL.txt`). No font is loaded from another origin. + +| Token | Use | +|---|---| +| `--axes-text` (`MONO` 0) | company, role, body | +| `--axes-mono` (`MONO` 1) | age, source, status, counts, brand, group headers | +| `--size-body` 15 px | rows; the floor for text a reader acts on | +| `--size-meta` 13 px | status, source, footer | +| `--weight-strong` 640 | company, new age, group headers | + +`CASL` stays 0: the face reads linear, not casual. + +The CSP needs `font-src 'self'`. Without it, `default-src 'none'` blocks the font, and the only report is a console message. `scripts/probe.mjs` checks the header and the font URL on a live Worker. + +## Color + +Each color is written once as `light-dark(light, dark)`. The system theme picks a side; `data-theme` on `` pins it. + +| Token | Light | Dark | Role | Contrast on paper (light / dark) | +|---|---|---|---|---| +| `--paper` | `#e1edf0` | `#051a1d` | page | | +| `--ink` | `#14202b` | `#cde3e0` | text, rows not read yet | 13.8 / 13.4 | +| `--ink-2` | `#45545e` | `#8fabab` | read rows, meta | 6.6 / 7.3 | +| `--signal` | `#0a6b4c` | `#5fd3a5` | new rows, First hour, focus | 5.5 / 9.7 | +| `--alert` | `#a3261b` | `#ff8f80` | failed sources, stale banner | 6.2 / 8.1 | +| `--rule-strong` | = ink | = ink | masthead, group rules, input | | +| `--rule` | `#c3d5d9` | `#17393c` | row dividers (decorative) | | + +Every text token reads at 4.5:1 or better on paper in both themes, and the test pins it. Read rows use `--ink-2`, so dimming never drops below that floor. + +The palette is cool on purpose: sift, the sibling HN reader, is warm cream. No firsthour token sits within ΔE 5 (CIE76) of a sift token. + +## Rows + +``` +desktop age │ company │ role, then who may apply │ source +mobile age │ company │ source + role +``` + +Age leads, in mono with tabular figures: `12m`, `3h`, `2d`. Company and role come from the title's `Company | Role | …` convention (`parts` in `public/lib.js`); URLs are dropped from display because the link is built from a validated id, and HN job stories have no company part. The source is one word: `HN`, `HN jobs`, `board`. The filter matches company and role only. + +Rows wrap; they never truncate. In HN titles the restriction ("US only", "visa possible") usually comes last, which is what an ellipsis would hide. Titles are capped at 160 characters upstream. Touch screens get rows of at least 44 px; mouse rows stay dense. + +## New and read + +| Row | Means | +|---|---| +| signal tick, signal age | **new**: arrived since your last visit (it was not listed then) | +| full ink | not read yet: never on screen in an earlier visit | +| `--ink-2` | read | + +- A row is read once most of it has been on screen (IntersectionObserver, 60 %). Rows you never scrolled to keep full ink, though they lose the tick once they have been listed. +- The count line opens with the same tick, as the key: "12 new since Thu, Oct 8, 11:45 AM". It counts arrivals only. +- A first visit has no baseline: no ticks, nothing dimmed. +- A record from before reading was tracked loads as all read. +- Screen readers get the word "new", which is visually hidden. +- First hour is the only group header in signal color: it is the event the page is named for. + +## Theme + +`auto` / `light` / `dark` sits in the masthead. `public/theme.js` is a classic script placed before the stylesheet, so the first paint uses the stored choice. Storage failure means auto. + +`light-dark()` has been Baseline since May 2024. In an older browser the color tokens are invalid, and the page falls back to default black on the canvas: readable, without the palette. + +## Rules + +- Tokens only: a new value is a new token. +- Rows wrap; text is never truncated. +- Calm: a finite list with an end. No badges, no counters beyond the new count and the filter's "N of M", no animation. +- Untrusted text is rendered with `textContent` only. + +What this design avoids: +- cream paper; +- small uppercase tracked labels; +- a grey meta line above every title; +- an amber accent; +- the system font stack; +- rounded cards; +- palette values copied from a framework. diff --git a/public/app.js b/public/app.js index 6614ae9..d6b0e3d 100644 --- a/public/app.js +++ b/public/app.js @@ -1,5 +1,5 @@ // @ts-check -import { age, allDown, group, isActive, isNew, loadSeen, matches, nextSeen, parseFilter, saveSeen, statusText } from './lib.js'; +import { age, allDown, group, isActive, isNew, isRead, loadSeen, loadTheme, matches, nextSeen, parseFilter, parts, saveSeen, saveTheme, sourceLabel, statusText } from './lib.js'; /** @typedef {import('./lib.js').Posts} Posts */ @@ -26,6 +26,19 @@ const seen = loadSeen(storage()); let posts = null; /** @type {string[]} */ let shown = []; +/** Ids whose row was on screen during this visit. @type {Set} */ +const viewed = new Set(); +const onScreen = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const id = entry.target instanceof HTMLElement ? entry.target.dataset.id : undefined; + if (!entry.isIntersecting || !id) continue; + viewed.add(id); + onScreen.unobserve(entry.target); + } + }, + { threshold: 0.6 }, +); /** * @param {string} tag @@ -67,12 +80,10 @@ function render() { renderStatus(posts); const fresh = visible.filter((item) => isNew(item, seen)).length; - count.textContent = [ - isActive(filter) ? `${visible.length} of ${posts.items.length}` : '', - seen && fresh > 0 ? `${fresh} new since ${dateTimeFmt.format(new Date(seen.at))}` : '', - ] - .filter(Boolean) - .join(' · '); + count.replaceChildren( + ...(isActive(filter) ? [el('span', { text: `${visible.length} of ${posts.items.length}` })] : []), + ...(seen && fresh > 0 ? [el('span', { class: 'new-count', text: `${fresh} new since ${dateTimeFmt.format(new Date(seen.at))}` })] : []), + ); if (allDown(posts)) { list.replaceChildren(el('p', { class: 'empty', text: 'Every source is unavailable right now.' }), retryButton()); @@ -96,29 +107,32 @@ function render() { list.replaceChildren( ...(groups[0]?.label === 'First hour' ? [] : [nextThreadLine(posts)]), ...groups.map((g) => - el('section', {}, [ + el('section', { class: g.label === 'First hour' ? 'first' : '' }, [ el('h2', { text: g.label }), el( 'ol', {}, - g.items.map((item) => - el('li', {}, [ - el('a', { class: isNew(item, seen) ? 'row new' : 'row', href: item.url }, [ - el('span', { class: 'age', text: age(item.posted_at, now) }), - el('span', { class: 'body' }, [ - el('span', { class: 'where' }, [ - el('span', { text: item.where }), - isNew(item, seen) ? el('span', { class: 'mark', text: 'new' }) : null, - ]), - el('span', { class: 'title', text: item.title }), - ]), - ]), - ]), - ), + g.items.map((item) => el('li', {}, [row(item, now)])), ), ]), ), ); + onScreen.disconnect(); + for (const node of list.querySelectorAll('.row')) onScreen.observe(node); +} + +/** @param {import('./lib.js').Item} item @param {number} now */ +function row(item, now) { + const fresh = isNew(item, seen); + const { company, role } = parts(item); + const a = el('a', { class: ['row', fresh ? 'new' : '', isRead(item, seen) ? 'read' : ''].filter(Boolean).join(' '), href: item.url }, [ + el('span', { class: 'age' }, [el('span', { text: age(item.posted_at, now) }), fresh ? el('span', { class: 'mark', text: 'new' }) : null]), + el('span', { class: 'co', text: company }), + el('span', { class: 'role', text: role }), + el('span', { class: 'src', text: sourceLabel(item) }), + ]); + a.dataset.id = item.id; + return a; } /** @param {Posts} p */ @@ -155,8 +169,27 @@ function onFilter() { } function markSeen() { - if (posts) saveSeen(storage(), nextSeen(seen, posts.items.map((item) => item.id), shown), new Date()); + if (posts) saveSeen(storage(), nextSeen(seen, posts.items.map((item) => item.id), shown, viewed), new Date()); +} + +const themeButtons = /** @type {NodeListOf} */ (document.querySelectorAll('[data-theme-choice]')); + +/** @param {import('./lib.js').Theme} theme */ +function applyTheme(theme) { + if (theme === 'auto') delete document.documentElement.dataset.theme; + else document.documentElement.dataset.theme = theme; + for (const button of themeButtons) button.setAttribute('aria-pressed', String(button.dataset.themeChoice === theme)); +} + +for (const button of themeButtons) { + button.addEventListener('click', () => { + const theme = button.dataset.themeChoice; + if (theme !== 'auto' && theme !== 'light' && theme !== 'dark') return; + saveTheme(storage(), theme); + applyTheme(theme); + }); } +applyTheme(loadTheme(storage())); input.value = new URL(location.href).searchParams.get('q') ?? ''; input.addEventListener('input', onFilter); diff --git a/public/favicon.svg b/public/favicon.svg index 1852cdd..b430ff1 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1 @@ - + diff --git a/public/fonts/recursive-OFL.txt b/public/fonts/recursive-OFL.txt new file mode 100644 index 0000000..981d4af --- /dev/null +++ b/public/fonts/recursive-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Recursive Project Authors (github.com/arrowtype/recursive) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/recursive.woff2 b/public/fonts/recursive.woff2 new file mode 100644 index 0000000..b0ed1bb Binary files /dev/null and b/public/fonts/recursive.woff2 differ diff --git a/public/index.html b/public/index.html index e22d501..0705b7a 100644 --- a/public/index.html +++ b/public/index.html @@ -7,19 +7,30 @@ + + -
-

firsthour

-

Fresh remote roles from Hacker News and the companies hiring there.

-

+
+
+

firsthour

+

Remote roles from Hacker News and its hiring companies, logged as they appear.

+
+
+

- - -

+
+ + +

+

Loading…

diff --git a/public/lib.js b/public/lib.js index d3c8a6d..4de82ce 100644 --- a/public/lib.js +++ b/public/lib.js @@ -6,7 +6,8 @@ * @typedef {{ id: string, ok: boolean, fetched_at: string, error: string | null, count: number }} Status * @typedef {{ generated: string, stale: boolean, sources: Status[], next_thread: string, items: Item[] }} Posts * @typedef {{ include: string[], exclude: string[] }} Filter - * @typedef {{ ids: Set, at: string }} Seen + * @typedef {{ ids: Set, read: Set, at: string }} Seen ids listed last visit (arrival); read: rows that were on screen + * @typedef {'auto' | 'light' | 'dark'} Theme */ const MINUTE = 60_000; @@ -70,9 +71,14 @@ function hasWord(haystack, term) { return new RegExp(`(? hasWord(haystack, t))) return false; return f.include.length === 0 || f.include.some((t) => hasWord(haystack, t)); } @@ -89,37 +95,102 @@ export function loadSeen(storage) { if (!raw) return null; const parsed = JSON.parse(raw); if (!Array.isArray(parsed?.ids) || typeof parsed.at !== 'string') return null; - return { ids: new Set(parsed.ids.filter((/** @type {unknown} */ id) => typeof id === 'string')), at: parsed.at }; + const strings = (/** @type {unknown[]} */ list) => new Set(/** @type {string[]} */ (list.filter((id) => typeof id === 'string'))); + const ids = strings(parsed.ids); + // A record from before `read` existed: count everything it listed as read, so nothing jumps back to full ink. + return { ids, read: Array.isArray(parsed.read) ? strings(parsed.read) : new Set(ids), at: parsed.at }; } catch { return null; } } /** - * Seen after this visit: what was seen before and is still listed, plus what was shown now. - * Items hidden by the filter stay unseen, so they show as new when the filter changes. - * @param {Seen | null} seen @param {string[]} listed @param {string[]} shown + * The record after this visit, pruned to what is still listed. `ids` (arrival): what was listed before plus what the + * filter showed now, so filtered-out items still arrive as new later. `read`: what was read before plus what was on + * screen now, so rows never scrolled to keep full ink. + * @param {Seen | null} seen @param {string[]} listed @param {string[]} shown @param {Iterable} viewed */ -export function nextSeen(seen, listed, shown) { +export function nextSeen(seen, listed, shown, viewed) { const ids = new Set(shown); - for (const id of listed) if (seen?.ids.has(id)) ids.add(id); - return [...ids]; + const read = new Set(viewed); + for (const id of listed) { + if (seen?.ids.has(id)) ids.add(id); + if (seen?.read.has(id)) read.add(id); + } + return { ids: [...ids], read: [...read] }; } -/** @param {Pick | undefined} storage @param {string[]} ids @param {Date} at */ -export function saveSeen(storage, ids, at) { +/** @param {Pick | undefined} storage @param {{ ids: string[], read: string[] }} next @param {Date} at */ +export function saveSeen(storage, next, at) { try { - storage?.setItem(SEEN_KEY, JSON.stringify({ ids, at: at.toISOString() })); + storage?.setItem(SEEN_KEY, JSON.stringify({ ...next, at: at.toISOString() })); } catch { // Private mode or full storage: markers just won't carry over. } } -/** @param {Item} item @param {Seen | null} seen */ +const THEME_KEY = 'firsthour:theme'; + +/** @param {Pick | undefined} storage @returns {Theme} */ +export function loadTheme(storage) { + try { + const theme = storage?.getItem(THEME_KEY); + return theme === 'light' || theme === 'dark' ? theme : 'auto'; + } catch { + return 'auto'; + } +} + +/** Auto is the absence of a choice, so it clears the key. @param {Pick | undefined} storage @param {Theme} theme */ +export function saveTheme(storage, theme) { + try { + if (theme === 'auto') storage?.removeItem(THEME_KEY); + else storage?.setItem(THEME_KEY, theme); + } catch { + // Private mode or full storage: the choice lasts until reload. + } +} + +/** Arrived since the last visit. A first visit has no baseline, so nothing is new. @param {Item} item @param {Seen | null} seen */ export function isNew(item, seen) { return seen !== null && !seen.ids.has(item.id); } +/** On screen during an earlier visit. A first visit has read nothing. @param {Item} item @param {Seen | null} seen */ +export function isRead(item, seen) { + return seen !== null && seen.read.has(item.id); +} + +// A URL or bare `www.` host, with its wrapping parens, the closing one possibly lost to the 160-char title cap. +// A paren that closes surrounding text stays, so "(listed here https://x)" keeps its ")". +const URLISH = /\((?:https?:|www\.)[^\s)]*\)?|\b(?:https?:|www\.)[^\s)]*/gi; +// The cap can also cut a URL before its colon: "(http…", "ww…". +const CUT_URL = /\s*\(?\b(?:h(?:t(?:t(?:ps?)?)?)?|w{1,3})…$/i; +const EDGES = /^[\s,;:·-]+|[\s,;:·-]+$/g; + +/** + * Display split of an untrusted title on the `Company | Role | ...` convention, URLs dropped. + * HN job stories and titles without a pipe have no company part. Text only: callers render with textContent. + * @param {Item} item @returns {{ company: string, role: string }} + */ +export function parts(item) { + const segs = item.title + .split('|') + .map((s) => s.replace(URLISH, '').replace(CUT_URL, '').replace(/\s+/g, ' ').replace(/ \)/g, ')').replace(EDGES, '')); + const kept = segs.filter(Boolean); + if (kept.length === 0) return { company: '', role: item.title }; + if (item.source === 'yc' || segs.length < 2) return { company: '', role: kept.join(' · ') }; + // Positions hold: a first segment that was only a URL leaves the company empty rather than promoting the role. + return { company: segs[0] ?? '', role: segs.slice(1).filter(Boolean).join(' · ') }; +} + +const SOURCES = { hn: 'HN', yc: 'HN jobs', boards: 'board' }; + +/** @param {Item} item */ +export function sourceLabel(item) { + return SOURCES[item.source] ?? item.source; +} + const NAMES = { hn: 'HN', yc: 'HN jobs', boards: 'Boards' }; /** @param {Status} s @param {(iso: string) => string} time */ diff --git a/public/style.css b/public/style.css index 18e931e..0154d84 100644 --- a/public/style.css +++ b/public/style.css @@ -1,36 +1,60 @@ +/* Logbook. Every color, size and space is a token in this block; test/style.test.ts fails on a literal elsewhere. + Colors use light-dark(): the system theme picks the side, data-theme (theme.js) pins it. */ :root { - --bg: #fbfaf7; - --fg: #1d1f21; - --muted: #5f6368; - --line: #e4e1da; - --accent: #b5651d; - --warn: #9a3412; - --banner: #fdf1e3; - --focus: #2563eb; -} - -@media (prefers-color-scheme: dark) { - :root:not([data-theme='light']) { - --bg: #15171a; - --fg: #e8e6e3; - --muted: #9aa0a6; - --line: #2a2d31; - --accent: #f2a541; - --warn: #fdba74; - --banner: #2a2118; - --focus: #93c5fd; - } + color-scheme: light dark; + + --paper: light-dark(#e1edf0, #051a1d); + --ink: light-dark(#14202b, #cde3e0); + --ink-2: light-dark(#45545e, #8fabab); + --rule: light-dark(#c3d5d9, #17393c); + --rule-strong: light-dark(#14202b, #cde3e0); + --signal: light-dark(#0a6b4c, #5fd3a5); + --alert: light-dark(#a3261b, #ff8f80); + --alert-wash: light-dark(#f3dedb, #2b1714); + + --face: 'Recursive', ui-sans-serif, system-ui, sans-serif; + --axes-text: 'MONO' 0, 'CASL' 0; + --axes-mono: 'MONO' 1, 'CASL' 0; + --size-meta: 0.8125rem; + --size-body: 0.9375rem; + --size-brand: 1.625rem; + --weight-strong: 640; + --weight-brand: 820; + --leading: 1.35; + --leading-tight: 1; + + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + + --measure: 72rem; + --gutter: 16px; + --col-age: 5ch; + --col-co: 17ch; + --col-src: 7ch; + --hair: 1px; + --tick: 3px; + --tap: 44px; + --full: 100%; + --clip-all: 50%; +} + +:root[data-theme='light'] { + color-scheme: light; } :root[data-theme='dark'] { - --bg: #15171a; - --fg: #e8e6e3; - --muted: #9aa0a6; - --line: #2a2d31; - --accent: #f2a541; - --warn: #fdba74; - --banner: #2a2118; - --focus: #93c5fd; + color-scheme: dark; +} + +@font-face { + font-family: 'Recursive'; + src: url('/fonts/recursive.woff2') format('woff2'); + font-weight: 300 1000; + font-display: swap; } * { @@ -39,85 +63,174 @@ body { margin: 0 auto; - max-width: 42rem; - padding: 1.5rem 16px 3rem; - background: var(--bg); - color: var(--fg); - font: 16px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + max-width: var(--measure); + padding: var(--space-6) var(--gutter) var(--space-8); + background: var(--paper); + color: var(--ink); + font-family: var(--face); + font-size: var(--size-body); + font-variation-settings: var(--axes-text); + line-height: var(--leading); +} + +a { + color: inherit; +} + +:focus-visible { + outline: var(--tick) solid var(--signal); + outline-offset: var(--hair); +} + +.masthead { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: var(--space-4); + padding-bottom: var(--space-2); + border-bottom: var(--tick) solid var(--rule-strong); } h1 { margin: 0; - font-size: 1.5rem; - letter-spacing: -0.01em; + font-size: var(--size-brand); + font-weight: var(--weight-brand); + font-variation-settings: var(--axes-mono); + line-height: var(--leading-tight); } -h2 { - margin: 1.75rem 0 0.25rem; - font-size: 0.8rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--muted); +.tagline { + margin: var(--space-1) 0 0; + font-size: var(--size-meta); + color: var(--ink-2); } -a { - color: inherit; +.theme { + display: flex; + gap: var(--space-1); } -:focus-visible { - outline: 2px solid var(--focus); - outline-offset: 2px; +.theme button { + min-height: var(--tap); + padding: 0 var(--space-2); + border: 0; + background: none; + color: var(--ink-2); + font: inherit; + font-size: var(--size-meta); + font-variation-settings: var(--axes-mono); + cursor: pointer; } -.tagline, -.muted, -.next, -.count, -footer { - color: var(--muted); +.theme button[aria-pressed='true'] { + color: var(--ink); + text-decoration: underline; + text-decoration-thickness: var(--hair); + text-underline-offset: var(--space-1); } -.tagline { - margin: 0.25rem 0 0; +.status, +.count, +.next, +.muted, +footer { + font-size: var(--size-meta); + font-variation-settings: var(--axes-mono); + color: var(--ink-2); } .status { display: flex; flex-wrap: wrap; - gap: 0.25rem 1rem; - margin: 0.75rem 0 0; - font-size: 0.85rem; - color: var(--muted); + gap: 0 var(--space-4); + margin: var(--space-2) 0 0; } .warn { - color: var(--warn); + color: var(--alert); +} + +.controls { + display: flex; + align-items: center; + gap: var(--space-4); + margin-top: var(--space-4); } input[type='search'] { - width: 100%; - min-height: 44px; - margin-top: 1.25rem; - padding: 0.5rem 0.75rem; - border: 1px solid var(--line); - border-radius: 6px; + flex: 1; + min-height: var(--tap); + padding: 0 var(--space-2); + border: 0; + border-bottom: var(--hair) solid var(--rule-strong); + border-radius: 0; background: transparent; color: inherit; font: inherit; + font-variation-settings: var(--axes-mono); +} + +input[type='search']::placeholder { + color: var(--ink-2); } .count { - min-height: 1.2em; - margin: 0.5rem 0 0; - font-size: 0.85rem; + margin: 0; + white-space: nowrap; +} + +.count:empty { + display: none; +} + +.count > span:not(:last-child)::after { + content: ' · '; +} + +/* Same tick as .new rows: change both together. */ +.new-count::before { + content: ''; + display: inline-block; + width: var(--tick); + height: var(--size-body); + margin-right: var(--space-2); + background: var(--signal); + vertical-align: text-bottom; } .banner { - margin: 1rem 0 0; - padding: 0.75rem 1rem; - border-radius: 6px; - background: var(--banner); + margin: var(--space-4) 0 0; + padding: var(--space-3) var(--space-4); + background: var(--alert-wash); + color: var(--alert); +} + +.next { + margin: var(--space-4) 0 0; +} + +h2 { + display: flex; + align-items: center; + gap: var(--space-3); + margin: var(--space-6) 0 var(--space-1); + font-size: var(--size-body); + font-weight: var(--weight-strong); + font-variation-settings: var(--axes-mono); +} + +h2::after { + content: ''; + flex: 1; + border-top: var(--hair) solid var(--rule-strong); +} + +.first h2 { + color: var(--signal); +} + +.first h2::after { + border-color: var(--signal); } ol { @@ -127,68 +240,70 @@ ol { } li + li { - border-top: 1px solid var(--line); + border-top: var(--hair) solid var(--rule); } +/* Never clip company or role (no nowrap, no ellipsis): who may apply often comes last. */ .row { display: grid; - grid-template-columns: 3.25rem 1fr; - gap: 0.75rem; - min-height: 44px; - padding: 0.65rem 0; + grid-template-columns: var(--col-age) var(--col-co) 1fr var(--col-src); + column-gap: var(--space-3); + align-items: baseline; + padding: var(--space-2); text-decoration: none; } -.row:hover .title { - text-decoration: underline; +.read { + color: var(--ink-2); } -.age { - font-variant-numeric: tabular-nums; - color: var(--muted); +.row:hover { + color: var(--ink); } -.new .age { - color: var(--accent); - font-weight: 600; +.row:hover .role { + text-decoration: underline; + text-decoration-thickness: var(--hair); } -.body { - display: flex; - flex-direction: column; +.row > * { min-width: 0; + overflow-wrap: anywhere; } -.where { - display: flex; - gap: 0.5rem; - font-size: 0.85rem; - color: var(--muted); +.age { + font-variation-settings: var(--axes-mono); + font-variant-numeric: tabular-nums; + text-align: right; } -.mark { - color: var(--accent); - font-weight: 600; +.co { + font-weight: var(--weight-strong); } -.title { - overflow-wrap: anywhere; +.src { + font-size: var(--size-meta); + font-variation-settings: var(--axes-mono); + text-align: right; } -.empty { - margin: 2rem 0 0.75rem; +.new { + box-shadow: inset var(--tick) 0 0 var(--signal); } -.next { - margin: 1.25rem 0 0; - font-size: 0.9rem; +.new .age { + color: var(--signal); + font-weight: var(--weight-strong); +} + +.empty { + margin: var(--space-8) 0 var(--space-3); } button.retry { - min-height: 44px; - padding: 0 1rem; - border: 1px solid var(--line); - border-radius: 6px; + min-height: var(--tap); + padding: 0 var(--space-4); + border: var(--hair) solid var(--rule-strong); background: transparent; color: inherit; font: inherit; @@ -196,23 +311,74 @@ button.retry { } footer { - margin-top: 3rem; - font-size: 0.85rem; + margin-top: var(--space-8); + padding-top: var(--space-2); + border-top: var(--hair) solid var(--rule); } footer p { - margin: 0.25rem 0; + margin: var(--space-1) 0; } +.mark, .visually-hidden { position: absolute; - width: 1px; - height: 1px; + width: var(--hair); + height: var(--hair); overflow: hidden; - clip-path: inset(50%); + clip-path: inset(var(--clip-all)); white-space: nowrap; } +@media (max-width: 40rem) { + .masthead { + flex-wrap: wrap; + } + + .tagline { + display: none; + } + + .controls { + flex-wrap: wrap; + row-gap: 0; + } + + .count { + flex-basis: var(--full); + white-space: normal; + } + + .row { + grid-template-columns: var(--col-age) 1fr auto; + grid-template-areas: 'age co src' '. role role'; + padding-block: var(--space-1); + } + + .age { + grid-area: age; + } + + .co { + grid-area: co; + } + + .src { + grid-area: src; + } + + .role { + grid-area: role; + } +} + +@media (pointer: coarse) { + .row { + min-height: var(--tap); + align-content: center; + } +} + @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; diff --git a/public/theme.js b/public/theme.js new file mode 100644 index 0000000..96dc3df --- /dev/null +++ b/public/theme.js @@ -0,0 +1,9 @@ +// @ts-check +// Classic script in , before the stylesheet, so the first paint is already in the chosen theme. +// Key and values match loadTheme in lib.js; test/lib.test.ts runs this file against both. +try { + const choice = localStorage.getItem('firsthour:theme'); + if (choice === 'light' || choice === 'dark') document.documentElement.dataset.theme = choice; +} catch { + // Storage blocked: the system theme applies. +} diff --git a/scripts/probe.mjs b/scripts/probe.mjs index f7e3e59..2519702 100644 --- a/scripts/probe.mjs +++ b/scripts/probe.mjs @@ -97,6 +97,14 @@ async function checkWorker(base) { for (const s of body.sources ?? []) console.log(` ${s.id.padEnd(7)} ok=${s.ok} count=${s.count} error=${s.error} fetched_at=${s.fetched_at}`); let failed = !response.ok || !body.sources?.every((s) => s.ok); + // default-src 'none' blocks the page's own font without a page error; only the console says so. + const page = await fetch(base, { headers: { 'user-agent': UA } }); + const fontSrc = page.headers.get('content-security-policy')?.includes("font-src 'self'") ?? false; + const font = await fetch(new URL('/fonts/recursive.woff2', base), { headers: { 'user-agent': UA } }); + const fontType = font.headers.get('content-type'); + console.log(`font csp font-src=${fontSrc} ${font.status} ${fontType}`); + failed ||= !fontSrc || font.status !== 200 || fontType !== 'font/woff2'; + // Replies in the hn response would count toward the 1000-hit cap. const threads = pickThreads(await getJson(THREADS_URL)).slice(0, 3); const pages = await Promise.all(threads.map((t) => getJson(commentsUrl(t.id)))); diff --git a/src/app.ts b/src/app.ts index d20a918..e9b4ec0 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,7 +5,7 @@ export const USER_AGENT = `firsthour/${pkg.version} (+https://github.com/1mb-dev export const SECURITY_HEADERS: Readonly> = { 'content-security-policy': - "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'", 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer', }; diff --git a/test/lib.test.ts b/test/lib.test.ts index 7ff5196..da48106 100644 --- a/test/lib.test.ts +++ b/test/lib.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { age, allDown, group, isNew, loadSeen, matches, nextSeen, parseFilter, saveSeen, statusText } from '../public/lib.js'; +import { readFileSync } from 'node:fs'; +import { age, allDown, group, isNew, isRead, loadSeen, loadTheme, matches, nextSeen, parseFilter, parts, saveSeen, saveTheme, sourceLabel, statusText } from '../public/lib.js'; const NOW = Date.parse('2026-10-07T16:00:00Z'); const ago = (ms: number) => new Date(NOW - ms).toISOString(); @@ -42,13 +43,22 @@ describe('filter', () => { expect(parseFilter(' Go, backend ,, -Onsite, - ')).toEqual({ include: ['go', 'backend'], exclude: ['onsite'] }); }); - it('matches whole words in title or where, any include, no exclude', () => { + it('matches whole words in company or role, any include, no exclude', () => { const f = parseFilter('go, rust, -onsite'); expect(matches(item('1', 0, 'Acme | Go Engineer | Remote'), f)).toBe(true); expect(matches(item('2', 0, 'Acme | Google Ads | Remote'), f)).toBe(false); expect(matches(item('3', 0, 'Acme | Engineer | Chicago'), f)).toBe(false); expect(matches(item('4', 0, 'Acme | Rust | Remote or onsite'), f)).toBe(false); - expect(matches(item('5', 0, 'Acme | Engineer', 'Rust Co careers'), f)).toBe(true); + }); + + it('never matches text a row does not show: URLs, where, or the source word', () => { + const board = { ...item('1', 0, 'Acme | Engineer | Remote', 'Acme careers'), source: 'boards' as const }; + expect(matches(board, parseFilter('board'))).toBe(false); + expect(matches(board, parseFilter('careers'))).toBe(false); + expect(matches(item('2', 0), parseFilter('-hiring'))).toBe(true); + expect(matches(item('3', 0, 'Acme | Engineer | Remote | https://jobs.lever.co/acme'), parseFilter('-lever'))).toBe(true); + const story = { ...item('4', 0, 'Acme (YC W22) is hiring a backend engineer', 'HN jobs'), source: 'yc' as const }; + expect(matches(story, parseFilter('-hn'))).toBe(true); }); it('accepts a plural', () => { @@ -72,10 +82,24 @@ describe('new since last visit', () => { return { getItem: (k: string) => data.get(k) ?? null, setItem: (k: string, v: string) => void data.set(k, v) }; } - it('marks nothing new on a first visit', () => { + it('marks nothing new and nothing read on a first visit', () => { const seen = loadSeen(memory()); expect(seen).toBeNull(); expect(isNew(item('a', 0), seen)).toBe(false); + expect(isRead(item('a', 0), seen)).toBe(false); + }); + + it('keeps arrival and reading apart: listed but never on screen is neither new nor read', () => { + const seen = loadSeen(memory(JSON.stringify({ ids: ['listed', 'read'], read: ['read'], at: ago(60 * MIN) }))); + expect([isNew(item('listed', 0), seen), isRead(item('listed', 0), seen)]).toEqual([false, false]); + expect([isNew(item('read', 0), seen), isRead(item('read', 0), seen)]).toEqual([false, true]); + expect([isNew(item('arrived', 0), seen), isRead(item('arrived', 0), seen)]).toEqual([true, false]); + }); + + it('reads a record from before `read` existed as all read', () => { + const seen = loadSeen(memory(JSON.stringify({ ids: ['a'], at: ago(60 * MIN) }))); + expect(isRead(item('a', 0), seen)).toBe(true); + expect(isRead(item('b', 0), seen)).toBe(false); }); it('marks unseen ids new on a return visit', () => { @@ -91,16 +115,19 @@ describe('new since last visit', () => { expect(loadSeen(undefined)).toBeNull(); }); - it('keeps filtered-out items unseen and prunes ids no longer listed', () => { - const seen = { ids: new Set(['old', 'gone']), at: ago(MIN) }; - expect(nextSeen(seen, ['old', 'new', 'hidden'], ['new']).sort()).toEqual(['new', 'old']); + it('keeps filtered-out items unseen, rows never on screen unread, and prunes ids no longer listed', () => { + const seen = { ids: new Set(['old', 'gone']), read: new Set(['old', 'gone']), at: ago(MIN) }; + const next = nextSeen(seen, ['old', 'new', 'below', 'hidden'], ['new', 'below'], ['new']); + expect(next.ids.sort()).toEqual(['below', 'new', 'old']); + expect(next.read.sort()).toEqual(['new', 'old']); }); it('round-trips through storage and survives a throwing setItem', () => { const store = memory(); - saveSeen(store, ['a'], new Date(NOW)); - expect(loadSeen(store)?.ids.has('a')).toBe(true); - expect(() => saveSeen({ setItem: () => { throw new Error('full'); } }, ['a'], new Date(NOW))).not.toThrow(); + saveSeen(store, { ids: ['a', 'b'], read: ['a'] }, new Date(NOW)); + const loaded = loadSeen(store); + expect([loaded?.ids.has('b'), loaded?.read.has('a'), loaded?.read.has('b')]).toEqual([true, true, false]); + expect(() => saveSeen({ setItem: () => { throw new Error('full'); } }, { ids: ['a'], read: [] }, new Date(NOW))).not.toThrow(); }); }); @@ -123,3 +150,103 @@ describe('status', () => { expect(allDown({ ...base, sources: [{ ...down, ok: true }] })).toBe(false); }); }); + +describe('parts', () => { + const hn = (title: string, source: 'hn' | 'yc' | 'boards' = 'hn') => ({ ...item('x', MIN, title), source }); + it.each([ + ['Acme | Backend Engineer | Remote', { company: 'Acme', role: 'Backend Engineer · Remote' }], + ['Acme|Backend Engineer|REMOTE (US)', { company: 'Acme', role: 'Backend Engineer · REMOTE (US)' }], + ['Acme | https://acme.example/jobs | REMOTE (US) | Full Time', { company: 'Acme', role: 'REMOTE (US) · Full Time' }], + ['Acme (https://acme.example) | Go Engineer', { company: 'Acme', role: 'Go Engineer' }], + ['Acme | www.acme.example | Remote', { company: 'Acme', role: 'Remote' }], + ['Acme | Staff Engineer | Remote | https:…', { company: 'Acme', role: 'Staff Engineer · Remote' }], + ['Acme | Staff Engineer | Remote (https://acme.example/careers/staff-eng…', { company: 'Acme', role: 'Staff Engineer · Remote' }], + ['Acme | Engineer | REMOTE (http…', { company: 'Acme', role: 'Engineer · REMOTE' }], + ['Acme | Engineer | REMOTE ww…', { company: 'Acme', role: 'Engineer · REMOTE' }], + ['Acme | Engineer | Hybrid…', { company: 'Acme', role: 'Engineer · Hybrid…' }], + ['www.acme.io | Senior Go Engineer | Remote', { company: '', role: 'Senior Go Engineer · Remote' }], + ['Acme | Node.js Engineer | Remote', { company: 'Acme', role: 'Node.js Engineer · Remote' }], + ['Acme | REMOTE in select countries (listed here https://wiki.example/p/a_9) | $181K', { company: 'Acme', role: 'REMOTE in select countries (listed here) · $181K' }], + ['Software Engineer — Remote — US Only', { company: '', role: 'Software Engineer — Remote — US Only' }], + ])('%s', (title, expected) => { + expect(parts(hn(title))).toEqual(expected); + }); + + it('gives HN job stories no company part', () => { + expect(parts(hn('Acme (YC W22) | is hiring a backend engineer', 'yc'))).toEqual({ company: '', role: 'Acme (YC W22) · is hiring a backend engineer' }); + }); + + it('keeps the raw title when nothing but URLs is left', () => { + expect(parts(hn('https://acme.example'))).toEqual({ company: '', role: 'https://acme.example' }); + }); +}); + +describe('sourceLabel', () => { + it.each([ + ['hn', 'HN'], + ['yc', 'HN jobs'], + ['boards', 'board'], + ] as const)('%s -> %s', (source, label) => { + expect(sourceLabel({ ...item('x', MIN), source })).toBe(label); + }); +}); + +describe('theme', () => { + const memory = () => { + const data = new Map(); + return { + getItem: (k: string) => data.get(k) ?? null, + setItem: (k: string, v: string) => void data.set(k, v), + removeItem: (k: string) => void data.delete(k), + }; + }; + const broken = { + getItem: () => { + throw new Error('denied'); + }, + setItem: () => { + throw new Error('denied'); + }, + removeItem: () => { + throw new Error('denied'); + }, + }; + + it('round-trips a choice; auto clears it', () => { + const storage = memory(); + expect(loadTheme(storage)).toBe('auto'); + saveTheme(storage, 'dark'); + expect(loadTheme(storage)).toBe('dark'); + saveTheme(storage, 'auto'); + expect(storage.getItem('firsthour:theme')).toBeNull(); + expect(loadTheme(storage)).toBe('auto'); + }); + + it('falls back to auto on blocked storage or an unknown value', () => { + expect(loadTheme(broken)).toBe('auto'); + expect(() => saveTheme(broken, 'light')).not.toThrow(); + expect(loadTheme(undefined)).toBe('auto'); + const storage = memory(); + storage.setItem('firsthour:theme', 'sepia'); + expect(loadTheme(storage)).toBe('auto'); + }); + + // theme.js is a classic script (it must run before first paint), so it cannot import lib.js. + it('theme.js applies what saveTheme stored, and nothing else', () => { + const source = readFileSync(new URL('../public/theme.js', import.meta.url), 'utf8'); + const run = (storage: unknown) => { + const documentElement = { dataset: {} as Record }; + new Function('localStorage', 'document', source)(storage, { documentElement }); + return documentElement.dataset.theme; + }; + for (const theme of ['light', 'dark'] as const) { + const storage = memory(); + saveTheme(storage, theme); + expect(run(storage)).toBe(theme); + } + const storage = memory(); + storage.setItem('firsthour:theme', 'sepia'); + expect(run(storage)).toBeUndefined(); + expect(run(broken)).toBeUndefined(); + }); +}); diff --git a/test/style.test.ts b/test/style.test.ts new file mode 100644 index 0000000..75e16ac --- /dev/null +++ b/test/style.test.ts @@ -0,0 +1,47 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const css = readFileSync(new URL('../public/style.css', import.meta.url), 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''); +const tokenBlock = css.match(/:root \{[^}]*\}/)?.[0] ?? ''; + +/** Each `--name: light-dark(#light, #dark)` token as [light, dark]. */ +const colors = Object.fromEntries([...tokenBlock.matchAll(/--([\w-]+):\s*light-dark\((#[0-9a-f]{6}),\s*(#[0-9a-f]{6})\)/gi)].map((m) => [m[1], [m[2], m[3]]])) as Record< + string, + [string, string] +>; + +// WCAG 2 relative luminance and contrast ratio. +const luminance = (hex: string) => { + const [r, g, b] = [1, 3, 5].map((i) => { + const c = parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }) as [number, number, number]; + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; +const contrast = (a: string, b: string) => { + const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]; + return (hi + 0.05) / (lo + 0.05); +}; + +describe('style tokens', () => { + it('keeps every color and length in the token block', () => { + // A @media condition cannot read var(), and @font-face only names the font file. + const rest = css + .replace(tokenBlock, '') + .replace(/@font-face \{[^}]*\}/, '') + .replace(/@media \([^)]*\)/g, '@media'); + expect(rest.match(/#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|light-dark)\(|(? { + // ink-2 is also the ink of seen rows, so dimming can never drop below this. + for (const side of [0, 1]) expect(contrast(colors[token]![side]!, colors.paper![side]!)).toBeGreaterThanOrEqual(4.5); + }); + + it('keeps alert text readable on its banner, and the input rule visible', () => { + for (const side of [0, 1]) { + expect(contrast(colors.alert![side]!, colors['alert-wash']![side]!)).toBeGreaterThanOrEqual(4.5); + expect(contrast(colors['rule-strong']![side]!, colors.paper![side]!)).toBeGreaterThanOrEqual(3); + } + }); +}); diff --git a/test/worker.test.ts b/test/worker.test.ts index fc081d0..186cb47 100644 --- a/test/worker.test.ts +++ b/test/worker.test.ts @@ -26,6 +26,8 @@ describe('worker', () => { const res = await worker.fetch(new Request('https://firsthour.1mb.dev/') as never, env, ctx); expect(res.headers.get('content-type')).toBe('text/html'); expect(res.headers.get('content-security-policy')).toContain("default-src 'none'"); + // Without font-src, default-src 'none' blocks the page's own font, and only the console says so. + expect(res.headers.get('content-security-policy')).toContain("font-src 'self'"); }); const body: PostsBody = { generated: '2026-10-07T16:00:00Z', stale: false, sources: [], next_thread: '2026-11-02T16:00:00Z', items: [] };