From 55b7a4d59a21733a40a07ba7baf5c593674d08d0 Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 07:53:18 +0530 Subject: [PATCH 01/16] feat(worker): allow same-origin fonts in the CSP default-src 'none' blocks the page's own font silently; only the console reports it. --- src/app.ts | 2 +- test/worker.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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/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: [] }; From 08f0f0d79069461025ebe781fb6188352d011907 Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 07:54:26 +0530 Subject: [PATCH 02/16] feat(page): split titles into company and role Rows show the company, the rest of the title without URLs, and a one-word source. --- public/app.js | 29 ++++++++++++++--------------- public/lib.js | 26 ++++++++++++++++++++++++++ public/style.css | 30 ++++++++++++++++-------------- test/lib.test.ts | 36 +++++++++++++++++++++++++++++++++++- 4 files changed, 91 insertions(+), 30 deletions(-) diff --git a/public/app.js b/public/app.js index 6614ae9..aa83f2d 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, loadSeen, matches, nextSeen, parseFilter, parts, saveSeen, sourceLabel, statusText } from './lib.js'; /** @typedef {import('./lib.js').Posts} Posts */ @@ -101,26 +101,25 @@ function render() { 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)])), ), ]), ), ); } +/** @param {import('./lib.js').Item} item @param {number} now */ +function row(item, now) { + const fresh = isNew(item, seen); + const { company, role } = parts(item); + return el('a', { class: fresh ? 'row new' : 'row', 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) }), + ]); +} + /** @param {Posts} p */ function nextThreadLine(p) { return el('p', { class: 'next', text: `Next Who is hiring thread expected ${dateTimeFmt.format(new Date(p.next_thread))}.` }); diff --git a/public/lib.js b/public/lib.js index d3c8a6d..e6be83c 100644 --- a/public/lib.js +++ b/public/lib.js @@ -120,6 +120,32 @@ export function isNew(item, seen) { return seen !== null && !seen.ids.has(item.id); } +// A URL, bare `www.` host, or one cut short by the 160-char title cap ("https:…"), with any wrapping parens. +const URLISH = /\(?\b(?:https?:\S*|www\.\S+?)\)?(?=\s|$)/gi; +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(/\s+/g, ' ').replace(EDGES, '')) + .filter(Boolean); + if (segs.length === 0) return { company: '', role: item.title }; + if (item.source === 'yc' || segs.length < 2) return { company: '', role: segs.join(' · ') }; + return { company: segs[0] ?? '', role: segs.slice(1).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..f2c33e2 100644 --- a/public/style.css +++ b/public/style.css @@ -132,18 +132,20 @@ li + li { .row { display: grid; - grid-template-columns: 3.25rem 1fr; - gap: 0.75rem; + grid-template-columns: 3.25rem 1fr auto; + grid-template-areas: 'age co src' 'age role role'; + column-gap: 0.75rem; min-height: 44px; padding: 0.65rem 0; text-decoration: none; } -.row:hover .title { +.row:hover .role { text-decoration: underline; } .age { + grid-area: age; font-variant-numeric: tabular-nums; color: var(--muted); } @@ -153,28 +155,28 @@ li + li { font-weight: 600; } -.body { - display: flex; - flex-direction: column; - min-width: 0; +.co { + grid-area: co; + font-weight: 600; } -.where { - display: flex; - gap: 0.5rem; +.role { + grid-area: role; + overflow-wrap: anywhere; +} + +.src { + grid-area: src; font-size: 0.85rem; color: var(--muted); } .mark { + margin-left: 0.5rem; color: var(--accent); font-weight: 600; } -.title { - overflow-wrap: anywhere; -} - .empty { margin: 2rem 0 0.75rem; } diff --git a/test/lib.test.ts b/test/lib.test.ts index 7ff5196..082d1a2 100644 --- a/test/lib.test.ts +++ b/test/lib.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { age, allDown, group, isNew, loadSeen, matches, nextSeen, parseFilter, saveSeen, statusText } from '../public/lib.js'; +import { age, allDown, group, isNew, loadSeen, matches, nextSeen, parseFilter, parts, saveSeen, sourceLabel, statusText } from '../public/lib.js'; const NOW = Date.parse('2026-10-07T16:00:00Z'); const ago = (ms: number) => new Date(NOW - ms).toISOString(); @@ -123,3 +123,37 @@ 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 | Node.js Engineer | Remote', { company: 'Acme', role: 'Node.js Engineer · Remote' }], + ['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); + }); +}); From a576ff5b6db200b9b022ad02debc4f5813040167 Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 07:56:16 +0530 Subject: [PATCH 03/16] feat(page): dim rows already seen A row counts as seen once most of it has been on screen, not when it was merely listed. --- public/app.js | 24 +++++++++++++++++++----- public/lib.js | 10 +++++----- public/style.css | 9 +++++++++ 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/public/app.js b/public/app.js index aa83f2d..4e33a0a 100644 --- a/public/app.js +++ b/public/app.js @@ -24,8 +24,19 @@ function storage() { const seen = loadSeen(storage()); /** @type {Posts | null} */ 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 @@ -63,7 +74,6 @@ function render() { const now = Date.now(); const filter = parseFilter(input.value); const visible = posts.items.filter((item) => matches(item, filter)); - shown = visible.map((item) => item.id); renderStatus(posts); const fresh = visible.filter((item) => isNew(item, seen)).length; @@ -106,18 +116,22 @@ function render() { ]), ), ); + 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); - return el('a', { class: fresh ? 'row new' : 'row', href: item.url }, [ + const a = el('a', { class: fresh ? 'row new' : 'row', 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 */ @@ -154,7 +168,7 @@ 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), viewed), new Date()); } input.value = new URL(location.href).searchParams.get('q') ?? ''; diff --git a/public/lib.js b/public/lib.js index e6be83c..4edc8ac 100644 --- a/public/lib.js +++ b/public/lib.js @@ -96,12 +96,12 @@ export function loadSeen(storage) { } /** - * 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 + * Seen after this visit: what was seen before and is still listed, plus what was on screen now. + * Rows never scrolled to, or hidden by the filter, stay unseen and keep full ink next visit. + * @param {Seen | null} seen @param {string[]} listed @param {Iterable} viewed */ -export function nextSeen(seen, listed, shown) { - const ids = new Set(shown); +export function nextSeen(seen, listed, viewed) { + const ids = new Set(viewed); for (const id of listed) if (seen?.ids.has(id)) ids.add(id); return [...ids]; } diff --git a/public/style.css b/public/style.css index f2c33e2..ac2fa29 100644 --- a/public/style.css +++ b/public/style.css @@ -140,6 +140,15 @@ li + li { text-decoration: none; } +.row { + color: var(--muted); +} + +.row:hover, +.new { + color: var(--fg); +} + .row:hover .role { text-decoration: underline; } From feffebd75b72b253e95c4f7400cc253110c95a6e Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 07:57:17 +0530 Subject: [PATCH 04/16] feat(page): auto, light and dark theme choice theme.js runs before the stylesheet so the first paint uses the stored choice. --- public/app.js | 21 +++++++++++++++- public/index.html | 6 +++++ public/lib.js | 23 +++++++++++++++++ public/style.css | 27 ++++++++++++++++++++ public/theme.js | 9 +++++++ test/lib.test.ts | 63 ++++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 public/theme.js diff --git a/public/app.js b/public/app.js index 4e33a0a..86a6f94 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, parts, saveSeen, sourceLabel, statusText } from './lib.js'; +import { age, allDown, group, isActive, isNew, loadSeen, loadTheme, matches, nextSeen, parseFilter, parts, saveSeen, saveTheme, sourceLabel, statusText } from './lib.js'; /** @typedef {import('./lib.js').Posts} Posts */ @@ -171,6 +171,25 @@ function markSeen() { if (posts) saveSeen(storage(), nextSeen(seen, posts.items.map((item) => item.id), 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); document.addEventListener('visibilitychange', () => { diff --git a/public/index.html b/public/index.html index e22d501..c94adcd 100644 --- a/public/index.html +++ b/public/index.html @@ -7,6 +7,7 @@ + @@ -15,6 +16,11 @@

firsthour

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

+
diff --git a/public/lib.js b/public/lib.js index 4edc8ac..3800e65 100644 --- a/public/lib.js +++ b/public/lib.js @@ -7,6 +7,7 @@ * @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 {'auto' | 'light' | 'dark'} Theme */ const MINUTE = 60_000; @@ -115,6 +116,28 @@ export function saveSeen(storage, ids, at) { } } +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. + } +} + /** @param {Item} item @param {Seen | null} seen */ export function isNew(item, seen) { return seen !== null && !seen.ids.has(item.id); diff --git a/public/style.css b/public/style.css index ac2fa29..714c3a6 100644 --- a/public/style.css +++ b/public/style.css @@ -22,7 +22,12 @@ } } +:root[data-theme='light'] { + color-scheme: light; +} + :root[data-theme='dark'] { + color-scheme: dark; --bg: #15171a; --fg: #e8e6e3; --muted: #9aa0a6; @@ -91,6 +96,28 @@ footer { color: var(--muted); } +.theme { + display: flex; + gap: 0.25rem; + margin-top: 0.5rem; +} + +.theme button { + min-height: 44px; + padding: 0 0.5rem; + border: 0; + background: none; + color: var(--muted); + font: inherit; + font-size: 0.85rem; + cursor: pointer; +} + +.theme button[aria-pressed='true'] { + color: var(--fg); + text-decoration: underline; +} + .warn { color: var(--warn); } 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/test/lib.test.ts b/test/lib.test.ts index 082d1a2..f94c359 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, parts, saveSeen, sourceLabel, statusText } from '../public/lib.js'; +import { readFileSync } from 'node:fs'; +import { age, allDown, group, isNew, 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(); @@ -157,3 +158,63 @@ describe('sourceLabel', () => { 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(); + }); +}); From 26786161a412fd8e7d0e85bddadb14757d7a716c Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 07:59:57 +0530 Subject: [PATCH 05/16] feat(page): logbook design language Recursive, self-hosted (OFL), and one token block with light-dark() colors; tests guard the tokens and contrast, probe checks the font live. --- public/app.js | 3 +- public/favicon.svg | 2 +- public/fonts/recursive-OFL.txt | 93 ++++++++ public/fonts/recursive.woff2 | Bin 0 -> 72560 bytes public/index.html | 19 +- public/style.css | 384 +++++++++++++++++++++------------ scripts/probe.mjs | 8 + test/style.test.ts | 47 ++++ 8 files changed, 413 insertions(+), 143 deletions(-) create mode 100644 public/fonts/recursive-OFL.txt create mode 100644 public/fonts/recursive.woff2 create mode 100644 test/style.test.ts diff --git a/public/app.js b/public/app.js index 86a6f94..2cbd49c 100644 --- a/public/app.js +++ b/public/app.js @@ -83,6 +83,7 @@ function render() { ] .filter(Boolean) .join(' · '); + count.classList.toggle('has-new', Boolean(seen && fresh > 0)); if (allDown(posts)) { list.replaceChildren(el('p', { class: 'empty', text: 'Every source is unavailable right now.' }), retryButton()); @@ -106,7 +107,7 @@ 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', 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 0000000000000000000000000000000000000000..b0ed1bb018a0bde60a0144371b3e9b3b4b28f20c GIT binary patch literal 72560 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 + -
-

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/style.css b/public/style.css index 714c3a6..d835ded 100644 --- a/public/style.css +++ b/public/style.css @@ -1,25 +1,45 @@ +/* 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'] { @@ -28,14 +48,13 @@ :root[data-theme='dark'] { color-scheme: dark; - --bg: #15171a; - --fg: #e8e6e3; - --muted: #9aa0a6; - --line: #2a2d31; - --accent: #f2a541; - --warn: #fdba74; - --banner: #2a2118; - --focus: #93c5fd; +} + +@font-face { + font-family: 'Recursive'; + src: url('/fonts/recursive.woff2') format('woff2'); + font-weight: 300 1000; + font-display: swap; } * { @@ -44,26 +63,14 @@ 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; -} - -h1 { - margin: 0; - font-size: 1.5rem; - letter-spacing: -0.01em; -} - -h2 { - margin: 1.75rem 0 0.25rem; - font-size: 0.8rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--muted); + 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 { @@ -71,80 +78,156 @@ a { } :focus-visible { - outline: 2px solid var(--focus); - outline-offset: 2px; + outline: var(--tick) solid var(--signal); + outline-offset: var(--hair); } -.tagline, -.muted, -.next, -.count, -footer { - color: var(--muted); +.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); } -.tagline { - margin: 0.25rem 0 0; +h1 { + margin: 0; + font-size: var(--size-brand); + font-weight: var(--weight-brand); + font-variation-settings: var(--axes-mono); + line-height: var(--leading-tight); } -.status { - display: flex; - flex-wrap: wrap; - gap: 0.25rem 1rem; - margin: 0.75rem 0 0; - font-size: 0.85rem; - color: var(--muted); +.tagline { + margin: var(--space-1) 0 0; + font-size: var(--size-meta); + color: var(--ink-2); } .theme { display: flex; - gap: 0.25rem; - margin-top: 0.5rem; + gap: var(--space-1); } .theme button { - min-height: 44px; - padding: 0 0.5rem; + min-height: var(--tap); + padding: 0 var(--space-2); border: 0; background: none; - color: var(--muted); + color: var(--ink-2); font: inherit; - font-size: 0.85rem; + font-size: var(--size-meta); + font-variation-settings: var(--axes-mono); cursor: pointer; } .theme button[aria-pressed='true'] { - color: var(--fg); + color: var(--ink); text-decoration: underline; + text-decoration-thickness: var(--hair); + text-underline-offset: var(--space-1); +} + +.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 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; +} + +/* The key for new rows: the same tick they carry. */ +.count.has-new::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 hour is the event the page is named for: its one raised voice. */ +.first h2 { + color: var(--signal); +} + +.first h2::after { + border-color: var(--signal); } ol { @@ -154,79 +237,68 @@ ol { } li + li { - border-top: 1px solid var(--line); + border-top: var(--hair) solid var(--rule); } +/* Seen rows recede; unseen rows keep full ink. Company and role always show whole: who may apply often comes last. */ .row { display: grid; - grid-template-columns: 3.25rem 1fr auto; - grid-template-areas: 'age co src' 'age role role'; - column-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); + color: var(--ink-2); text-decoration: none; } -.row { - color: var(--muted); -} - .row:hover, .new { - color: var(--fg); + color: var(--ink); } .row:hover .role { text-decoration: underline; + text-decoration-thickness: var(--hair); } -.age { - grid-area: age; - font-variant-numeric: tabular-nums; - color: var(--muted); +.row > * { + min-width: 0; + overflow-wrap: anywhere; } -.new .age { - color: var(--accent); - font-weight: 600; +.age { + font-variation-settings: var(--axes-mono); + font-variant-numeric: tabular-nums; + text-align: right; } .co { - grid-area: co; - font-weight: 600; -} - -.role { - grid-area: role; - overflow-wrap: anywhere; + font-weight: var(--weight-strong); } .src { - grid-area: src; - font-size: 0.85rem; - color: var(--muted); + font-size: var(--size-meta); + font-variation-settings: var(--axes-mono); + text-align: right; } -.mark { - margin-left: 0.5rem; - color: var(--accent); - font-weight: 600; +.new { + box-shadow: inset var(--tick) 0 0 var(--signal); } -.empty { - margin: 2rem 0 0.75rem; +.new .age { + color: var(--signal); + font-weight: var(--weight-strong); } -.next { - margin: 1.25rem 0 0; - font-size: 0.9rem; +.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; @@ -234,23 +306,67 @@ 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 (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; 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/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); + } + }); +}); From 845af0826f257d98783ebd83e7977bab56333eab Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 08:00:29 +0530 Subject: [PATCH 06/16] fix(page): keep the closing paren of text around a dropped URL --- public/lib.js | 7 ++++--- test/lib.test.ts | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/public/lib.js b/public/lib.js index 3800e65..9f5f5db 100644 --- a/public/lib.js +++ b/public/lib.js @@ -143,8 +143,9 @@ export function isNew(item, seen) { return seen !== null && !seen.ids.has(item.id); } -// A URL, bare `www.` host, or one cut short by the 160-char title cap ("https:…"), with any wrapping parens. -const URLISH = /\(?\b(?:https?:\S*|www\.\S+?)\)?(?=\s|$)/gi; +// A URL, bare `www.` host, or one cut short by the 160-char title cap ("https:…"). Wrapping parens go with it; +// a paren that closes surrounding text stays, so "(listed here https://x)" keeps its ")". +const URLISH = /\((?:https?:|www\.)[^\s)]*\)|\b(?:https?:|www\.)[^\s)]*/gi; const EDGES = /^[\s,;:·-]+|[\s,;:·-]+$/g; /** @@ -155,7 +156,7 @@ const EDGES = /^[\s,;:·-]+|[\s,;:·-]+$/g; export function parts(item) { const segs = item.title .split('|') - .map((s) => s.replace(URLISH, '').replace(/\s+/g, ' ').replace(EDGES, '')) + .map((s) => s.replace(URLISH, '').replace(/\s+/g, ' ').replace(/ \)/g, ')').replace(EDGES, '')) .filter(Boolean); if (segs.length === 0) return { company: '', role: item.title }; if (item.source === 'yc' || segs.length < 2) return { company: '', role: segs.join(' · ') }; diff --git a/test/lib.test.ts b/test/lib.test.ts index f94c359..928a25e 100644 --- a/test/lib.test.ts +++ b/test/lib.test.ts @@ -135,6 +135,7 @@ describe('parts', () => { ['Acme | www.acme.example | Remote', { company: 'Acme', role: 'Remote' }], ['Acme | Staff Engineer | Remote | https:…', { company: 'Acme', role: 'Staff 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); From faded8799f9f7c45b1295b397e3a284dadaff04b Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 08:01:38 +0530 Subject: [PATCH 07/16] docs: design language --- docs/design.md | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/design.md diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..e93d131 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,81 @@ +# Design + +firsthour is a logbook. Roles are entries stamped by age, and the page exists to show what arrived since you last looked. Time leads every row, unread entries carry full ink, and nothing a reader needs to decide is ever cut off. + +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` | unread rows, text | 13.8 / 13.4 | +| `--ink-2` | `#45545e` | `#8fabab` | seen 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. Rows you have seen use `--ink-2`, so dimming never drops below that floor. + +The palette is cool on purpose. The sibling sift is a warm cream reading desk, so 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, whole +``` + +- **Age** leads: `12m`, `3h`, `2d`, mono with tabular figures. +- **Company and role** come from the title's `Company | Role | …` convention (`parts` in `public/lib.js`). URLs are dropped from display, since the link is built from a validated id. HN job stories have no company part. +- **Source** is one quiet word: `HN`, `HN jobs`, `board`. +- **Nothing is truncated.** In HN titles the restriction ("US only", "visa possible") usually comes last, so rows wrap instead of cutting. Titles are capped at 160 characters upstream. + +## New and seen + +- An unseen row has full ink, a signal tick on its left edge, and a signal age. Screen readers get the word "new", which is visually hidden. +- A row becomes seen only once most of it has been on screen (IntersectionObserver, 60 %). Rows you never scrolled to keep full ink on the next visit. +- The count line opens with the same tick, as the key: "12 new since Thu, Oct 8, 11:45 AM". +- **First hour** is the only group header in signal color. It is the event the page is named for, and its one raised voice. + +## 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. +- Text is never cut. If a row needs more room, it wraps. +- 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. From 3096cfd24d3c251445138eff5d5d06c39c771f4e Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 08:05:12 +0530 Subject: [PATCH 08/16] fix(page): filter on what a row shows where is no longer displayed; matching it let -hiring hide 69 rows by invisible text. --- public/lib.js | 4 ++-- test/lib.test.ts | 11 +++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/public/lib.js b/public/lib.js index 9f5f5db..b20bf35 100644 --- a/public/lib.js +++ b/public/lib.js @@ -71,9 +71,9 @@ function hasWord(haystack, term) { return new RegExp(`(? hasWord(haystack, t))) return false; return f.include.length === 0 || f.include.some((t) => hasWord(haystack, t)); } diff --git a/test/lib.test.ts b/test/lib.test.ts index 928a25e..480412a 100644 --- a/test/lib.test.ts +++ b/test/lib.test.ts @@ -43,13 +43,20 @@ 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 the title or source word, 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('matches the source word a row shows, never the unshown where', () => { + const board = { ...item('1', 0, 'Acme | Engineer | Remote', 'Acme careers'), source: 'boards' as const }; + expect(matches(board, parseFilter('board'))).toBe(true); + expect(matches(board, parseFilter('careers'))).toBe(false); + expect(matches(item('2', 0), parseFilter('-hiring'))).toBe(true); + expect(matches(item('3', 0), parseFilter('hn'))).toBe(true); }); it('accepts a plural', () => { From c87b2388b5006f9f7d0313583caabd2a83630b66 Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 08:15:59 +0530 Subject: [PATCH 09/16] fix(page): tick marks arrivals, ink marks what was read Listed-but-unscrolled rows came back ticked as new since a visit they predate; a first visit dimmed every row. --- docs/design.md | 24 +++++++++++++++++------- public/app.js | 9 ++++++--- public/lib.js | 39 ++++++++++++++++++++++++++------------- public/style.css | 8 +++++--- test/lib.test.ts | 33 +++++++++++++++++++++++++-------- 5 files changed, 79 insertions(+), 34 deletions(-) diff --git a/docs/design.md b/docs/design.md index e93d131..1457e2c 100644 --- a/docs/design.md +++ b/docs/design.md @@ -27,14 +27,14 @@ Each color is written once as `light-dark(light, dark)`. The system theme picks | Token | Light | Dark | Role | Contrast on paper (light / dark) | |---|---|---|---|---| | `--paper` | `#e1edf0` | `#051a1d` | page | | -| `--ink` | `#14202b` | `#cde3e0` | unread rows, text | 13.8 / 13.4 | -| `--ink-2` | `#45545e` | `#8fabab` | seen rows, meta | 6.6 / 7.3 | +| `--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. Rows you have seen use `--ink-2`, so dimming never drops below that floor. +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. The sibling sift is a warm cream reading desk, so no firsthour token sits within ΔE 5 (CIE76) of a sift token. @@ -51,11 +51,21 @@ mobile age │ company │ sour - **Source** is one quiet word: `HN`, `HN jobs`, `board`. - **Nothing is truncated.** In HN titles the restriction ("US only", "visa possible") usually comes last, so rows wrap instead of cutting. Titles are capped at 160 characters upstream. -## New and seen +## New and read -- An unseen row has full ink, a signal tick on its left edge, and a signal age. Screen readers get the word "new", which is visually hidden. -- A row becomes seen only once most of it has been on screen (IntersectionObserver, 60 %). Rows you never scrolled to keep full ink on the next visit. -- The count line opens with the same tick, as the key: "12 new since Thu, Oct 8, 11:45 AM". +Two signals, two facts: + +| 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, and its one raised voice. ## Theme diff --git a/public/app.js b/public/app.js index 2cbd49c..2467708 100644 --- a/public/app.js +++ b/public/app.js @@ -1,5 +1,5 @@ // @ts-check -import { age, allDown, group, isActive, isNew, loadSeen, loadTheme, matches, nextSeen, parseFilter, parts, saveSeen, saveTheme, sourceLabel, 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 */ @@ -24,6 +24,8 @@ function storage() { const seen = loadSeen(storage()); /** @type {Posts | null} */ 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( @@ -74,6 +76,7 @@ function render() { const now = Date.now(); const filter = parseFilter(input.value); const visible = posts.items.filter((item) => matches(item, filter)); + shown = visible.map((item) => item.id); renderStatus(posts); const fresh = visible.filter((item) => isNew(item, seen)).length; @@ -125,7 +128,7 @@ function render() { function row(item, now) { const fresh = isNew(item, seen); const { company, role } = parts(item); - const a = el('a', { class: fresh ? 'row new' : 'row', href: item.url }, [ + 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 }), @@ -169,7 +172,7 @@ function onFilter() { } function markSeen() { - if (posts) saveSeen(storage(), nextSeen(seen, posts.items.map((item) => item.id), viewed), 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]')); diff --git a/public/lib.js b/public/lib.js index b20bf35..79a8f39 100644 --- a/public/lib.js +++ b/public/lib.js @@ -6,7 +6,7 @@ * @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 */ @@ -90,27 +90,35 @@ 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 on screen now. - * Rows never scrolled to, or hidden by the filter, stay unseen and keep full ink next visit. - * @param {Seen | null} seen @param {string[]} listed @param {Iterable} viewed + * 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, viewed) { - const ids = new Set(viewed); - for (const id of listed) if (seen?.ids.has(id)) ids.add(id); - return [...ids]; +export function nextSeen(seen, listed, shown, viewed) { + const ids = new Set(shown); + 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. } @@ -138,11 +146,16 @@ export function saveTheme(storage, theme) { } } -/** @param {Item} item @param {Seen | null} seen */ +/** 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, bare `www.` host, or one cut short by the 160-char title cap ("https:…"). Wrapping parens go with it; // a paren that closes surrounding text stays, so "(listed here https://x)" keeps its ")". const URLISH = /\((?:https?:|www\.)[^\s)]*\)|\b(?:https?:|www\.)[^\s)]*/gi; diff --git a/public/style.css b/public/style.css index d835ded..78b3c18 100644 --- a/public/style.css +++ b/public/style.css @@ -247,12 +247,14 @@ li + li { column-gap: var(--space-3); align-items: baseline; padding: var(--space-2); - color: var(--ink-2); text-decoration: none; } -.row:hover, -.new { +.read { + color: var(--ink-2); +} + +.row:hover { color: var(--ink); } diff --git a/test/lib.test.ts b/test/lib.test.ts index 480412a..00a170a 100644 --- a/test/lib.test.ts +++ b/test/lib.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { readFileSync } from 'node:fs'; -import { age, allDown, group, isNew, loadSeen, loadTheme, matches, nextSeen, parseFilter, parts, saveSeen, saveTheme, sourceLabel, statusText } from '../public/lib.js'; +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(); @@ -80,10 +80,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', () => { @@ -99,16 +113,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(); }); }); From ac261b9dea738c1dd5eca386828d0c6f30c4560e Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 08:16:16 +0530 Subject: [PATCH 10/16] fix(page): filter on company and role only The title's URLs and the source word matched too: -lever hid rows by a hidden URL, -hn dropped HN job stories. --- public/lib.js | 9 +++++++-- test/lib.test.ts | 10 ++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/public/lib.js b/public/lib.js index 79a8f39..4ae2eaa 100644 --- a/public/lib.js +++ b/public/lib.js @@ -71,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)); } diff --git a/test/lib.test.ts b/test/lib.test.ts index 00a170a..019f2e8 100644 --- a/test/lib.test.ts +++ b/test/lib.test.ts @@ -43,7 +43,7 @@ describe('filter', () => { expect(parseFilter(' Go, backend ,, -Onsite, - ')).toEqual({ include: ['go', 'backend'], exclude: ['onsite'] }); }); - it('matches whole words in the title or source word, 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); @@ -51,12 +51,14 @@ describe('filter', () => { expect(matches(item('4', 0, 'Acme | Rust | Remote or onsite'), f)).toBe(false); }); - it('matches the source word a row shows, never the unshown where', () => { + 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(true); + 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), parseFilter('hn'))).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', () => { From df860bff12b1a80b2990f2e6a134f25b02e8dda4 Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 08:16:52 +0530 Subject: [PATCH 11/16] fix(page): drop URLs the title cap cut short MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit '(https://…' kept its open paren and '(http…' survived whole. --- public/lib.js | 10 ++++++---- test/lib.test.ts | 4 ++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/public/lib.js b/public/lib.js index 4ae2eaa..d5c7c96 100644 --- a/public/lib.js +++ b/public/lib.js @@ -161,9 +161,11 @@ export function isRead(item, seen) { return seen !== null && seen.read.has(item.id); } -// A URL, bare `www.` host, or one cut short by the 160-char title cap ("https:…"). Wrapping parens go with it; -// a paren that closes surrounding text stays, so "(listed here https://x)" keeps its ")". -const URLISH = /\((?:https?:|www\.)[^\s)]*\)|\b(?:https?:|www\.)[^\s)]*/gi; +// 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; /** @@ -174,7 +176,7 @@ const EDGES = /^[\s,;:·-]+|[\s,;:·-]+$/g; export function parts(item) { const segs = item.title .split('|') - .map((s) => s.replace(URLISH, '').replace(/\s+/g, ' ').replace(/ \)/g, ')').replace(EDGES, '')) + .map((s) => s.replace(URLISH, '').replace(CUT_URL, '').replace(/\s+/g, ' ').replace(/ \)/g, ')').replace(EDGES, '')) .filter(Boolean); if (segs.length === 0) return { company: '', role: item.title }; if (item.source === 'yc' || segs.length < 2) return { company: '', role: segs.join(' · ') }; diff --git a/test/lib.test.ts b/test/lib.test.ts index 019f2e8..87839e2 100644 --- a/test/lib.test.ts +++ b/test/lib.test.ts @@ -160,6 +160,10 @@ describe('parts', () => { ['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…' }], ['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' }], From 87402aa736e61eb329fa03fa8c3c24a7751050bb Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 08:17:10 +0530 Subject: [PATCH 12/16] fix(page): keep the role out of the company column A first segment that was only a URL moved the role into the company slot. --- public/lib.js | 11 ++++++----- test/lib.test.ts | 1 + 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/public/lib.js b/public/lib.js index d5c7c96..4de82ce 100644 --- a/public/lib.js +++ b/public/lib.js @@ -176,11 +176,12 @@ const EDGES = /^[\s,;:·-]+|[\s,;:·-]+$/g; export function parts(item) { const segs = item.title .split('|') - .map((s) => s.replace(URLISH, '').replace(CUT_URL, '').replace(/\s+/g, ' ').replace(/ \)/g, ')').replace(EDGES, '')) - .filter(Boolean); - if (segs.length === 0) return { company: '', role: item.title }; - if (item.source === 'yc' || segs.length < 2) return { company: '', role: segs.join(' · ') }; - return { company: segs[0] ?? '', role: segs.slice(1).join(' · ') }; + .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' }; diff --git a/test/lib.test.ts b/test/lib.test.ts index 87839e2..da48106 100644 --- a/test/lib.test.ts +++ b/test/lib.test.ts @@ -164,6 +164,7 @@ describe('parts', () => { ['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' }], From 798f0573cde50073953751780a03ee32de9cdf72 Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 08:17:59 +0530 Subject: [PATCH 13/16] fix(page): the new-row tick keys the new count only With a filter on, the tick sat before 'N of M'. --- public/app.js | 11 ++++------- public/style.css | 6 +++++- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/public/app.js b/public/app.js index 2467708..d6b0e3d 100644 --- a/public/app.js +++ b/public/app.js @@ -80,13 +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.classList.toggle('has-new', Boolean(seen && fresh > 0)); + 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()); diff --git a/public/style.css b/public/style.css index 78b3c18..739f663 100644 --- a/public/style.css +++ b/public/style.css @@ -183,8 +183,12 @@ input[type='search']::placeholder { display: none; } +.count > span:not(:last-child)::after { + content: ' · '; +} + /* The key for new rows: the same tick they carry. */ -.count.has-new::before { +.new-count::before { content: ''; display: inline-block; width: var(--tick); From bf0133bd1dc546ed5baf3f9b3ef19e36a42c6494 Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 08:18:26 +0530 Subject: [PATCH 14/16] fix(page): 44px rows on touch screens Desktop rows are 36px; a tablet wider than 40rem got them too. --- public/style.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/public/style.css b/public/style.css index 739f663..13a8993 100644 --- a/public/style.css +++ b/public/style.css @@ -373,6 +373,13 @@ footer p { } } +@media (pointer: coarse) { + .row { + min-height: var(--tap); + align-content: center; + } +} + @media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; From 2a66905758238511c0f0c8750dd85fc44dc9711e Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 08:18:35 +0530 Subject: [PATCH 15/16] style(page): keep only constraints in CSS comments --- public/style.css | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/public/style.css b/public/style.css index 13a8993..0154d84 100644 --- a/public/style.css +++ b/public/style.css @@ -187,7 +187,7 @@ input[type='search']::placeholder { content: ' · '; } -/* The key for new rows: the same tick they carry. */ +/* Same tick as .new rows: change both together. */ .new-count::before { content: ''; display: inline-block; @@ -225,7 +225,6 @@ h2::after { border-top: var(--hair) solid var(--rule-strong); } -/* First hour is the event the page is named for: its one raised voice. */ .first h2 { color: var(--signal); } @@ -244,7 +243,7 @@ li + li { border-top: var(--hair) solid var(--rule); } -/* Seen rows recede; unseen rows keep full ink. Company and role always show whole: who may apply often comes last. */ +/* Never clip company or role (no nowrap, no ellipsis): who may apply often comes last. */ .row { display: grid; grid-template-columns: var(--col-age) var(--col-co) 1fr var(--col-src); From 41d75cbd200f7aa0cdcf91388bda6b5e34d4e74f Mon Sep 17 00:00:00 2001 From: 1mb-dev <1mb@1mb.dev> Date: Fri, 9 Oct 2026 08:20:48 +0530 Subject: [PATCH 16/16] docs: tighten the design notes --- docs/design.md | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/docs/design.md b/docs/design.md index 1457e2c..019b742 100644 --- a/docs/design.md +++ b/docs/design.md @@ -1,6 +1,6 @@ # Design -firsthour is a logbook. Roles are entries stamped by age, and the page exists to show what arrived since you last looked. Time leads every row, unread entries carry full ink, and nothing a reader needs to decide is ever cut off. +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. @@ -36,24 +36,21 @@ Each color is written once as `light-dark(light, dark)`. The system theme picks 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. The sibling sift is a warm cream reading desk, so no firsthour token sits within ΔE 5 (CIE76) of a sift token. +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, whole + role ``` -- **Age** leads: `12m`, `3h`, `2d`, mono with tabular figures. -- **Company and role** come from the title's `Company | Role | …` convention (`parts` in `public/lib.js`). URLs are dropped from display, since the link is built from a validated id. HN job stories have no company part. -- **Source** is one quiet word: `HN`, `HN jobs`, `board`. -- **Nothing is truncated.** In HN titles the restriction ("US only", "visa possible") usually comes last, so rows wrap instead of cutting. Titles are capped at 160 characters upstream. +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. -## New and read +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. -Two signals, two facts: +## New and read | Row | Means | |---|---| @@ -66,7 +63,7 @@ Two signals, two facts: - 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, and its one raised voice. +- First hour is the only group header in signal color: it is the event the page is named for. ## Theme @@ -77,7 +74,7 @@ Two signals, two facts: ## Rules - Tokens only: a new value is a new token. -- Text is never cut. If a row needs more room, it wraps. +- 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.