Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ mobile age │ company │ sour

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.
A row opens its posting in a new tab, so the list keeps its ticks, scroll and filter while you read; no icon or note says so. 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

Expand Down
10 changes: 7 additions & 3 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,18 @@ const onScreen = new IntersectionObserver(

/**
* @param {string} tag
* @param {{ class?: string, text?: string, href?: string }} [props]
* @param {{ class?: string, text?: string, href?: string, target?: string, rel?: string }} [props]
* @param {(Node | null)[]} [children]
*/
function el(tag, props = {}, children = []) {
const node = document.createElement(tag);
if (props.class) node.className = props.class;
if (props.text !== undefined) node.textContent = props.text;
if (props.href !== undefined && node instanceof HTMLAnchorElement) node.href = props.href;
if (node instanceof HTMLAnchorElement) {
if (props.href !== undefined) node.href = props.href;
if (props.target !== undefined) node.target = props.target;
if (props.rel !== undefined) node.rel = props.rel;
}
for (const child of children) if (child) node.append(child);
return node;
}
Expand Down Expand Up @@ -125,7 +129,7 @@ function render() {
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 }, [
const a = el('a', { class: ['row', fresh ? 'new' : '', isRead(item, seen) ? 'read' : ''].filter(Boolean).join(' '), href: item.url, target: '_blank', rel: 'noopener' }, [
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 }),
Expand Down
14 changes: 14 additions & 0 deletions test/app.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';

// app.js drives the DOM at import, so it is pinned by source rather than run.
const source = readFileSync(new URL('../public/app.js', import.meta.url), 'utf8');
const row = source.match(/^function row\([\s\S]*?^\}/m)?.[0] ?? '';

describe('row link', () => {
it('opens the posting in a new tab without handing it the page', () => {
expect(row).not.toBe('');
expect(row).toContain("target: '_blank'");
expect(row).toContain("rel: 'noopener'");
});
});
Loading