From 5d1ac515fd50dd3b64ffe9e5ed26bc85c1d4782a Mon Sep 17 00:00:00 2001 From: John Bolliger Date: Wed, 12 Aug 2026 21:22:48 -0700 Subject: [PATCH 01/20] Port to Manifest V3 and rebuild the stylesheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome no longer loads MV2 extensions, so HNES has not run at all as shipped. This is the port, plus the storage migration that has to travel in the same release, plus a stylesheet rebuilt on a token layer. Storage migration (has to be in this release or the data is stranded): every durable value — user tags, upvote counts, per-thread read positions — lived in the MV2 background page's localStorage, which a service worker cannot see. background.js now reads it once via an offscreen document on Chrome and directly on Firefox's event page, copies it into chrome.storage.local without clobbering anything already there, and normalizes the legacy bare-number vote format on the way. The lazy converter in hn.js that was supposed to do that never fired: it tested `typeof value === "number"` against localStorage, which only ever returns strings. The MV2 expiry sweep never ran either — it was written as `(function(){...});` with no trailing call — so it runs here. Manifest: dual-target background so one file serves Chrome's service worker and Firefox's event page; web_accessible_resources in the MV3 object form; templates/comment.html dropped from it, as that file has never existed and the real template is inline in hn.js. Match patterns lose news.ycombinator.net/.org (dead DNS) and the http:// variants, and hckrnews.com is corrected to https — it is HTTPS-only, so that content script has silently not run for years. all_frames dropped: it re-ran jQuery and hn.js in every iframe for no benefit. hn.js: chrome.extension.getURL -> chrome.runtime.getURL, and the sendMessage proxy to the background page is gone in favour of direct chrome.storage.local calls. That also collapses two disjoint stores into one, since collapse state already used chrome.storage.local directly. The comment fade class is read off div.commtext now; HN moved the body out of a span, so the old querySelector('span') picked up whatever inline element came first and every comment defaulted to c00. style.css is rebuilt around tokens with light-dark() pairs, a theme override and a density axis, both applied by the new boot.js before first paint. boot.js also hides the page while hn.js rewrites it, with a CSS animation as the failsafe: an uncaught throw mid-rewrite used to leave a permanently blank Hacker News, and now costs styling instead. Co-Authored-By: Claude Opus 5 (1M context) --- background.js | 172 ++++- js/boot.js | 43 ++ js/hn.js | 178 +++-- manifest.json | 45 +- offscreen.html | 4 + offscreen.js | 18 + style.css | 1914 +++++++++++++++++++++++++++++------------------- 7 files changed, 1514 insertions(+), 860 deletions(-) create mode 100644 js/boot.js create mode 100644 offscreen.html create mode 100644 offscreen.js diff --git a/background.js b/background.js index be28219..9e4febd 100644 --- a/background.js +++ b/background.js @@ -1,41 +1,147 @@ -// Add event listeners -chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) { - console.log('REQUEST', request.method, request) - if (request.method == "getAllLocalStorage") { - sendResponse({data: localStorage}); - } - else if (request.method == "getLocalStorage") { - sendResponse({data: localStorage[request.key]}); - } - else if (request.method == "setLocalStorage") { - localStorage[request.key] = request.value; - sendResponse({}); +/* + * HNES background worker. + * + * Chrome runs this as an MV3 service worker, Firefox as an event page. It has no + * message handlers any more: content scripts reach chrome.storage.local directly. + * What is left is one-time maintenance — rescuing the MV2 localStorage store and + * sweeping expired entries. + */ + +const MIGRATION_FLAG = 'hnesMigratedFromLocalStorage'; +const OFFSCREEN_URL = 'offscreen.html'; + +chrome.runtime.onInstalled.addListener(() => { + migrateLegacyStorage() + .then(() => expireOldEntries()) + .catch(e => console.error('HNES: maintenance failed', e)); +}); + +chrome.runtime.onStartup.addListener(() => { + expireOldEntries().catch(e => console.error('HNES: expiry sweep failed', e)); +}); + +/* + * Everything the extension persisted before v2 — user tags, upvote counts, per-thread + * read positions — lived in the MV2 background page's localStorage, which a service + * worker cannot see. The data itself survives the upgrade because the extension origin + * is unchanged, so a document running on that origin can still read and forward it. + * + * On failure the flag is deliberately left unset: the localStorage copy is still on + * disk, so a later run gets another attempt. + */ +async function migrateLegacyStorage() { + const flag = await chrome.storage.local.get(MIGRATION_FLAG); + if (flag[MIGRATION_FLAG]) return; + + let legacy; + try { + legacy = await readLegacyStorage(); + } catch (e) { + console.error('HNES: could not read legacy localStorage, will retry later', e); + return; } - else if (request.method == "getUserData") { - var data = getUserData(request.usernames); - sendResponse({ data: data }); + + // Only the legacy keys need checking, so this stays bounded even though the + // collapse-state store can grow to tens of thousands of entries. + const existing = await chrome.storage.local.get(Object.keys(legacy)); + const toWrite = {}; + + for (const key of Object.keys(legacy)) { + // Never clobber anything the v2 build already wrote (e.g. comment collapse state). + if (key in existing) continue; + toWrite[key] = normalizeLegacyValue(legacy[key]); } - else { - sendResponse({}); + + console.log(`HNES: migrated ${Object.keys(toWrite).length} entries out of localStorage`); + toWrite[MIGRATION_FLAG] = true; + await chrome.storage.local.set(toWrite); +} + +/* + * Upvote counts were originally stored as a bare number ("3") and later as + * '{"votes":3}'. The lazy converter in hn.js checked `typeof value === "number"`, which + * localStorage could never satisfy — it always hands back strings — so legacy entries + * fell through to JSON.parse and became a bare number, whose .votes is undefined. That + * left those users' scores invisible and stuck. Convert them once, here. + * + * Keyed on value shape rather than key shape: a bare run of digits is only ever a + * legacy vote count. Everything else is a JSON object (thread read-state), a URL, or + * one of the "true"/"false" flags, so none of them need naming individually — which + * also means a flag added later cannot be silently reshaped. + */ +function normalizeLegacyValue(value) { + return /^\d+$/.test(value) ? JSON.stringify({ votes: Number(value) }) : value; +} + +async function readLegacyStorage() { + // Firefox's MV3 event page still has localStorage; only Chrome needs the detour. + if (typeof localStorage !== 'undefined') return snapshotLocalStorage(localStorage); + return readLegacyStorageViaOffscreen(); +} + +/* + * Walks by index rather than spreading the Storage object: a spread reads own + * enumerable properties, which misbehaves for keys that collide with Storage's + * own members (a stored key literally named "length" or "getItem"). offscreen.js + * inlines the same walk — it runs in its own document and cannot import. + */ +function snapshotLocalStorage(store) { + const out = {}; + for (let i = 0; i < store.length; i++) { + const key = store.key(i); + out[key] = store.getItem(key); } -}); + return out; +} + +async function readLegacyStorageViaOffscreen() { + if (!chrome.offscreen) throw new Error('offscreen API unavailable'); + + await chrome.offscreen.createDocument({ + url: OFFSCREEN_URL, + reasons: ['LOCAL_STORAGE'], + justification: 'Read the Manifest V2 background page localStorage so saved user tags, upvote counts and thread read positions survive the upgrade.' + }); -function getUserData(usernames) { - var results = {}; - for (var i = 0; i < usernames.length; i++) { - var key = usernames[i], - value = localStorage[key]; - results[key] = value; + try { + return await chrome.runtime.sendMessage({ + target: 'offscreen', + method: 'readLegacyStorage' + }); + } finally { + await chrome.offscreen.closeDocument(); } - return results; } -//expire old entries -(function() { - for (i=0; i info.expire) - localStorage.removeItem(localStorage.key(i)); +/* + * Thread read-state entries carry a five-day `expire` stamp. The MV2 sweep was written + * as `(function(){...});` with no trailing call, so it never ran once and stale entries + * have accumulated for the life of the extension. + * + * Comment collapse state is stored as an object rather than a string and has no expiry, + * so it is skipped here — it still grows without bound. + */ +async function expireOldEntries(snapshot) { + const all = snapshot || await chrome.storage.local.get(null); + const now = Date.now(); + const stale = []; + + for (const key of Object.keys(all)) { + const value = all[key]; + if (typeof value !== 'string') continue; + + let info; + try { + info = JSON.parse(value); + } catch (e) { + continue; + } + + if (info && typeof info.expire === 'number' && now > info.expire) stale.push(key); } -}); + + if (stale.length) { + await chrome.storage.local.remove(stale); + console.log(`HNES: expired ${stale.length} stale entries`); + } +} diff --git a/js/boot.js b/js/boot.js new file mode 100644 index 0000000..a594edc --- /dev/null +++ b/js/boot.js @@ -0,0 +1,43 @@ +/* + * Runs at document_start, before HN's markup is parsed. + * + * Two jobs, both of which have to happen before first paint: + * + * - Mark the document as pending so the stylesheet can hide the raw HN markup + * while hn.js rewrites it. Putting the flag here rather than in CSS means a + * page where the content script never runs is never hidden at all, and the + * stylesheet's failsafe animation reveals the page even if hn.js throws. + * - Apply the saved theme override and view density. The storage read is async, + * so it can land after paint; that is harmless because the body is still + * hidden, and an unset theme just falls through to prefers-color-scheme + * while an unset density falls through to comfortable. + */ +(function () { + var root = document.documentElement; + if (!root) return; + + root.classList.add('hnes-pending'); + + /* + * Mirrors HN.MODES in hn.js — deliberately, not accidentally: this is a + * separate content script at document_start, so it cannot read hn.js's copy. + * Same convention, so the two stay comparable at a glance: values[0] is the + * unset state and leaves the attribute off. Adding a mode means adding it in + * both places, or it works after paint and flashes on every cold load. + */ + var MODES = [ + { key: 'hnesTheme', attr: 'data-hnes-theme', values: ['auto', 'light', 'dark'] }, + { key: 'hnesDensity', attr: 'data-hnes-density', values: ['comfortable', 'compact', 'flow'] } + ]; + + try { + chrome.storage.local.get(MODES.map(function (m) { return m.key; }), function (items) { + MODES.forEach(function (m) { + var value = items && items[m.key]; + if (m.values.indexOf(value) > 0) root.setAttribute(m.attr, value); + }); + }); + } catch (e) { + /* Storage unavailable — prefers-color-scheme and comfortable still apply. */ + } +})(); diff --git a/js/hn.js b/js/hn.js index e6f1472..ee2888d 100644 --- a/js/hn.js +++ b/js/hn.js @@ -63,7 +63,7 @@ var InlineReply = { $(this).attr("value","Posting..."); //Add loading spinner image = $(''); - image.attr('src',chrome.extension.getURL("images/spin.gif")); + image.attr('src',chrome.runtime.getURL("images/spin.gif")); $(this).after(image); //Post InlineReply.postCommentTo(link, domain, text, $(this)); @@ -261,7 +261,7 @@ var CommentTracker = { } } -var unvoteImg = chrome.extension.getURL("images/unvote.gif"); +var unvoteImg = chrome.runtime.getURL("images/unvote.gif"); class HNComments { constructor(storyId) { @@ -394,8 +394,14 @@ class HNComments { userColor = userFontEl ? userFontEl.getAttribute('color') : '', isNoob = userColor == "#3c963c", isOP = username == original_poster, - commentSpanEl = commentEl.querySelector('span'), - commentColor = commentSpanEl ? commentSpanEl.classList[0] : 'c00', + // HN's fade level lives on div.commtext as a cN class (c00 = normal, + // through cdd = heavily downvoted). This used to read classList[0] off + // the first in the comment, which stopped working when HN moved + // the body from a span to div.commtext: it picked up whatever class the + // first inline element happened to carry, or nothing at all. + commentTextEl = commentEl.querySelector('.commtext'), + commentColor = (commentTextEl && Array.from(commentTextEl.classList) + .find(cls => /^c[0-9a-f]{2}$/.test(cls))) || 'c00', isDead = t.querySelector('span.comhead').textContent.includes(' [dead] '), scoreEl = t.querySelector('span.score'), score = scoreEl ? scoreEl.textContent : ''; @@ -462,7 +468,7 @@ class HNComments { c.el = commentEl; - tagImageEl.src = chrome.extension.getURL('/images/tag.svg'); + tagImageEl.src = chrome.runtime.getURL('/images/tag.svg'); commentEl.id = c.id; commentEl.classList.add(`level-${oddOrEven}`); @@ -907,27 +913,105 @@ var HN = { $('head').append(''); }, + /* + * boot.js hides the page at document_start by putting .hnes-pending on ; + * dropping it here is what reveals the finished rewrite. The stylesheet also + * reveals the page on a timer, so a throw before this point costs the user some + * styling rather than a blank Hacker News. + */ + reveal: function() { + document.documentElement.classList.remove('hnes-pending'); + }, + + /* + * The nav's cycling preference toggles. Each descriptor is the whole + * definition of one toggle: values[0] is the unset state and clears the + * attribute, so "which values are real" is derived from the list rather + * than restated as a condition somewhere else. Adding a mode is one entry + * in `values` plus the matching CSS block — and the same list in boot.js, + * which runs as a separate content script and cannot read this one. + * + * theme: auto -> light -> dark. 'auto' lets prefers-color-scheme decide; + * the explicit modes pin color-scheme, which is what the + * stylesheet's light-dark() tokens resolve against. + * view: comfortable -> compact -> flow. compact shrinks the scale, flow + * drops the card chrome and keeps the type readable. + */ + MODES: [ + { key: 'hnesTheme', attr: 'data-hnes-theme', label: 'theme', + title: 'Switch colour theme', + values: ['auto', 'light', 'dark'] }, + { key: 'hnesDensity', attr: 'data-hnes-density', label: 'view', + title: 'Switch row density', + values: ['comfortable', 'compact', 'flow'] } + ], + + applyMode: function(spec, value) { + var root = document.documentElement; + if (spec.values.indexOf(value) > 0) root.setAttribute(spec.attr, value); + else root.removeAttribute(spec.attr); + }, + + /* + * boot.js already applied both stored values before first paint, so the only + * job on load is labelling. One storage read covers every toggle: separate + * reads resolve in separate tasks, which cost an extra round trip and leave + * the toggles' left-to-right order up to whichever callback lands first. + */ + initModeToggles: function() { + var nav = $('#top-navigation .nav-links').first(); + if (!nav.length) return; + + chrome.storage.local.get(HN.MODES.map(function(spec) { return spec.key; }), function(items) { + HN.MODES.forEach(function(spec) { + // Index rather than name as state — the name is one lookup away and + // values[0] is the fallback for anything unset or unrecognised. + var i = Math.max(spec.values.indexOf(items[spec.key]), 0), + link = $('').attr('href', 'javascript:void(0)').attr('title', spec.title), + wrap = $('').addClass('hnes-nav-toggle').text('|').append(link); + + link.text(spec.label + ': ' + spec.values[i]); + link.click(function() { + i = (i + 1) % spec.values.length; + HN.applyMode(spec, spec.values[i]); + link.text(spec.label + ': ' + spec.values[i]); + HN.setLocalStorage(spec.key, spec.values[i]); + }); + + // Appended here so a toggle never appears unlabelled and inert. + nav.append(wrap); + }); + }); + }, + + /* + * These used to proxy to the background page's localStorage over sendMessage. + * Content scripts can reach chrome.storage.local directly, so the proxy is gone. + * (hckrnews.com still issues one read per list item — that is now a direct + * storage call rather than a message round trip, but it should be batched + * the way HNComments.loadMeta already does.) + * + * Keys and values are still coerced to strings because that is what localStorage + * did implicitly and the call sites depend on it: results are handed to JSON.parse, + * and 'update_profile' is compared against the literal string "false". + */ getLocalStorage: function(key, callback) { - chrome.runtime.sendMessage({ - method: "getLocalStorage", - key: key - }, callback); + var name = String(key); + chrome.storage.local.get(name, function(items) { + callback({ data: items[name] }); + }); }, setLocalStorage: function(key, value) { - chrome.runtime.sendMessage( - { method: "setLocalStorage", - key: key, - value: value }, - function(response) { - }); + var item = {}; + item[String(key)] = String(value); + chrome.storage.local.set(item); }, getUserData: function(usernames, callback) { - chrome.runtime.sendMessage({ - method: "getUserData", - usernames: usernames - }, callback); + chrome.storage.local.get(usernames.map(String), function(items) { + callback({ data: items }); + }); }, doLogin: function() { @@ -1291,7 +1375,9 @@ var HN = { var author_els = document.querySelectorAll('.author a'); var usernames = Array.from(author_els).map( x => x.textContent ); - HN.getUserData(usernames, response => { + // Threads repeat authors heavily; the loop below still needs the + // index-aligned list, but the storage read only needs each name once. + HN.getUserData([...new Set(usernames)], response => { if (!response) return; var userData = response.data; for (var i = 0; i < author_els.length; i++) { @@ -1300,30 +1386,19 @@ var HN = { userInfo = userData[name]; if (userInfo) { - if (typeof userInfo === "number") { - //Convert the legacy format. - // Upvotes used to be saved in localStorage as (for example) etcet: '1', but are now etcet: '{"votes": 1}'. - // This change in format was made so that tag information can be saved in the same location; - // i.e. it will soon be saved as etcet: '{"votes": 1, "tag": "Creator of HNES"}'. - // - // The conversion only needs to be done here, since this executes on page load, - // which means that whatever username you see will have undergone the conversion to the new format. - userInfo = {'votes': userInfo}; - HN.setLocalStorage(name, JSON.stringify(userInfo)); - console.log('Converted legacy format for user', name); + // The bare-number legacy format is converted once during the MV2 + // storage migration (normalizeLegacyValue in background.js), so + // everything arriving here is already '{"votes":n,"tag":…}'. + var info; + try { + info = JSON.parse(userInfo); } - else { - var info; - try { - info = JSON.parse(userInfo); - } - catch (e) { - info = {} - } - // display user tag and score - if (info.tag) HN.displayUserTag(author_el, info.tag || ''); - if (info.votes) HN.displayUserScore(author_el, info.votes); + catch (e) { + info = {} } + // display user tag and score + if (info.tag) HN.displayUserTag(author_el, info.tag || ''); + if (info.votes) HN.displayUserScore(author_el, info.votes); } }; }); @@ -1734,11 +1809,17 @@ var HN = { }, setTopColor: function(){ - var topcolor = document.getElementById("header").children[0].getAttribute("bgcolor"); - if(topcolor.toLowerCase() != '#ff6600') { + // HN tints the header on special days. The dropdowns no longer follow it — + // they are menu surfaces floating over the page now, not extensions of the + // header, and inheriting the tint is what made them read as orange smears. + // (The old .nav-drop-down a:hover rule was a no-op anyway; jQuery cannot + // set styles on a pseudo-class.) + var header = document.getElementById("header"), + headerCell = header && header.children[0], + topcolor = headerCell && headerCell.getAttribute("bgcolor"); + + if (topcolor && topcolor.toLowerCase() != '#ff6600') { $('#header').css('background-color', topcolor); - $('.nav-drop-down').css('background-color', topcolor); - $('.nav-drop-down a:hover').css('background-color', topcolor); } }, @@ -1894,7 +1975,7 @@ var HN = { //show new comment count on hckrnews.com if (window.location.host == "hckrnews.com") { $('ul.entries li').each(function() { - chrome.runtime.sendMessage({method: "getLocalStorage", key: Number($(this).attr('id'))}, function(response) { + HN.getLocalStorage($(this).attr('id'), function(response) { if (response.data != undefined) { var data = JSON.parse(response.data); var id = data.id; @@ -1940,6 +2021,7 @@ else { }); } - $('body').css('visibility', 'visible'); + HN.initModeToggles(); + HN.reveal(); }); } diff --git a/manifest.json b/manifest.json index 6852d78..306b941 100644 --- a/manifest.json +++ b/manifest.json @@ -1,15 +1,18 @@ { "name": "Hacker News Enhancement Suite", "short_name": "HNES", - "version": "1.6.0.3", + "version": "2.0.0", "description": "Hacker News Enhanced.", - "manifest_version": 2, + "manifest_version": 3, + "minimum_chrome_version": "123", "background": { + "service_worker": "background.js", "scripts": ["background.js"] }, "permissions": [ "storage", - "unlimitedStorage" + "unlimitedStorage", + "offscreen" ], "icons": { "16" : "images/icon-16.png", @@ -19,45 +22,39 @@ "content_scripts": [ { "run_at": "document_start", "css": [ "style.css" ], + "js": [ "js/boot.js" ], "matches": [ - "http://news.ycombinator.com/*", "https://news.ycombinator.com/*", - "http://news.ycombinator.net/*", - "https://news.ycombinator.net/*", - "http://hackerne.ws/*", - "https://hackerne.ws/*", - "http://news.ycombinator.org/*", - "https://news.ycombinator.org/*"] + "https://hackerne.ws/*"] }, { "run_at": "document_end", - "all_frames": true, - "css": [ "style.css" ], "js": [ "js/jquery-3.2.1.min.js", "js/linkify/jquery.linkify-1.0.js", "js/linkify/plugins/jquery.linkify-1.0-twitter.js", "js/hn.js"], "matches": [ - "http://news.ycombinator.com/*", "https://news.ycombinator.com/*", - "http://news.ycombinator.net/*", - "https://news.ycombinator.net/*", - "http://hackerne.ws/*", - "https://hackerne.ws/*", - "http://news.ycombinator.org/*", - "https://news.ycombinator.org/*"] + "https://hackerne.ws/*"] }, { - "matches": ["http://hckrnews.com/*"], + "matches": ["https://hckrnews.com/*"], "run_at": "document_end", "js": ["js/jquery-3.2.1.min.js", "js/hn.js"] } ], "web_accessible_resources": [ - "images/spin.gif", - "images/unvote.gif", - "images/tag.svg", - "templates/comment.html" + { + "resources": [ + "images/spin.gif", + "images/unvote.gif", + "images/tag.svg" + ], + "matches": [ + "https://news.ycombinator.com/*", + "https://hackerne.ws/*" + ] + } ] } diff --git a/offscreen.html b/offscreen.html new file mode 100644 index 0000000..5a2ab20 --- /dev/null +++ b/offscreen.html @@ -0,0 +1,4 @@ + + +HNES storage migration + diff --git a/offscreen.js b/offscreen.js new file mode 100644 index 0000000..096ffc5 --- /dev/null +++ b/offscreen.js @@ -0,0 +1,18 @@ +/* + * Runs only during the one-time MV2 -> MV3 storage migration. A service worker has no + * localStorage, but this document shares the extension origin and can still read the + * store the MV2 background page wrote. + */ +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (!message || message.target !== 'offscreen') return; + if (message.method !== 'readLegacyStorage') return; + + // Same index walk as snapshotLocalStorage() in background.js; duplicated + // because this document has no way to import from the worker. + const out = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + out[key] = localStorage.getItem(key); + } + sendResponse(out); +}); diff --git a/style.css b/style.css index 884b0c0..39d22ad 100644 --- a/style.css +++ b/style.css @@ -6,939 +6,1343 @@ * HN+ for Chrome v1.5 * by @jarques * +* --------------------------------------------------------------------------- +* Every colour goes through a token in :root. Light and dark values are paired +* in a single light-dark() declaration, so a theme change is one edit, not two +* palettes kept in sync by hand. The manual override works by flipping +* color-scheme, which is what light-dark() resolves against. +* --------------------------------------------------------------------------- */ -body { - /* hide content flash */ +:root { + color-scheme: light dark; + + /* + * Two oranges, deliberately. --hnes-brand paints large surfaces (header, + * buttons) and is burnt so it does not glare; --hnes-orange is the accent used + * for text and rules, and has to lighten in dark mode to stay legible against + * a dark background. HN's #ff6600 was doing both jobs and doing neither well: + * as a surface it vibrated against white text, and as accent text on white it + * only reached about 3:1. + */ + --hnes-brand: light-dark(#ab470a, #8f3b08); + --hnes-orange: light-dark(#bd4f0d, #ff8f45); + /* Warm off-white rather than pure white — full white on saturated orange is + the "too strong" pairing that makes the header feel like it is buzzing. */ + --hnes-orange-ink: #fff3e9; + /* Header ink at two weights plus the hover wash. These were spelled out as + raw rgba() in six places, which meant the header could not be retinted + from the token block the way everything else can. */ + --hnes-header-ink: rgba(255, 243, 233, .92); + --hnes-header-ink-dim: rgba(255, 243, 233, .82); + --hnes-header-hover: rgba(0, 0, 0, .16); + + /* surfaces */ + --hnes-bg: light-dark(#f6f6ef, #15150f); + --hnes-surface: light-dark(#fffffd, #1d1d16); + --hnes-surface-alt: light-dark(#eeeee4, #22221a); + --hnes-surface-hi: light-dark(#e4e4d6, #2b2b21); + + /* text */ + --hnes-fg: light-dark(#1b1b19, #e8e8de); + --hnes-fg-muted: light-dark(#6a6a63, #9a9a8e); + --hnes-fg-subtle: light-dark(#95958c, #6f6f66); + --hnes-fg-strong: light-dark(#000000, #ffffff); + + /* links */ + --hnes-link: light-dark(#1b1b19, #ddddd2); + --hnes-link-hover: var(--hnes-orange); + --hnes-visited: light-dark(#6a6a63, #8d8d82); + + /* lines */ + --hnes-border: light-dark(#dedad0, #33332a); + --hnes-spine: var(--hnes-border); + --hnes-spine-active: var(--hnes-orange); + + /* semantic */ + --hnes-danger: light-dark(#c0392b, #ff7a6e); + --hnes-new-user: light-dark(#2f8f4e, #57c47c); + --hnes-new-comment: var(--hnes-orange); + --hnes-new-parent: light-dark(#bc9b85, #7a6154); + --hnes-selection: light-dark(#ffd9bf, #5a3410); + + /* + * HN's comment fade scale. news.css ships .c00 (a normal comment) as pure + * black and fades downvoted comments toward white, which only works on a + * light background — on a dark one .c00 is unreadable and the scale runs + * backwards, leaving the most-downvoted comments the most prominent. These + * pairs keep HN's light values and mirror the fade for dark, so in both + * themes the scale runs from full contrast toward the page background. + */ + --hnes-c00: var(--hnes-fg); + --hnes-c5a: light-dark(#5a5a5a, #c6c6bc); + --hnes-c73: light-dark(#737373, #adada4); + --hnes-c82: light-dark(#828282, #9b9b92); + --hnes-c88: light-dark(#888888, #909087); + --hnes-c9c: light-dark(#9c9c9c, #81817a); + --hnes-cae: light-dark(#aeaeae, #73736c); + --hnes-cbe: light-dark(#bebebe, #686861); + --hnes-cce: light-dark(#cecece, #5c5c56); + --hnes-cdd: light-dark(#dddddd, #53534e); + + /* heat scale on comment counts */ + --hnes-heat-0: var(--hnes-fg); + --hnes-heat-1: light-dark(#8a5a00, #c8a25a); + --hnes-heat-2: light-dark(#c25e00, #e2913f); + --hnes-heat-3: var(--hnes-orange); + + /* type */ + --hnes-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + --hnes-font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, + "Liberation Mono", monospace; + --hnes-size: 15px; + /* Comment bodies are the longest-form reading on the site, so they get the + full body size rather than the 13px the old stylesheet used. */ + --hnes-size-comment: var(--hnes-size); + --hnes-size-sm: 13px; + --hnes-size-xs: 12px; + --hnes-size-title: 17px; + --hnes-size-nav: 13.5px; + --hnes-leading: 1.6; + + /* space + shape */ + --hnes-gap-1: 4px; + --hnes-gap-2: 8px; + --hnes-gap-3: 12px; + --hnes-gap-4: 16px; + --hnes-gap-5: 24px; + --hnes-radius: 6px; + --hnes-radius-pill: 999px; + + /* Comment header gutter. The vote column and the collapse control are the two + fixed-width slots to the left of every comment header; the body indents to + clear both, so all three sizes have to move together. */ + --hnes-vote-col: 12px; + /* + * Two tokens, because this size answers to two owners with different rules. + * A view mode may ask for a tighter gutter (-density), but consumers read + * --hnes-control, which never drops below 24px: it is also the width and + * height of the collapse button and the box holding the two stacked vote + * arrows. Those arrows need 23px (10 + 3 gap + 10) or they spill out of a + * collapsed comment — the bug documented at .voteblock below — and 24px is + * the WCAG 2.2 SC 2.5.8 floor for the most-clicked control in a comment. + */ + --hnes-control-density: 26px; + --hnes-control: max(24px, var(--hnes-control-density)); + + /* + * Density. Everything that contributes to vertical rhythm reads from these + * rather than the raw gaps, so a view mode is a token block rather than a + * hunt through the rules below. Defaults here are the "comfortable" mode. + */ + --hnes-row-pad-y: var(--hnes-gap-2); /* index row, top and bottom */ + --hnes-row-title: var(--hnes-size-title); + --hnes-com-pad-y: var(--hnes-gap-2); /* comment card */ + --hnes-com-pad-x: var(--hnes-gap-3); + --hnes-com-gap: var(--hnes-gap-3); /* between sibling comments */ + --hnes-com-lead: 1.65; + --hnes-com-para: var(--hnes-gap-3); /* between paragraphs in a comment */ + --hnes-com-indent: var(--hnes-gap-3); /* reply indent, charged twice: margin + padding */ + /* Surfaces a mode may switch off wholesale, so "no chrome" is a value here + rather than a pile of override rules further down. */ + --hnes-com-fill: var(--hnes-surface); + --hnes-com-fill-alt: var(--hnes-surface-alt); + --hnes-row-zebra: var(--hnes-surface-alt); + --hnes-row-rule: transparent; + + /* layout */ + --hnes-page-pad: 20px; + /* The one place full-width is not applied: prose past roughly this many + characters per line gets measurably harder to track back to the next line. + Raise or drop this if you want comment text edge to edge as well. */ + --hnes-measure: 88ch; + + /* elevation */ + --hnes-shadow-menu: light-dark(0 8px 24px rgba(0,0,0,.16), 0 8px 24px rgba(0,0,0,.6)); + + /* index gutter columns */ + --hnes-col-comments: 76px; + --hnes-col-score: 60px; + +} + +/* Manual override from the theme toggle; 'auto' removes the attribute entirely. */ +:root[data-hnes-theme="light"] { color-scheme: only light; } +:root[data-hnes-theme="dark"] { color-scheme: only dark; } + + +/* --------------------------------------------------------------------------- + View modes, set by the density toggle; 'comfortable' removes the attribute + and falls back to the :root defaults. + + Two different levers, deliberately. `compact` shrinks the scale — smaller + type, tighter leading, less padding — and buys the most rows per screen at + some cost to reading long comments. `flow` keeps the scale readable and + removes the chrome instead: no card fills, no card padding, no zebra. On a + comment page nearly all the wasted height is decoration rather than text, so + flow lands close to compact's density without shrinking a single glyph. + + Both blocks are wrapped in :where() so they weigh (0,0,1) — the same as the + :root they override, winning only on source order. That is what lets the + responsive blocks at the foot of the file retune a density-owned token + without knowing density exists; at plain [data-hnes-density] specificity + every breakpoint would have to restate its values behind a matching + selector, and forgetting to would fail silently. + --------------------------------------------------------------------------- */ + +:root:where([data-hnes-density="compact"]) { + --hnes-row-pad-y: 3px; + --hnes-row-title: 14.5px; + --hnes-com-pad-y: var(--hnes-gap-1); + --hnes-com-pad-x: 10px; + --hnes-com-gap: 6px; + --hnes-com-lead: 1.45; + --hnes-com-para: 7px; + --hnes-com-indent: var(--hnes-gap-2); + --hnes-size-comment: 14px; + /* Asks for a tighter gutter; --hnes-control's floor decides how much of it + the collapse button and the vote arrows can actually give up. */ + --hnes-control-density: 20px; +} + +/* No --hnes-row-title here on purpose: flow's whole claim is that it removes + chrome rather than shrinking type, and pinning a literal would also outrank + the responsive steps below — leaving flow's titles *larger* than + comfortable's under 560px. */ +:root:where([data-hnes-density="flow"]) { + --hnes-row-pad-y: 5px; + --hnes-com-pad-y: 0px; + --hnes-com-pad-x: 0px; + /* With the fills gone this gap is the only thing separating two comments, + so it stays generous where compact would cut it. */ + --hnes-com-gap: var(--hnes-gap-4); + --hnes-com-para: 10px; + --hnes-com-indent: 10px; + --hnes-control-density: 22px; + /* Chrome off: the thread spine and the gap already carry separation, and + with zero card padding the fills would bleed edge to edge and read as + noise. The zebra was doing the row-tracking work on the index, so a + hairline takes over (plus the hover tint below). */ + --hnes-com-fill: transparent; + --hnes-com-fill-alt: transparent; + --hnes-row-zebra: transparent; + --hnes-row-rule: var(--hnes-border); +} + +/* The one flow rule that isn't a value substitution: a hover tint is additive, + and tokenising it would either kill the zebra on hover in the other modes or + give them a tint they never asked for. :not(.on_story) so the current story + keeps its own highlight. */ +:root[data-hnes-density="flow"] #index-body #content tr:hover:not(.on_story) { + background-color: var(--hnes-surface-alt) !important; +} + + +/* =========================================================================== + Boot / anti-flash + HNES rewrites the page wholesale, so the raw markup is hidden until it is + done. The class is added by boot.js at document_start and removed once the + rewrite finishes, which means a page where the content script never runs is + never hidden in the first place. The animation is a failsafe: if the rewrite + throws part way through, the page reveals itself anyway rather than leaving + a permanently blank Hacker News. + =========================================================================== */ + +html.hnes-pending body { visibility: hidden; - margin: 0; -} -/*ignore
elements*/ -/*body center { - text-align: left; -}*/ -form center { - text-align: left; -} -body > center > table { - width: 100%; - margin: 0 auto; - padding-bottom: 20px; -} -body center table table { - box-shadow: none; - padding: 0; - background: none + animation: hnes-failsafe-reveal 1ms linear 2s forwards; } -.hnes-comment .score { - display: none; +@keyframes hnes-failsafe-reveal { + to { visibility: visible; } } -.hnes-comment .score.visible { - display: inline-block; -} -/*header color*/ -#header, -.nav-drop-down { - background-color: #f60; -} -/*content color*/ -#content { - background-color: #f6f6ef !important; -} -/*header padding*/ -#header table td { - padding: 5px; -} -/*icon width*/ -#header table td:first-child { - width: 18px; -} -/*user nav align*/ -#header table td:nth-child(3) { - text-align: right; -} -/*content padding*/ -#content > td { - padding: 21px 8px 21px 8px; -} -body#threads-body #content > td { - padding: 21px 8px 0px 8px; +/* =========================================================================== + Base + + Note on the `html body` prefixes throughout this file: a content script's + stylesheet is inserted ahead of the page's own, so at equal specificity HN's + news.css wins. It sets body, td, a:link, a:visited, .title, .subtext, + .comhead and .default directly, so anything competing with those needs to + out-specify them rather than merely restate them. + =========================================================================== */ + +html body { + margin: 0; + background: var(--hnes-bg); + color: var(--hnes-fg); + font-family: var(--hnes-font); + font-size: var(--hnes-size); + line-height: var(--hnes-leading); } -body#index-body #content > td { - padding: 21px 0px; + +/* + * Deliberately no -webkit-font-smoothing: antialiased. It thins glyph stems, + * which costs little on a light background but makes light-on-dark text look + * washed out and weak — the exact thing it is usually added to prevent. + */ + +/* news.css puts Verdana 10pt #828282 on every ; let them inherit instead. */ +html body td { + font-family: inherit; + font-size: inherit; + color: inherit; } +/* HN nests tables several deep; only the outermost should carry the surface. */ table, td { - background: none !important; + background: none !important; } -table tr { - position: relative; +table { + border-collapse: collapse; } +table tr, table tr td { position: relative; - padding: 4px 0px 4px 0px; } - -#index-body table table { - margin: 0 auto; +table tr td { + padding: var(--hnes-gap-1) 0; } - -/*highlight every other story line except 'More' link*/ -#index-body #content tr:nth-child(2n), -#jobs-body #content tr:nth-child(2n) { - background-color: #e6e6df !important; +/* `table, td` above already clears every nested background. */ +body center table table { + box-shadow: none; + padding: 0; } -#index-body #content tr, -#index-body #content tr:nth-child(32) { - background-color: #f6f6ef !important; + +/* The page shell: HN ships width="85%"; run edge to edge instead. */ +body > center > table { + width: 100%; + margin: 0 auto; + padding-bottom: var(--hnes-gap-5); } -#index-body #content tr.on_story { - border: 1px solid black; - background-color: #c6c6bf !important; +#header > td { + padding-left: var(--hnes-page-pad); + padding-right: var(--hnes-page-pad); } -.blurb { - margin: 0 1em; - text-align: center; -} +/* news.css pins #hnmain to min-width:796px and only releases it between + 300-750px, so 751-796px scrolled horizontally with no way for the responsive + rules below to take effect. */ +html body #hnmain { min-width: 0; } -/*position comment and score tally at the top even if story line is multi-line*/ -#index-body .score, #index-body .comments { - position: absolute; - height: 100%; - top: 0px; - padding-top: 7px; - padding-right: 3px; - right: 0px; -} -.comments { - left: 0; -} -.comments:hover { - color: #f60; -} -/* show a fallback if parsing comment number fails */ -.comments:empty::before { - content: '💬'; -} +form center { text-align: left; } -/*spacing for comment and score tally*/ -#index-body #content table { /* Firefox */ - width: 100%; - max-width: 1170px; -} -#index-body #content table td:first-child { - width: 80px; - max-width: 80px; /* Firefox */ - text-align: right; -} -#index-body #content table td:nth-child(2) { - width: 64px; - max-width: 64px; /* Firefox */ - text-align: right; -} +a { text-decoration: none; } +html body a:link { color: var(--hnes-link); } +html body a:visited { color: var(--hnes-visited); } +html body a:hover { color: var(--hnes-link-hover); } -#index-body #content table td.title { /* Firefox */ - width: auto; +#content a:hover, +#content a:visited:hover { + color: var(--hnes-link-hover) !important; } -/*spacing between story lines*/ -#index-body #content tr td { - padding-top: 5px; - padding-bottom: 5px; +::selection { + background: var(--hnes-selection); + color: var(--hnes-fg-strong); } -#index-body .title { - padding-left: 8px; - padding-right: 18px; + +/* HN separates lines with
; HNES lays out with real block elements. */ +br { display: none; } + +code, pre { + font-family: var(--hnes-font-mono); + font-size: var(--hnes-size-sm); } -/*comments tally hover*/ -.hover-comments-score { - cursor: pointer; - text-decoration: underline; + +/* Wrap indented text so it doesn't force a horizontal scrollbar. */ +html body pre { + white-space: pre-wrap; + word-break: break-word; + margin-left: 1em; + padding: var(--hnes-gap-2) var(--hnes-gap-3); + background: var(--hnes-surface-alt); + border-radius: var(--hnes-radius); + overflow-x: auto; } -/*new comments in black*/ -.newcomments { - color: #000; + +#content { + background-color: var(--hnes-bg) !important; } -a.comments:hover { - text-decoration: underline; +#content > td { + padding: var(--hnes-gap-5) var(--hnes-page-pad); } +body#threads-body #content > td { padding-bottom: 0; } +body#index-body #content > td { padding-left: 0; padding-right: 0; } -/*karma score in the user nav*/ -#my-karma { - padding-left: 8px; - color: #f6f6ef; -} -/*navigation links*/ -.nav-links a { - color: #222; - padding-left: 8px; - padding-right: 8px; -} -/*text shadow on hover*/ -.nav-links a:hover, -.more-arrow:hover, -.more-arrow:hover a, -.more-arrow a.active, -.nav-active-link { - color: #fff !important; -} -/*allow an active link after 'more' dropdown to wrap down*/ -.new-active-link { + +/* =========================================================================== + Header + navigation + =========================================================================== */ + +#header { background-color: var(--hnes-brand); } +#header table td { padding: var(--hnes-gap-2) 0; } +#header table td:first-child { width: 18px; padding-right: var(--hnes-gap-3); } +#header table td:nth-child(3) { text-align: right; } + +#top-navigation { display: block; } + +/* Everything in the header sits on the brand surface, so it all takes header + ink — otherwise stragglers like the login link fall back to body link colour + and render near-black on orange. */ +html body #header a:link, +html body #header a:visited { color: var(--hnes-header-ink); } +html body #header a:hover { color: var(--hnes-orange-ink); } + +/* One pill shape for every header link — section tabs, the login link and the + theme toggle. The .nav-links rule below adds only what differs. */ +html body #header .pagetop > a, +html body #header td:nth-child(3) a, +html body .nav-links > span > a, +html body .nav-links > span > a:link, +html body .nav-links > span > a:visited { display: inline-block; + font-weight: 600; + font-size: var(--hnes-size-nav); + padding: 7px 12px; + border-radius: var(--hnes-radius-pill); + transition: background-color .12s ease, color .12s ease; } -/*center alert on jobs page*/ -#content > td > center > table { - text-align: center; - margin: 0 auto; +html body #header td:nth-child(3) a:hover { background: var(--hnes-header-hover); } + +/* news.css sets .pagetop{font-size:10pt} at the same specificity, so an + unprefixed rule here silently loses. */ +html body .pagetop { font-size: var(--hnes-size-sm); } +.pagetop b a { + display: none; + font-size: var(--hnes-size-xs); } -/*hide text-select cursor on | seperators*/ -.nav-links span { - cursor: default; + +/* + * The nav is built as
top| per item — the separator is a + * bare text node after the link, with no element to hook. Zeroing the wrapper's + * font-size collapses those pipes; every real child restores its own size. That + * is why the sizes below are set explicitly rather than inherited. + */ +.nav-links > span { font-size: 0; } + +/* html body prefix: the base `html body a:link` rule above out-specifies a + plain .nav-links descendant selector, so these need the same head start. */ +html body .nav-links > span > a, +html body .nav-links > span > a:link, +html body .nav-links > span > a:visited { + font-weight: 600; + letter-spacing: .01em; + line-height: 1; + color: var(--hnes-header-ink); + margin-right: 2px; +} +html body .nav-links > span > a:hover, +html body .more-arrow:hover > a { + color: var(--hnes-orange-ink) !important; + background: var(--hnes-header-hover); +} +/* The current section reads as a filled tab rather than just brighter text. */ +html body .nav-links > span > a.nav-active-link, +html body .more-arrow > a.active { + color: var(--hnes-brand) !important; + background: var(--hnes-orange-ink); + font-weight: 700; + box-shadow: 0 1px 4px rgba(0, 0, 0, .2); +} +.nav-links span { cursor: default; } +.new-active-link { display: inline-block; } + +#my-karma { + font-size: var(--hnes-size-nav); + font-weight: 600; + padding-left: var(--hnes-gap-1); + color: var(--hnes-header-ink-dim); } -/*hand cursor on more arrow*/ -.more-arrow { - cursor: pointer !important; + +.more-arrow { cursor: pointer !important; } +.more-arrow > a::after { + content: ''; + display: inline-block; + margin-left: 5px; + vertical-align: middle; + border-left: 3.5px solid transparent; + border-right: 3.5px solid transparent; + border-top: 4px solid currentColor; } -/*down arrow after more links*/ -.more-arrow:after { - position: relative; - top: -2px; - left: -3px; - font-shadow: none; - font-size: 8px; - content: '▼'; - padding-right: 5px; -} -/*toggleable drop downs on top of everything*/ + +/* + * The menus used to inherit the header's orange, which read as a smear of the + * header rather than a surface floating above the page. + */ .nav-drop-down { z-index: 9999; display: none; position: absolute; - padding-top: 10px; -} -.nav-drop-down a { + margin-top: var(--hnes-gap-1); + padding: var(--hnes-gap-1); + min-width: 148px; + background: var(--hnes-surface); + border: 1px solid var(--hnes-border); + border-radius: var(--hnes-radius); + box-shadow: var(--hnes-shadow-menu); +} +html body .nav-drop-down a { display: block; - padding-left: 8px; + font-size: var(--hnes-size-sm); + color: var(--hnes-fg) !important; + padding: 6px var(--hnes-gap-3); + border-radius: var(--hnes-radius); text-align: left; - padding-top: 4px; - padding-bottom: 4px; -} -/*other main nav links*/ -#nav-others { - padding-right: 16px; -} -#nav-others a { - width: 100%; -} -/*other user nav links*/ -#user-hidden { - right: 18px; + white-space: nowrap; } -#user-hidden a { - padding-right: 28px; +html body .nav-drop-down a:hover { + color: var(--hnes-orange) !important; + background: var(--hnes-surface-alt); } -/*make logout look a little different*/ +#nav-others a { width: 100%; box-sizing: border-box; } +#user-hidden { right: var(--hnes-page-pad); } #user-logout { - padding-top: 8px; - padding-bottom: 8px; -} -/* More link */ -#more { - text-align: left !important; -} -#more a { - color: #f60; -} -#more a:hover { - text-decoration: underline; + margin-top: var(--hnes-gap-1); + padding-top: var(--hnes-gap-2); + border-top: 1px solid var(--hnes-border); } -body#item-body .title { - width: 100%; -} -/*story title*/ -.title { - color: #242222; - font-size: 16px !important; - line-height: 18px; - font-weight: medium; -} +/* A toggle is a direct child span of .nav-links, so the pill and font-size:0 + rules above already cover it; only the cursor needs saying. */ +.hnes-nav-toggle > a { cursor: pointer; } -/*make self posts text black*/ -.item-header tr:nth-child(3) td { - color: #000 !important; -} +.mourning { border-top: 5px solid var(--hnes-fg-strong); } -a:link { - color: #202020; -} -/*submit/add comment button*/ -input[type="submit"] { - font-size: 13px; - color: white; - background: #f60; - padding: 5px 10px; - text-transform: capitalize; - border: 1px solid #666; - border-radius: 2px; -} -input[type="submit"]:hover { - cursor: pointer; - background: #; -} -/*format add comment button on its own line*/ -input[value="add comment"] { - display: block; -} -input[type="text"] { - font-size: 12px !important; -} -font[color="#ff6600"] { - display: none; -} +/* =========================================================================== + Index pages + =========================================================================== */ -.title a.on_story { - border-left: 2px solid #3986f8 !important; -} -.submitter a:hover, -.title a:hover { - color: #f60; -} +#index-body table table { margin: 0 auto; } -/*user name in info subtext*/ -.submitter { - margin-left: 4px; -} -.subtext, .submitter { - color: #b8b8b8; - font-size: 11px !important; - padding-bottom: 15px; -} +#index-body #content table { width: 100%; } -.subtext a, .comhead a { - color: #989898 !important +/* Zebra striping, skipping the trailing 'More' row. */ +#index-body #content tr, +#jobs-body #content tr { + background-color: transparent !important; } -.comhead { - color: #000; - font-size: 11px !important; +#index-body #content tr:nth-child(2n), +#jobs-body #content tr:nth-child(2n) { + background-color: var(--hnes-row-zebra) !important; } -div > .comhead { - font-size: 12px !important; +#index-body #content tr.on_story { + background-color: var(--hnes-surface-hi) !important; + box-shadow: inset 3px 0 0 var(--hnes-orange); } - -/*score next to username indicating user upvotes*/ -.hnes-user-score { - color: #444; +#index-body #content tr td { + padding-top: var(--hnes-row-pad-y); + padding-bottom: var(--hnes-row-pad-y); + /* Transparent in every mode but flow, which trades the zebra for a rule. + An inset shadow rather than a border: it is unconditional, and a border + would charge every row a pixel of height in the modes that don't draw it. */ + box-shadow: inset 0 -1px 0 var(--hnes-row-rule); } -.noscore { - display: none; +/* Gutter columns holding the comment count and score. */ +#index-body #content table td:first-child { + width: var(--hnes-col-comments); + max-width: var(--hnes-col-comments); + text-align: right; } - -.nostory, .noreply { - display: none !important; +#index-body #content table td:nth-child(2) { + width: var(--hnes-col-score); + max-width: var(--hnes-col-score); + text-align: right; } - -.hidden { - display: none; +#index-body #content table td.title { width: auto; } +/* Without a cap the vote column absorbs all the slack and squeezes the title, + which only shows up once the viewport is narrow. */ +#index-body #content table td.votelinks { + width: 22px; + max-width: 22px; } -#loading_comments { - margin: 1em; - padding: 1em; - color: #000; - background-color: #FFFFFD -} +/* Pin the tallies to the top so multi-line titles don't drag them down. */ +#index-body .score, +#index-body .comments { + position: absolute; + top: 0; + right: 0; + height: 100%; + /* Absolutely positioned, so it can't inherit the row's padding — track it + manually or the tallies drift off the title baseline in the tighter modes. + The extra pixel is the optical nudge the 9px here always was. */ + padding-top: calc(var(--hnes-row-pad-y) + 1px); + padding-right: var(--hnes-gap-1); + font-variant-numeric: tabular-nums; +} +.comments { left: 0; } +/* Fallback when the comment count can't be parsed out of the row. */ +.comments:empty::before { content: '💬'; } +a.comments:hover { text-decoration: underline; } -/*collapse comment 'button'*/ -.collapse { - padding-right: 6px; +.hover-comments-score { cursor: pointer; -} -.collapse:hover { text-decoration: underline; } -.paren { - color: #c9c9c9 !important; -} -.paren:first-child { - margin-left: 5px; -} -.title:first-child { - padding-left: 5px !important; -} -.pagetop a:hover, -.pagetop a:active { - color: #000; +/* Scoped to the span: HNES also gives nav links a class matching their text, + so an unscoped .newcomments rule styled the /newcomments menu item too. */ +span.newcomments { + color: var(--hnes-fg-strong); + font-weight: 600; } -/*footer links*/ -.yclinks { - display: block; - padding: 10px; - text-align: center; - font-size: 10px; - color: #b8b8b8 !important; -} -.yclinks a { - color: #888 !important; +/* Out-specifies the `html body .title` block below, so the index title tracks + the view mode while item pages keep the full title size. */ +#index-body .title { + padding-left: var(--hnes-gap-3); + padding-right: 18px; + font-size: var(--hnes-row-title) !important; } +.title:first-child { padding-left: 5px !important; } +body#item-body .title { width: 100%; } -#top-navigation { - display: block; -} -.pagetop { - font-size: 10pt; -} -.pagetop b a { - font-size: 11px; - display: none; - color: #a3a3a3 !important +html body .title { + color: var(--hnes-fg); + font-family: var(--hnes-font); + font-size: var(--hnes-size-title) !important; + line-height: 1.35; + font-weight: 500; } -textarea { - border: 1px solid #c6cfd6 !important; - padding: 4px !important; - font-size: 12px !important; -} -form textarea { - background: #fff; - font-size: 14px; - padding: 10px; - height:80px; - overflow: auto; - margin-bottom: 10px; -} -textarea, input { - border-width: 1px; - border-radius: 2px; -} -form textarea:focus, textarea:focus, input:focus, input[name="q"] { - outline: #f60; - border-color: #d73937 !important; +html body .title a:link, +html body .title a:visited { color: var(--hnes-fg); } +.title a.on_story { border-left: 2px solid #3986f8 !important; } + +.link-highlight { + border: 1px dashed var(--hnes-border); + border-radius: var(--hnes-radius); + padding: var(--hnes-gap-1) 27px var(--hnes-gap-1) 7px !important; } -form[action="http://hn.algolia.com/"], -form[action="//hn.algolia.com/"], -input[name="q"] { - width: 270px; +td #more.link-highlight { + border: none; + padding: 5px 18px 5px var(--hnes-gap-2) !important; } -input[name="q"] { - margin-left: 10px; - height: 32px; - padding-left: 10px; - color: #888 !important; + +#more { text-align: left !important; } +#more a { color: var(--hnes-orange); } +#more a:hover { text-decoration: underline; } + +.blurb { + margin: 0 1em; + text-align: center; + color: var(--hnes-fg-muted); } -input[name="q"]:focus { - color: #3b3e40 !important; + +/* Comment-count heat scale. */ +.no-heat { color: var(--hnes-heat-0) !important; } +.mild { color: var(--hnes-heat-1) !important; } +.medium { color: var(--hnes-heat-2) !important; } +.hot { color: var(--hnes-heat-3) !important; } + + +/* =========================================================================== + Subtext / bylines + =========================================================================== */ + +.submitter { margin-left: var(--hnes-gap-1); } +html body .subtext, +html body .subtext td, +html body .submitter { + color: var(--hnes-fg-subtle); + font-family: var(--hnes-font); + font-size: var(--hnes-size-xs) !important; + padding-bottom: var(--hnes-gap-3); } -form[action="http://hn.algolia.com/"], -form[action="//hn.algolia.com/"] { - margin: 0 auto; - color: #fff; +html body .subtext a:link, +html body .subtext a:visited, +html body .comhead a:link, +html body .comhead a:visited { color: var(--hnes-fg-muted) !important; } +html body .subtext a:hover, +html body .comhead a:hover { color: var(--hnes-link-hover) !important; } + +html body .comhead { + color: var(--hnes-fg-muted); + font-family: var(--hnes-font); + font-size: var(--hnes-size-xs) !important; } -html body center table tbody tr td table tbody tr td table { - background: none !important +html body div > .comhead { font-size: var(--hnes-size-sm) !important; } + +html body .default { + color: var(--hnes-fg); + font-family: var(--hnes-font); + font-size: var(--hnes-size-sm); } -br { - display: none; + +.hnes-age, +.hnes-actions { + margin-left: .5em; + font-size: var(--hnes-size-xs); + color: var(--hnes-fg-subtle); } -.default a:link { - color: #000; +.hnes-actions a:after { + content: " | "; + color: var(--hnes-fg-subtle); } +.hnes-actions a:last-child:after { content: ""; } +.hnes-actions:before { content: "[ "; } +.hnes-actions:after { content: " ]"; } +/* Don't render a stray "[ ]" when no actions were populated for the row. */ +.hnes-actions:empty { display: none; } + +.paren { color: var(--hnes-fg-subtle) !important; } +.paren:first-child { margin-left: 5px; } -/*usernames are usually black*/ +/* Usernames: default, brand-new accounts, and the story's author. */ #item-body .comhead a.commenter, #threads-body .comhead a.commenter { - color: #000 !important; + color: var(--hnes-fg) !important; } -/*but new users are green*/ a.new_user, #item-body .comhead a.new_user { - color: #3c963c !important; + color: var(--hnes-new-user) !important; } -/*and the person who posted the story is orange*/ a.original_poster, #item-body .comhead a.original_poster { - color: #f60 !important; + color: var(--hnes-orange) !important; } +a.dead { text-decoration: line-through !important; } -a.dead { - text-decoration: line-through !important; -} +.hnes-user-score { color: var(--hnes-fg-muted); } +.noscore { display: none; } -.comment font{ - display: block; -} -.comment { - display: block; - width: 100%; - max-width: 800px; - line-height: 1.5em; -} -.comment, .dead { - font-size: 13px !important; -} -.comment code { - font-size: 13px !important; - line-height: 20px; -} -.default div { - margin-bottom: 6px !important; -} -.comment p, -.comment > font { - font-size: 13px !important; - margin-top: 6px !important; - margin-bottom: 6px !important; -} -/* Wrap
 tags (indented text) so they don't have an annoying horizontal scrollbar */
-pre {
-  white-space: pre-wrap;
-  margin-left: 1em;
-}
+/* ===========================================================================
+   Comments — HNES renders its own tree and hides HN's
+   =========================================================================== */
 
-/*make sure comment headers are on one line
-  even if comment is shorter than header to avoid bug in new chrome (win only)
- */
-#comment-table {
-  width: 100%;
-}
+#hnmain table.comment-tree { display: none; }
+/* HN's native collapser; HNES supplies its own. */
+.togg { display: none; }
+/* Trim the padding HN's comment rows carry (issue #140). */
+.comtr td { padding: 0; }
 
-.reply_form {
-  width: 300px;
-}
-.reply_form input[type="submit"] {
-  margin: 3px;
-}
-a[href^="reply"]:visited {
-  color: #000
-}
-form input[type="submit"] {
-  display: block;
+#hnes-comments {
+  color: var(--hnes-fg);
+  font-family: var(--hnes-font);
+  font-size: var(--hnes-size-comment);
+  line-height: var(--hnes-com-lead);
 }
+#hnes-comments a,
+#hnes-comments a:visited { color: var(--hnes-link); }
+#hnes-comments .permalink,
+#hnes-comments .permalink:visited { color: var(--hnes-fg-muted); }
+#hnes-comments .parent { display: none; }
 
-#options_bar {
-  position: fixed;
-  bottom: 0;
-  background: #fff;
-  padding: 5px;
-  border-top: 1px solid #c9c9c7;
-  width: 100%;
-}
-td::selection,
-::selection *,
-::selection {
-  background: #dd4b39 !important;
-  color: #fff;
+/*
+ * Thread spine. Each nesting level draws a guide line down the left of its
+ * replies; hovering one lights it up so you can see where a subthread ends.
+ * The :has() rule keeps only the innermost hovered spine lit — without it every
+ * ancestor lights up at once, which is noise rather than signal.
+ */
+#hnes-comments .replies {
+  margin: var(--hnes-com-pad-y) 0 0 var(--hnes-com-indent);
+  padding-left: var(--hnes-com-indent);
+  border-left: 2px solid var(--hnes-spine);
+  transition: border-color .12s ease;
 }
+#hnes-comments .replies:hover { border-left-color: var(--hnes-spine-active); }
+#hnes-comments .replies:has(.replies:hover) { border-left-color: var(--hnes-spine); }
+.replies:empty { display: none; }
 
-/* Submit Page */
-form[action="/r"] table {
-  width: 400px;
-}
-form[action="/r"],
-form[action="/r"] input[type="text"],
-form[action="/r"] textarea {
-  width: 385px;
-  margin: 5px 0;
-}
-form[action="/r"] input[type="submit"] {
-  margin: 0px;
-}
-#submit-overlay {
-  position: absolute;
-  background: white;
-  padding: 20px;
-  z-index: 100;
-  width: 535px;
-  margin: 20px 0 0 150px;
-  display: none;
-  border-radius: 4px;
-}
-#overlay-bg {
-  background: #000;
-  opacity: .6;
-  width: 4000px;
-  height: 4000px;
-  position: fixed;
-  left: 0;
-  top: 0;
-  z-index: 99;
-  display: none;
+@media (prefers-reduced-motion: reduce) {
+  #hnes-comments .replies { transition: none; }
 }
 
-/* HN Heat */
-.no-heat {
-  color: #000 !important;
-}
-.mild {
-  color: #520 !important;
-}
-.medium {
-  color: #a40 !important;
-}
-.hot {
-  color: #f60 !important;
+.hnes-comment {
+  padding: var(--hnes-com-pad-y) var(--hnes-com-pad-x);
+  margin: 0 0 var(--hnes-com-gap) 0;
+  border-radius: var(--hnes-radius);
 }
+.hnes-comment:last-child { margin-bottom: 0; }
 
-#jobs-body table table {
-  padding-left: 7px;
-  margin: 0 auto;
+/*
+ * Alternating level tints are kept, but far quieter than before — the spine now
+ * carries the nesting information, so the fills only need to separate adjacent
+ * comments rather than encode depth.
+ */
+.hnes-comment.level-odd,
+#hnes-comments.nolevels .hnes-comment {
+  background-color: var(--hnes-com-fill);
 }
-#jobs-body #content>td {
-  margin: 0;
-  padding: 0;
+.hnes-comment.level-even {
+  background-color: var(--hnes-com-fill-alt);
 }
 
-.poll-graph {
-  background-color: #f60;
-  display: block;
-  height: 32px;
+.hnes-comment header {
+  display: flex;
+  align-items: center;
+  /* The gutter slots and the age are fixed-width, so a nowrap row has a
+     min-content width the viewport cannot undercut. Deep nesting then adds
+     indentation on top of that and drags HN's auto-layout table wider than the
+     screen. Wrapping lets the row give way instead. */
+  flex-wrap: wrap;
+  row-gap: 2px;
+  /* gap alone; the children used to add margin-right on top of it, so the
+     real spacing was 8px while both rules read as 4px. */
+  gap: var(--hnes-gap-2);
+  font-size: var(--hnes-size-xs);
+  color: var(--hnes-fg-muted);
+}
+/* Author block: username, karma and tag read as one unit, so lay them out
+   together instead of relying on inline whitespace and baseline nudges. */
+.hnes-comment header .author {
+  display: inline-flex;
+  align-items: center;
+  gap: var(--hnes-gap-1);
+  min-width: 0;
+}
+.hnes-comment header .author > a { font-weight: 600; }
+.hnes-comment header .age { white-space: nowrap; }
+
+.hnes-comment a:hover { text-decoration: underline; }
+/* The header row is now as tall as the vote column, so the username no longer
+   sits on the card's own padding — give a collapsed comment its space back. */
+.hnes-comment.collapsed { padding-top: var(--hnes-gap-1); padding-bottom: var(--hnes-gap-1); }
+
+/* Indent past both gutter slots so the body starts under the username. */
+.hnes-comment section.body {
+  margin-left: calc(var(--hnes-vote-col) + var(--hnes-control) + var(--hnes-gap-2) * 2);
 }
+.hnes-comment.collapsed > section { display: none; }
 
-.underlined {
-  text-decoration: underline !important;
-}
-.no-font-size {
-  font-size: 0;
-}
-.link-highlight {
-  border: 1px dashed #aaa;
-  padding: 4px 27px 4px 7px !important;
+.hnes-comment .text {
+  max-width: var(--hnes-measure);
+  /* `anywhere`, not `break-word`: both break a long URL that would otherwise
+     overflow, but only `anywhere` shrinks the element's min-content width.
+     With `break-word` a bare link kept HN's auto-layout table wider than the
+     viewport — the table sized to the unbroken URL and the break never
+     happened. */
+  overflow-wrap: anywhere;
 }
+.hnes-comment .text a { text-decoration: underline; }
 
-td #more.link-highlight {
-  border: none;
-  padding: 5px 18px 5px 8px !important;
-}
+/*
+ * extractCommentParts hands .text bare 

nodes, so paragraph rhythm was + * whatever the UA default 1em happened to be — untouched by any token and the + * largest single contributor to a thread's scroll length. The leading

is + * synthesised even when the comment opens with a block element, so an empty one + * is normal and must not bill for a margin. + */ +.hnes-comment .text p { margin: var(--hnes-com-para) 0; } +.hnes-comment .text > p:first-child { margin-top: 0; } +.hnes-comment .text p:empty { display: none; } -#alert { - color: #dd4b39; - padding: 5px; - text-align: center; +/* + * Remap HN's fade classes onto the tokens above. These have to out-specify + * news.css's own `.c00, .c00 a:link` rules, hence the html body prefix. + */ +html body .c00, html body .c00 a:link, html body .c00 a:visited { color: var(--hnes-c00); } +html body .c5a, html body .c5a a:link, html body .c5a a:visited { color: var(--hnes-c5a); } +html body .c73, html body .c73 a:link, html body .c73 a:visited { color: var(--hnes-c73); } +html body .c82, html body .c82 a:link, html body .c82 a:visited { color: var(--hnes-c82); } +html body .c88, html body .c88 a:link, html body .c88 a:visited { color: var(--hnes-c88); } +html body .c9c, html body .c9c a:link, html body .c9c a:visited { color: var(--hnes-c9c); } +html body .cae, html body .cae a:link, html body .cae a:visited { color: var(--hnes-cae); } +html body .cbe, html body .cbe a:link, html body .cbe a:visited { color: var(--hnes-cbe); } +html body .cce, html body .cce a:link, html body .cce a:visited { color: var(--hnes-cce); } +html body .cdd, html body .cdd a:link, html body .cdd a:visited { color: var(--hnes-cdd); } + +/* news.css sets .comment{max-width:1215px} plus ten per-breakpoint variants, + all at this specificity — without the prefix the measure never applied to + HN's own comment blocks, only to HNES's rendered tree. */ +html body .comment { + display: block; + width: 100%; + max-width: var(--hnes-measure); + line-height: var(--hnes-leading); } - -#alert a { - color: #000; +.comment, +.dead { font-size: var(--hnes-size-comment) !important; } +.comment font { display: block; } +.comment code { font-size: .92em !important; } +.comment p, +.comment > font { + font-size: var(--hnes-size-comment) !important; + margin: var(--hnes-com-para) 0 !important; } +.default div { margin-bottom: 6px !important; } +.default a:link { color: var(--hnes-link); } -.mourning { - border-top: 5px solid #000; +/* + * The collapse control was a 14px-wide bare glyph — the smallest target in the + * comment and the one clicked most. It is now a square button matching the nav + * pills: same radius, same hover tint, same transition. + */ +.hnes-comment .collapser { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--hnes-control); + height: var(--hnes-control); + margin: 0; + border-radius: var(--hnes-radius); + font-size: var(--hnes-size-xs); + line-height: 1; + cursor: pointer; + user-select: none; + transition: background-color .12s ease, color .12s ease; +} +/* Colour is deliberately absent: the glyph is an , so it already takes the + link colour from `#hnes-comments a` and the orange from `#content a:hover`, + both of which out-specify anything set here. Only the tint is ours. */ +.hnes-comment .collapser:hover { background: var(--hnes-surface-hi); } +.hnes-comment .collapser::after { content: "[\2013]"; } +.hnes-comment.collapsed .collapser::after { content: "[+]"; } + +.hnes-comment .reply-count { display: none; } +/* :not(:empty) — renderComment only fills this when descCount > 0, so a + childless comment would otherwise show a pill made of padding and tint. */ +.hnes-comment.collapsed .reply-count:not(:empty) { + display: inline-block; + padding: 1px 8px; + border-radius: var(--hnes-radius-pill); + background: var(--hnes-surface-hi); + color: var(--hnes-fg-muted); + font-size: 11px; } -#user-profile td:first-child, -body#poll-body form td:first-child, -body#login-body tr#content form td:first-child { - color: #444; - padding-right: 1em; -} -#user-profile td:nth-child(2) { - color: #000; -} -#user-profile.your-profile td p:first-of-type { - margin-top: .5em; -} -#user-profile.your-profile td p { - margin-top: 0em; -} -#user-profile.your-profile td span { - margin-left: 1em; -} -#user-profile.your-profile .select-option { - padding-top: 7px; -} -#user-profile a, .self-post-text a { - text-decoration: underline !important; -} -#others-profile-submitted, #your-profile-change-password { - padding-top: 2em; +.hnes-comment footer { + font-size: var(--hnes-size-xs); + text-decoration: underline; } -form input[type="text"], -form input[type="password"] { - padding: 3px; -} +/* Left rules marking comments posted since your last visit. */ +.hnes-new-parent { border-left: 2px solid var(--hnes-new-parent); } +.hnes-new { border-left: 2px solid var(--hnes-new-comment); } -body#login-body { -} -#login-body tr#content form{ - margin-top: 10px; -} -#login-body tr#content form input:not([type="submit"]) { - width: 200px; -} -#login-body b { - color: #000; -} -#login-body #login-msg { - color: #000; - font-style: italic; - margin-left: 10px; -} -#login-body h1 { - font-size: 130%; - color: #222; -} -#login-body a:hover { - text-decoration: underline; -} +.hnes-comment .score { display: none; } +.hnes-comment .score.visible { display: inline-block; } -/* borders indicating new comments */ -.hnes-new-parent { - border-left: 2px solid #bc9b85; -} -.hnes-new { - border-left: 2px solid #f60; +#loading_comments { + margin: 1em; + padding: 1em; + color: var(--hnes-fg); + background-color: var(--hnes-surface); + border-radius: var(--hnes-radius); } -.hnes-age, -.hnes-actions { - margin-left: .5em; - font-size: 11px; -} -.hnes-actions a:after { - content: " | "; - color: #000; -} +/* =========================================================================== + Vote arrows -.hnes-actions a:last-child:after { - content: ""; -} + HNES used to pull grayarrow.gif / graydown.gif off news.ycombinator.com; both + now 404. Inline SVG in a mask would be the obvious replacement, but HN serves + `img-src 'self' https://account.ycombinator.com`, which blocks data: URIs — + a failed mask hides the element entirely, so the arrows vanish. Drawing them + with borders needs no image at all, so the CSP is irrelevant and the colour + comes from a token like everything else. + =========================================================================== */ -.hnes-actions:before { - content: "[ "; +.votearrow { + position: relative; + width: 10px; + height: 10px; + border: 0; + background: none !important; } +.votearrow::after { + content: ""; + position: absolute; + /* Centred rather than pinned left so the anchor can be widened into a tap + target without the triangle drifting off it. */ + left: 50%; + margin-left: -5px; + bottom: 1px; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-bottom: 8px solid var(--hnes-fg-subtle); +} +a:hover .votearrow::after, +.votearrow:hover::after { border-bottom-color: var(--hnes-orange); } +.votearrow.rotate180 { transform: rotate(180deg); } + +/* Below 750px news.css scales arrows 1.3x for touch — including the .rotate180 + variant, at the same specificity as the rule above, and it comes later in the + cascade so it wins. HNES sizes the whole gutter column for touch instead, so + the extra scale only pushes the triangles outside the comment header. The id + raises specificity enough to drop it inside HNES's own tree; HN's tables + elsewhere keep their own behaviour. */ +#hnes-comments .votearrow { transform: none; } +#hnes-comments .votearrow.rotate180 { transform: rotate(180deg); } -.hnes-actions:after { - content: " ]"; +/* + * Two stacked 10px arrows used to live in a 10x10 box, so ~22px of content + * overflowed the bottom. Expanded comments hid the spill behind the body; on a + * collapsed one it hung outside the card. A flex column sized to hold both + * arrows fixes it, and centring means a comment with only one arrow (no + * downvote karma, or already voted) needs no special-case offset. + */ +.voteblock { + flex: none; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + width: var(--hnes-vote-col); + height: var(--hnes-control); + margin: 0 !important; + padding: 0; + border: 0; } +/* The anchor spans the column so the whole gutter is clickable, not just the + 10px triangle drawn inside it. */ +.voteblock a { display: block; margin: 0; width: 100%; } -/* user tags */ -.hnes-tag, .hnes-tagText:not(:empty) { - cursor: pointer; +.votearrow.voted, +.voteblock.voted { display: none; } + +/* The unvote control. hn.js assigns unvote.gif as an inline background-image, + so clearing it needs !important; the cross is then drawn from two bars. */ +.unvoter { + position: relative; + display: none; + flex: none; + /* Stands in for .voteblock once you have voted, so it occupies the same slot. */ + width: var(--hnes-vote-col); + height: var(--hnes-control); + border: 0; + margin: 0 !important; + padding: 0; + background-image: none !important; + background-color: transparent !important; } +.unvoter::before, +.unvoter::after { + content: ""; + position: absolute; + left: 1px; + top: 50%; + margin-top: -1px; + width: 10px; + height: 2px; + border-radius: 1px; + background: var(--hnes-fg-subtle); +} +.unvoter::before { transform: rotate(45deg); } +.unvoter::after { transform: rotate(-45deg); } +.unvoter:hover::before, +.unvoter:hover::after { background: var(--hnes-danger); } +.unvoter.voted { display: block; } + + +/* =========================================================================== + User tags + =========================================================================== */ + +.hnes-tag, +.hnes-tagText:not(:empty) { cursor: pointer; } +/* Flat black glyph — quieten it so it reads as an affordance next to the + username rather than the loudest thing in the header. The comment header now + centres it as a flex item, so the old negative margin-bottom is gone. */ .hnes-tag { - margin-bottom: -3px; + width: 12px; + height: 12px; + opacity: .45; + transition: opacity .12s ease; } +.hnes-tag:hover { opacity: .9; } .hnes-tagText:not(:empty) { - padding: 0 2px; - color: #000; + padding: 1px 6px; + border-radius: 999px; + background: var(--hnes-surface-hi); + color: var(--hnes-fg); + font-size: 11px; } .hnes-tagEdit { - padding-left: 2px; - width: 120px; - margin: 0 4px; - display: none; -} -.hnes-tagImage { - margin-right: 3px; - margin-bottom: 3px; -} -.hnes-tag-cont.edit .hnes-tagText { - display: none; -} -.hnes-tag-cont.edit .hnes-tagEdit { - display: initial; -} - -#hnmain table.comment-tree { display: none; + padding-left: 2px; + width: 120px; + margin: 0 var(--hnes-gap-1); } - -#hnes-comments { - color: black; - font-family: "Helvetica Neue", Arial, sans-serif; - font-size: 13px; - line-height: 1.5em; -} - -#hnes-comments a, #hnes-comments a:visited { - color: black; +.hnes-tag-cont.edit .hnes-tagText { display: none; } +.hnes-tag-cont.edit .hnes-tagEdit { display: initial; } +/* The tag icon ships as a flat black SVG; lift it in dark mode. */ +:root[data-hnes-theme="dark"] .hnes-tag { filter: invert(1); } +@media (prefers-color-scheme: dark) { + :root:not([data-hnes-theme="light"]) .hnes-tag { filter: invert(1); } } -#hnes-comments .permalink, #hnes-comments .permalink:visited { - color: rgb(102, 102, 102); -} -#hnes-comments .parent { - display: none; -} +/* =========================================================================== + Forms + =========================================================================== */ -#hnes-comments .replies { - margin: 8px 0 0 30px; +/* news.css puts monospace on input/textarea at the same specificity. */ +html body textarea, +html body input { + font-family: var(--hnes-font); + border-width: 1px; + border-radius: var(--hnes-radius); } - -.replies:empty { - display: none; +textarea { + border: 1px solid var(--hnes-border) !important; + padding: var(--hnes-gap-2) !important; + font-size: var(--hnes-size-sm) !important; + background: var(--hnes-surface); + color: var(--hnes-fg); } - -.hnes-comment { - padding: 4px 8px 4px 8px; - margin: 0 0 16px 0; +form textarea { + font-size: var(--hnes-size); + padding: var(--hnes-gap-3); + height: 80px; + overflow: auto; + margin-bottom: var(--hnes-gap-3); + width: 100%; + max-width: 640px; + box-sizing: border-box; } - -.hnes-comment header { - display: flex; - font-size: 93%; - color: rgb(102, 102, 102); +input[type="text"], +input[type="password"] { + font-size: var(--hnes-size-sm) !important; + padding: 5px var(--hnes-gap-2); + background: var(--hnes-surface); + color: var(--hnes-fg); + border: 1px solid var(--hnes-border); } - -.hnes-comment header > span, .hnes-comment header > a { - margin-right: 4px; +form textarea:focus, +textarea:focus, +input:focus { + outline: 2px solid var(--hnes-orange); + outline-offset: 1px; + border-color: var(--hnes-orange) !important; } -.votearrow.voted, .voteblock.voted {; - display: none; +input[type="submit"] { + font-family: inherit; + font-size: var(--hnes-size-sm); + font-weight: 600; + color: var(--hnes-orange-ink); + background: var(--hnes-brand); + padding: 6px var(--hnes-gap-3); + text-transform: capitalize; + border: 1px solid transparent; + border-radius: var(--hnes-radius); } - -.unvoter.voted { - display: block; +input[type="submit"]:hover { + cursor: pointer; + filter: brightness(1.08); } +input[value="add comment"], +form input[type="submit"] { display: block; } -.unvoter { - display: none; - width: 10px; - height: 10px; - border: 0px; - margin-top: 9px !important; - margin-left: 1px; - margin-right: 1px !important; - padding: 0px; -} +/* HN's orange section headings duplicate the nav; HNES hides them. */ +font[color="#ff6600"] { display: none; } -.hnes-comment header a, .hnes-comment header span { - margin-top: 4px; -} -.voteblock { - margin: 0px 1px 0px 1px !important; - padding: 0px; - border: 0px; - width: 10px; - height: 10px; -} -.voteblock a { - display: block; - margin-top: 0px; -} -.voteblock .votearrow { - margin: 3px 1px 3px; -} -.upvote.nodownvote { - margin-top: 7px; +input[name="q"] { + margin-left: var(--hnes-gap-3); + height: 32px; + padding-left: var(--hnes-gap-3); + color: var(--hnes-fg-muted) !important; } +input[name="q"]:focus { color: var(--hnes-fg) !important; } +form[action="http://hn.algolia.com/"], +form[action="//hn.algolia.com/"], +input[name="q"] { width: 270px; } +form[action="http://hn.algolia.com/"], +form[action="//hn.algolia.com/"] { margin: 0 auto; } -/*.hnes-comment .upvote, .hnes-comment .downvote { - font-size: 16px; -}*/ +.reply_form { width: 100%; max-width: 640px; } +.reply_form input[type="submit"] { margin: 3px; } +a[href^="reply"]:visited { color: var(--hnes-link); } +.input-help { color: var(--hnes-fg-subtle); font-size: var(--hnes-size-xs); } -/*.hnes-comment .upvote { - display: block; - height: 10px; - width: 10px; - padding: 1px; - background: url('https://news.ycombinator.com/grayarrow.gif'); - background-position: top center; - background-repeat: no-repeat; - background-origin: padding-box; +/* Submit page */ +form[action="/r"] table { width: 100%; max-width: 440px; } +form[action="/r"], +form[action="/r"] input[type="text"], +form[action="/r"] textarea { + width: 100%; + max-width: 420px; + margin: 5px 0; + box-sizing: border-box; } -.hnes-comment .downvote { - display: block; - height: 10px; - width: 10px; - padding: 1px; - background: url('https://news.ycombinator.com/graydown.gif') no-repeat center; -}*/ +form[action="/r"] input[type="submit"] { margin: 0; } -.hnes-comment .reply-count { +#submit-overlay { display: none; + position: absolute; + z-index: 100; + background: var(--hnes-surface); + color: var(--hnes-fg); + padding: var(--hnes-gap-5); + width: min(535px, 90vw); + margin: 20px auto 0; + left: 0; + right: 0; + border-radius: var(--hnes-radius); + box-shadow: 0 12px 40px rgba(0, 0, 0, .35); } - -.hnes-comment.collapsed .reply-count { - color: #000; - display: initial; -} - -.hnes-comment .text { - max-width: 800px; -} - -.hnes-comment.level-odd, -#hnes-comments.nolevels .hnes-comment { - background-color: #FFFFFD; +#overlay-bg { + display: none; + position: fixed; + inset: 0; + z-index: 99; + background: #000; + opacity: .6; } -.hnes-comment.level-even { - background-color: #F6F6EF; +#options_bar { + position: fixed; + bottom: 0; + width: 100%; + padding: 5px; + background: var(--hnes-surface); + border-top: 1px solid var(--hnes-border); } -.hnes-comment:last-child { - margin: 0; -} -.hnes-comment a:hover { - text-decoration: underline; -} +/* =========================================================================== + Profiles, login, polls, jobs, threads + =========================================================================== */ -.hnes-comment.collapsed > section { - display: none; +#user-profile td:first-child, +body#poll-body form td:first-child, +body#login-body tr#content form td:first-child { + color: var(--hnes-fg-muted); + padding-right: 1em; } - -.hnes-comment section.body { - margin-left: 34px; +#user-profile td:nth-child(2) { color: var(--hnes-fg); } +#user-profile.your-profile td p:first-of-type { margin-top: .5em; } +#user-profile.your-profile td p { margin-top: 0; } +#user-profile.your-profile td span { margin-left: 1em; } +#user-profile.your-profile .select-option { padding-top: 7px; } +#user-profile a, +.self-post-text a { text-decoration: underline !important; } +#others-profile-submitted, +#your-profile-change-password { padding-top: 2em; } + +.item-header tr:nth-child(3) td { color: var(--hnes-fg) !important; } + +#login-body tr#content form { margin-top: var(--hnes-gap-3); } +#login-body tr#content form input:not([type="submit"]) { width: 200px; } +#login-body b { color: var(--hnes-fg); } +#login-body #login-msg { + color: var(--hnes-fg); + font-style: italic; + margin-left: var(--hnes-gap-3); } +#login-body h1 { font-size: 130%; color: var(--hnes-fg); } +#login-body a:hover { text-decoration: underline; } -.hnes-comment .collapser { +.poll-graph { display: block; - margin-left: 4px; - margin-right: 4px; - width: 14px; - cursor: pointer; + height: 28px; + background-color: var(--hnes-brand); + border-radius: 3px; } -.hnes-comment .collapser:hover { - color: #f60; -} - -.hnes-comment .collapser::after { - content: "[\2013]"; -} - -.hnes-comment.collapsed .collapser::after { - content: "[+]"; -} - -.hnes-comment footer { - font-size: smaller; - text-decoration: underline; -} +#jobs-body table table { padding-left: 7px; margin: 0 auto; } +#jobs-body #content > td { margin: 0; padding: 0; } -/* removes some extraneous spacing between comments as mentioned in issue #140 */ -.comtr td { - padding: 0px; +#threads-body .morelink, +#threads-body .morelink:hover { + display: block; + padding: 1em; + color: var(--hnes-orange); + font-size: var(--hnes-size-title); } +#threads-body .morelink:hover { text-decoration: underline; } -/* hide native comment collapse */ -.togg { - display: none; +#content > td > center > table { + text-align: center; + margin: 0 auto; } -#content a:hover, #content a:visited:hover { - color: #f60 !important; +#alert { + padding: var(--hnes-gap-2); + text-align: center; + color: var(--hnes-danger); } +#alert a { color: var(--hnes-fg); } -#threads-body .morelink, #threads-body .morelink:hover { + /* news.css sets .yclinks{font-size:8pt}; the colour below was already armored + with !important but the font-size next to it was not. */ +html body .yclinks { display: block; - padding: 1em; - color: #f60; - font-size: 16px; -} -#threads-body .morelink:hover { - text-decoration: underline; + padding: var(--hnes-gap-3); + text-align: center; + font-size: var(--hnes-size-xs); + color: var(--hnes-fg-subtle); +} +.yclinks a { color: var(--hnes-fg-muted) !important; } + +/* Keep comment headers on one line even when the comment is shorter. */ +#comment-table { width: 100%; } + + +/* =========================================================================== + Utilities + =========================================================================== */ + +.hidden { display: none; } +.nostory, .noreply { display: none !important; } +.underlined { text-decoration: underline !important; } + + +/* =========================================================================== + Responsive + HN's table shell can't reflow, so below the breakpoint the gutter columns + shrink and the tallies move inline rather than being pinned to the row. + =========================================================================== */ + +@media (max-width: 860px) { + :root { + --hnes-size-title: 16px; + --hnes-col-comments: 52px; + --hnes-col-score: 44px; + /* Widen the gutter slots for touch. Growing the arrows themselves would + overflow the column that holds them, which is the bug this replaces. */ + --hnes-vote-col: 26px; + /* Touch targets outrank the view mode: this sets the consumed token, not + the -density request the modes write to, and the :where() on those blocks + leaves this later declaration free to win. */ + --hnes-control: 32px; + } + + #content > td { padding: var(--hnes-gap-4) var(--hnes-gap-2); } + + #index-body .title { + padding-left: var(--hnes-gap-2); + padding-right: var(--hnes-gap-2); + } + + .voteblock .votearrow { height: 14px; } + + #hnes-comments .replies { + margin-left: var(--hnes-gap-1); + padding-left: var(--hnes-gap-2); + } + /* Cap rather than set, and only the axis that needs it: the modes already + tighter than 8px (flow at zero) keep their own value, and vertical padding + keeps flowing from the single declaration on .hnes-comment. */ + .hnes-comment { padding-inline: min(var(--hnes-com-pad-x), var(--hnes-gap-2)); } + /* Narrow screens cannot afford the full gutter indent at every nesting level. */ + .hnes-comment section.body { margin-left: var(--hnes-gap-4); } + + form textarea, + .reply_form { max-width: 100%; } +} + +@media (max-width: 560px) { + :root { + --hnes-size-title: 15px; + --hnes-col-comments: 44px; + --hnes-col-score: 44px; + } + + /* Below this the absolute tallies overlap the title, so unpin them. */ + #index-body .score, + #index-body .comments { + position: static; + display: block; + height: auto; + padding-top: 0; + } } From b94504d20eb84ba972a19ab8c63c636981b1c3df Mon Sep 17 00:00:00 2001 From: John Bolliger Date: Wed, 12 Aug 2026 21:23:11 -0700 Subject: [PATCH 02/20] Add the proposal set, and keep it out of the packaged zips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proposals/README.md is the plan this port was executed against: what was verified against live HN markup, what is implemented, what is left (Phase 2's positional table walks, Phase 3's jQuery upgrade), and the open decisions about republishing and Firefox. The two HTML files are self-contained design write-ups with live mockups — comment-ux.html for the comment tree, visual-overhauls.html for four palettes with measured contrast. palettes.md proposes shipping all four as a runtime option rather than picking one. zip.sh gains a proposals exclusion so ~90 KB of design documents does not ride along into the store uploads. Co-Authored-By: Claude Opus 5 (1M context) --- proposals/README.md | 255 +++++++ proposals/comment-ux.html | 1164 +++++++++++++++++++++++++++++++ proposals/palettes.md | 159 +++++ proposals/visual-overhauls.html | 894 ++++++++++++++++++++++++ zip.sh | 4 +- 5 files changed, 2474 insertions(+), 2 deletions(-) create mode 100644 proposals/README.md create mode 100644 proposals/comment-ux.html create mode 100644 proposals/palettes.md create mode 100644 proposals/visual-overhauls.html diff --git a/proposals/README.md b/proposals/README.md new file mode 100644 index 0000000..f9392a4 --- /dev/null +++ b/proposals/README.md @@ -0,0 +1,255 @@ +# HNES — proposal to make it work on modern Chrome + +## Context + +HNES is at v1.6.0.3, Manifest V2, last substantively touched years ago. Two independent +things have broken since: + +1. **Chrome killed MV2.** MV2 extensions no longer load, and the Chrome Web Store no + longer accepts MV2 uploads. The extension cannot run at all as shipped. +2. **HN's markup drifted.** Even after an MV3 port, several features would render wrong + or silently no-op, because they key off assets and tags HN removed. + +Both must be fixed for the extension to be usable; (1) alone gets it loading, (2) makes +it correct. Verified against live `news.ycombinator.com` markup on 2026-07-31. + +## Status + +| Phase | State | +|---|---| +| Phase 0 — data migration | **Implemented**, uncommitted | +| Phase 1 — MV3 port | **Implemented**, uncommitted | +| Stylesheet rebuild | **Implemented**, uncommitted (not in the original plan; added during the same pass) | +| Phase 2 — markup drift | Partly done — the comment fade-class and vote-arrow breakages are fixed; the positional table walks are unaudited | +| Phase 3 — hygiene | Not started | +| Design tracks | Proposals only | +| Palette as a user option | Proposal only — [`palettes.md`](./palettes.md) | + +Implemented work is verified but **not committed** — 23/23 migration unit tests, 7/7 +in-browser migration, 11 page types loading clean, 0 horizontal overflow at +1280/900/780/600/420/375, and WCAG AA or better on every measured text pair. + +Known gap: `/login` throws on an unguarded `$('form input[type=submit]').get(0)` when HN +returns a body with no form. Pre-existing, not a regression — but it is the kind of thing +Phase 2's try/catch hardening is for. + +--- + +## Phase 0 — Rescue user data BEFORE flipping the manifest *(implemented)* + +This is the highest-risk step and it constrains everything after it, so it goes first. + +All durable user state — per-user upvote counts, user tags, per-thread last-read comment +counts — lives in the **MV2 background page's `localStorage`** (`background.js:5,8,11,27`). +`localStorage` does not exist in an MV3 service worker. A naive port silently wipes every +user's tags and vote history. + +The data itself survives the update (same `chrome-extension://` origin); it just +becomes unreachable from a service worker. Two mechanisms recover it: + +- **Chrome:** an offscreen document with `chrome.offscreen.Reason.LOCAL_STORAGE` + ("the offscreen document needs access to localStorage") — this is the migration path + Chrome's own MV2→MV3 guide points at. Requires the `offscreen` permission, Chrome 109+. +- **Firefox:** no offscreen API, but Firefox MV3 uses an *event page*, which still has + `localStorage` — read it directly there. + +Migration routine, run once on `chrome.runtime.onInstalled` and guarded by a +`hnesMigrated` flag in `chrome.storage.local`: + +- copy every `localStorage` key into `chrome.storage.local` +- while copying, normalize the legacy numeric vote format (`"etcet": 1`) to the object + form (`{"votes":1}`) — this is currently done lazily and per-page-load at + `js/hn.js:1300-1310`; doing it once here lets that branch be deleted +- apply the expiry sweep that `background.js:34-41` was supposed to do — that IIFE is + missing its trailing `()` and has never run, so stale thread entries have accumulated + since the feature shipped +- set the flag, then tear the offscreen document down + +**New releases must ship in this order:** migration logic first, in the same release that +flips to MV3. There is no second chance once a user updates. + +## Phase 1 — Manifest V3 port *(implemented)* + +`manifest.json`: + +- `manifest_version: 2` → `3`, version → `2.0.0` +- `background.scripts` → `background: { service_worker, scripts }` — Chrome reads + `service_worker`, Firefox reads `scripts` and ignores the other. One manifest, both + browsers; this is MDN's documented cross-browser form. +- `web_accessible_resources` → MV3 object form (`{ resources, matches }`). Without this, + `spin.gif` / `unvote.gif` / `tag.svg` are blocked in the page context. +- Drop `templates/comment.html` from that list — the file has never existed in git; the + real template is an inline literal at `js/hn.js:269-307`. +- Match patterns: drop `news.ycombinator.net` and `news.ycombinator.org` (both fail to + resolve — dead DNS), drop the `http://` variants (HN is HTTPS + HSTS), keep + `hackerne.ws` (301s to HN). +- **Fix `hckrnews.com`:** the manifest matches only `http://hckrnews.com/*` and the site + is HTTPS-only, so that content script has silently not run for years. +- Drop `all_frames: true` on the main content script — it re-runs jQuery + hn.js in every + iframe for no benefit. +- `storage` + `unlimitedStorage` carry over unchanged (`unlimitedStorage` still lifts the + `chrome.storage.local` 10 MB cap). Add `offscreen`. No `host_permissions` needed — + every network call is same-origin from the content script. + +`background.js` → service worker: replace the whole `localStorage` message proxy with +`chrome.storage.local`. Nothing else lives there — no tabs, alarms, webRequest, +contextMenus, commands, or action. + +`js/hn.js`: + +- `chrome.extension.getURL` → `chrome.runtime.getURL` at lines 66, 264, 465 (the + `chrome.extension` namespace is gone in MV3). +- Delete the message-passing layer (`hn.js:911-931`) and call `chrome.storage.local` + directly. This collapses today's two disjoint stores into one — collapse state already + uses `chrome.storage.local` (`hn.js:622,630`) while everything else goes through the + proxy — and kills the per-list-item message storm on hckrnews.com (`hn.js:1897` fires + one `sendMessage` per `

  • `). +- Nothing else blocks MV3: no `eval`, no remote script, no inline ` + + diff --git a/proposals/palettes.md b/proposals/palettes.md new file mode 100644 index 0000000..6813d00 --- /dev/null +++ b/proposals/palettes.md @@ -0,0 +1,159 @@ +# Ship all four palettes as a user option + +## What this asks that Track B didn't + +[`visual-overhauls.html`](./visual-overhauls.html) asked *which* of Newsprint, Ember, Slate +and Letterpress should become the look. This asks for all four, selectable at runtime — +which turns a design decision into a mechanism decision. The design work is already done and +measured; what follows is about making four palettes cost roughly what one costs. + +The extension already has two runtime axes on `` — `data-hnes-theme` (auto/light/dark) +and `data-hnes-density` (comfortable/compact/flow). A palette is a third axis of exactly the +same shape, and every piece of machinery it needs already exists. + +## The one real obstacle + +The mockups define each palette as **7 slots** — bg, surface, fg, muted, rule, accent, +onaccent. `style.css` defines **75 tokens, 29 of them `light-dark()` colour pairs**. Written +out literally, four palettes is 116 hand-picked hex values, every one of which needs its own +contrast measurement. That is not a stylesheet anyone will keep correct. + +So the palettes are cheap only if the token block is first split into **seeds** and +**derived values**. That split is the bulk of the work, and it is worth doing on its own +merits — it is also, not coincidentally, what Slate's write-up was arguing for. + +--- + +## Step 1 — Seed / derive split *(prerequisite, no visible change)* + +Today's `:root` block is partly derived already (`--hnes-c00: var(--hnes-fg)`, +`--hnes-heat-3: var(--hnes-orange)`, `--hnes-spine: var(--hnes-border)`). This finishes +the job. + +**Seeds** — the only thing a palette declares, ~8 `light-dark()` pairs: + +| Seed | Why it can't be derived | +|---|---| +| `--hnes-bg`, `--hnes-surface` | the two grounds everything else mixes toward | +| `--hnes-fg` | the ink | +| `--hnes-brand`, `--hnes-orange`, `--hnes-orange-ink` | brand surface, accent, ink on brand | +| `--hnes-danger`, `--hnes-new-user` | independent hues — see below | + +**Derived** — one palette-independent block, `color-mix(in oklab, …)`: + +`--hnes-surface-alt`, `--hnes-surface-hi`, `--hnes-border`, `--hnes-fg-muted`, +`--hnes-fg-subtle`, `--hnes-visited`, `--hnes-selection`, `--hnes-new-parent`, +`--hnes-heat-1/2`, `--hnes-header-ink*`, and the entire `c5a…cdd` fade ladder. + +The fade ladder is where this pays off most. The existing comment already says what those +ten pairs *are* — "the scale runs from full contrast toward the page background" — so state +it instead of restating it twenty times: + +```css +--hnes-c73: color-mix(in oklab, var(--hnes-fg) 60%, var(--hnes-bg)); +``` + +Ten pairs become ten percentages, correct in every palette and every theme for free. +The percentages get fitted to today's rendered values during implementation — sRGB hex to +an oklab mix ratio is not a clean linear map, so `[100, 72, 60, 54, 51, 44, 36, 30, 24, 18]` +is a starting ladder to be checked against the current output, not a claim. + +**Why `--hnes-danger` and `--hnes-new-user` become seeds rather than derivations:** this is +Slate's "split brand from state" argument, and it applies whichever palette ships. Today +`--hnes-new-comment` traces back to `--hnes-brand`, so any palette that moves the brand +silently changes what "new" looks like. Once the seeds are separate, a palette can move the +brand without moving the state colours — or move both deliberately. + +`color-mix()` and `oklch()` are Chrome 111+; the manifest floor is already 123. + +## Step 2 — The palette axis + +One entry in `HN.MODES` (`js/hn.js:929`) and the mirrored entry in `js/boot.js` — the +mirroring is deliberate and already documented in both files; skip the boot.js half and the +palette flashes to `classic` on every cold load. + +```js +{ key: 'hnesPalette', attr: 'data-hnes-palette', label: 'palette', + title: 'Switch colour palette', + values: ['classic', 'newsprint', 'ember', 'slate', 'letterpress'] } +``` + +`values[0]` is the unset state and leaves the attribute off, per the existing convention — +so **`classic` is today's look and nobody who ignores the toggle sees any change.** +`HN.applyMode` and the storage write need no modification at all. + +Each palette is then one seed block, weighted with `:where()` for the same reason the +density blocks are: + +```css +:root:where([data-hnes-palette="ember"]) { + --hnes-bg: light-dark(#fbf6f1, #14100c); + --hnes-surface: light-dark(#fffcf9, #1e1712); + --hnes-fg: light-dark(#241a12, #f0e6dc); + --hnes-orange: light-dark(#a8480c, #ff8f45); + /* …five more */ +} +``` + +Seed values for all four come straight out of `visual-overhauls.html:255-292`, where they +are already paired light/dark and already measured. + +**Orthogonality holds:** palettes own colour tokens, density owns geometry tokens, and the +two sets do not intersect. 5 palettes × 3 densities × 3 themes is 45 combinations and zero +combinatorial CSS. + +## Step 3 — What deliberately does *not* come along + +- **Newsprint's "no card fills"** is `--hnes-com-fill: transparent` — which is already + `view: flow`. Keeping it there preserves the orthogonality; Newsprint-the-palette is its + colour half, and the documented recipe for the full look is **palette: newsprint + view: + flow**. Folding a geometry change into a palette would be the one thing that breaks the + axis model. +- **Newsprint's `data-hnes-contrast` axis** — defer. If it is wanted later it becomes a + multiplier on the derived mix percentages, which is only cheap *because* of step 1. +- **Ember's user-selectable `--hnes-hue`** — ship Ember at fixed hue 45. Ember's own + measurements say the accent lightness has to be solved per hue (a 30-entry table, because + the target chroma is unreachable at 19 of 36 sampled hues). A hue slider is its own + project; the ramp underneath it is what step 1 delivers. +- **Slate's semantic layer** — already absorbed into step 1, for every palette. + +## Step 4 — Presentation + +Three cycling text toggles in a 13.5px nav, one of them cycling five values, is the wrong +control: four clicks to reach `letterpress`, and the label is long. + +| Option | Cost | Trade | +|---|---|---| +| Cycle, like the other two | none | 4 clicks worst case, longest label in the nav | +| **Dropdown** reusing `.nav-drop-down` | small | one click to any palette; component exists at `js/hn.js:1659-1700`, styled at `style.css:476-507` | +| Options page (`options_ui`) | medium | conventional home for 3+ prefs, but a new surface, and `boot.js` still needs its own storage read | + +**Recommendation: the dropdown.** Give the `MODES` descriptor a `ui` field (`cycle` or +`menu`); theme and density keep cycling, palette renders as a menu. The storage key, the +attribute write and `boot.js` are identical either way — only the rendering branch differs. + +## Verification + +Per palette (×2 themes), only the **seeds** need measuring — every derived token is a mix of +two already-measured seeds: + +1. `--hnes-fg` on `--hnes-bg` and on `--hnes-surface`; `--hnes-orange-ink` on `--hnes-brand`; + `--hnes-orange` on `--hnes-bg`. Same twelve pairs this session already measured for classic. +2. Fade ladder renders monotonic `c00 → cdd`, and the steps at `c88` and below still clear the + floor for de-emphasised text — construction guarantees monotone lightness, not legibility. +3. **`setTopColor` (`js/hn.js:1809`)**: on HN's memorial days the header `bgcolor` is an inline + style that beats `--hnes-brand`. Confirm `--hnes-orange-ink` still reads on HN's tint in each + palette — this is the one place the token layer is not in charge. +4. Cold-load flash check per palette: hard reload with cache disabled, confirm no `classic` + frame — i.e. `boot.js` really did get the third entry. + +## Sequencing + +1. **Commit what exists first.** All of this lands on ~1,449 uncommitted lines (MV3 port + + stylesheet rebuild). A seed/derive refactor is a bad thing to have tangled with that diff. +2. Seed/derive split — no user-visible change, carries the real risk, own commit. +3. Palette axis + four seed blocks. +4. Menu UI. + +Steps 2-4 are each independently shippable; stopping after 2 still leaves the stylesheet +better than it is now. diff --git a/proposals/visual-overhauls.html b/proposals/visual-overhauls.html new file mode 100644 index 0000000..5144508 --- /dev/null +++ b/proposals/visual-overhauls.html @@ -0,0 +1,894 @@ + + + + + + +HNES — four visual overhauls + + + + +
    + +
    +

    HNES · visual identity · four overhauls

    +

    How much orange is Hacker News?

    +

    + Four complete palettes with their theming mechanics. This track is independent of the + comment-UX directions — the token layer already separates colour from layout, so any + palette here composes with any of those. Every contrast figure below is computed, not + estimated. +

    + +
    +

    The question each one answers

    +
      +
    1. Newsprint demotes orange to a signal and lets contrast and structure carry the page.
    2. +
    3. Ember promotes orange to the entire tonal system — every surface derived from one hue.
    4. +
    5. Slate moves the ground cool and makes colour strictly semantic.
    6. +
    7. Letterpress takes Newsprint's answer and builds it on Ember's machinery — the + restrained look, generated rather than typed.
    8. +
    +
    +
    + + + +
    +

    Overhaul one

    +

    Newsprint

    +

    + Hacker News as a broadsheet. No card fills anywhere — separation comes from hairline + rules and whitespace, the way a printed page does it. Near-white paper, near-black ink, + and orange spent only where it means something: the active tab, an unread marker, a + link you have not followed. +

    +
    + +

    Palette

    +
    +
    +
    page#fdfdfc
    +
    surface#ffffff
    +
    ink#111110
    +
    muted#5b5b57
    +
    rule#e4e4de
    +
    signal#c2450a
    +
    +
    +
    page#0d0d0c
    +
    surface#151513
    +
    ink#f3f2ec
    +
    muted#a3a29b
    +
    rule#2a2a27
    +
    signal#ff7a33
    +
    +
    + +
    +
    +
    Light — day edition
    +
    + + topnewask +
    +
    +
    +
    keiferski46 minutes ago
    +

    The problem was never the tooling. Nobody agrees on what "done" means before they start.

    +
    +
    +
    seizethecheese44 minutes ago
    +

    Strong agree, though the estimate is usually fine. What drifts is the scope behind it.

    +
    +
    +
    + +
    +
    Dark — night edition
    +
    + + topnewask +
    +
    +
    +
    keiferski46 minutes ago
    +

    The problem was never the tooling. Nobody agrees on what "done" means before they start.

    +
    +
    +
    seizethecheese44 minutes ago
    +

    Strong agree, though the estimate is usually fine. What drifts is the scope behind it.

    +
    +
    +
    +
    + +

    Theming mechanics

    +
      +
    • Adds a contrast axis, not just a palette. A contrast-first design earns a + data-hnes-contrast="normal|high" attribute alongside the existing theme + one — high pushes muted text to full ink and thickens rules to 2px.
    • +
    • Retires the surface tints. --hnes-surface-alt and the + odd/even level fills stop being used, so nesting is carried entirely by the spine.
    • +
    • Cheapest of the three to implement — it is a re-pointing of existing tokens + plus deletions. No new colour machinery.
    • +
    + +
    + + + + + + + + + +
    Measured contrast
    PairLightDark
    body on surface18.89:1AAA16.30:1AAA
    muted on surface6.82:1AA7.14:1AAA
    signal on surface5.06:1AA7.04:1AAA
    header ink on signal5.06:1AA7.33:1AAA
    +
    +
    + + + +
    +

    Overhaul two

    +

    Ember

    +

    + The opposite bet. Rather than rationing the orange, derive everything from it: page, + surfaces, borders and muted text are all steps along one hue, so the interface reads as + a single warm material lit from one source. The accent is the brightest step of the same + ramp rather than a separate colour. +

    +
    + +

    Palette

    +
    +
    +
    page#fbf6f1
    +
    surface#fffcf9
    +
    ink#241a12
    +
    muted#6b5443
    +
    rule#e8dbcd
    +
    brand#a8480c
    +
    +
    +
    page#14100c
    +
    surface#1e1712
    +
    ink#f0e6dc
    +
    muted#b09681
    +
    rule#33281f
    +
    brand#ff8f45
    +
    +
    + +
    +
    +
    Light — one hue, eight steps
    +
    + + topnewask +
    +
    +
    +
    keiferski46 minutes ago3 replies
    +

    The problem was never the tooling. Nobody agrees on what "done" means before they start.

    +
    +
    +
    seizethecheese44 minutes ago
    +

    Strong agree, though the estimate is usually fine. What drifts is the scope behind it.

    +
    +
    +
    + +
    +
    Dark — warm charcoal, not black
    +
    + + topnewask +
    +
    +
    +
    keiferski46 minutes ago3 replies
    +

    The problem was never the tooling. Nobody agrees on what "done" means before they start.

    +
    +
    +
    seizethecheese44 minutes ago
    +

    Strong agree, though the estimate is usually fine. What drifts is the scope behind it.

    +
    +
    +
    +
    + +

    Theming mechanics

    +
      +
    • The interesting one: a user-selectable hue. Because every surface is a step on + one ramp, the literal hex tokens can become oklch() values computed from + a single --hnes-hue. Set it to 45 and you have HN orange; set it to 200 + and the whole extension is cool blue, with contrast preserved because only the hue + channel moves.
    • +
    • Cost is in the colour machinery. oklch() and + light-dark() both need Chrome 123+, which the manifest already requires + — but the ramp has to be authored so that lightness steps hold their contrast at + every hue, which is real design work, not a find-and-replace.
    • +
    • Watch for muddiness. A single warm hue across every surface can go sepia. The + guard is keeping ink and muted text slightly desaturated relative to the ramp, which + is why #241a12 is not simply a dark step of the brand.
    • +
    + +
    + + + + + + + + + +
    Measured contrast
    PairLightDark
    body on surface16.68:1AAA14.38:1AAA
    muted on surface6.91:1AA6.34:1AA
    brand on surface5.71:1AA7.81:1AAA
    header ink on brand5.39:1AA8.40:1AAA
    +
    +
    + + + +
    +

    Overhaul three

    +

    Slate

    +

    + The largest departure. The ground goes cool and desaturated — the register of a + developer tool rather than a news site — and orange survives only as a signal. In + exchange, colour finally gets to mean something consistently: new, dead, flagged and + score each get their own hue, separate from the brand. +

    +
    + +

    Palette

    +
    +
    +
    page#f1f3f4
    +
    surface#ffffff
    +
    ink#14181a
    +
    muted#5a656b
    +
    rule#d8dde0
    +
    signal#ae470b
    +
    +
    +
    page#0f1214
    +
    surface#181d20
    +
    ink#e3e8ea
    +
    muted#96a0a6
    +
    rule#283035
    +
    signal#ff7a33
    +
    +
    + +
    +
    +
    Dark — designed first
    +
    + + topnewask +
    +
    +
    +
    keiferski46 minutes ago
    +

    The problem was never the tooling. Nobody agrees on what "done" means before they start.

    +
    +
    +
    seizethecheese44 minutes agonew
    +

    Strong agree, though the estimate is usually fine. What drifts is the scope behind it.

    +
    +
    +
    + +
    +
    Light — derived from dark
    +
    + + topnewask +
    +
    +
    +
    keiferski46 minutes ago
    +

    The problem was never the tooling. Nobody agrees on what "done" means before they start.

    +
    +
    +
    seizethecheese44 minutes agonew
    +

    Strong agree, though the estimate is usually fine. What drifts is the scope behind it.

    +
    +
    +
    +
    + +

    Theming mechanics

    +
      +
    • Splits the token block in two. Brand colour and state colour stop being the + same thing. Today --hnes-new-comment, the poll graph and the active tab + all trace back to --hnes-brand; here they resolve against a separate + semantic set, so restyling the brand cannot silently change what "new" looks like.
    • +
    • Dark is the reference, light is derived. Reverses the current order, which + matches how the extension is actually used — and the dark palette is where the + existing design has needed the most correction.
    • +
    • Biggest identity risk of the three. A cool HN reads as a different product. + Worth prototyping behind the existing theme toggle as a fourth option before + committing.
    • +
    + +
    + + + + + + + + + +
    Measured contrast
    PairLightDark
    body on surface17.87:1AAA13.76:1AAA
    muted on surface5.99:1AA6.37:1AA
    signal on surface5.67:1AA6.54:1AA
    header ink on signal5.67:1AA6.88:1AA
    +
    +
    + + + +
    +

    Overhaul four · the synthesis

    +

    Letterpress

    +

    + Newsprint's page with Ember's press underneath it. What you see is the broadsheet — + no fills, hairline rules, ink on paper. What sits under it is Ember's ramp: every value + here is a step on one oklch() scale at hue 45, not a hex somebody typed. + The two signals are the proof rather than the exception — they are the exact ramp + coordinates of the oranges Newsprint reached by hand, which is the useful result: the + generated scale can land on a chosen colour instead of merely near it. The look is the + conservative one; the machinery is the ambitious one. +

    +
    + +

    Palette

    +
    +
    +
    page#fefaf9
    +
    surface#ffffff
    +
    ink#19120f
    +
    muted#6d605b
    +
    rule#e6deda
    +
    signal#c04800
    +
    +
    +
    page#0f0b09
    +
    surface#1b1613
    +
    ink#f0ece9
    +
    muted#aea29c
    +
    rule#38312e
    +
    signal#ff7a34
    +
    +
    + +
    +
    +
    Light — hue 45, the default
    +
    + + topnewask +
    +
    +
    +
    keiferski46 minutes ago
    +

    The problem was never the tooling. Nobody agrees on what "done" means before they start.

    +
    +
    +
    seizethecheese44 minutes ago
    +

    Strong agree, though the estimate is usually fine. What drifts is the scope behind it.

    +
    +
    +
    + +
    +
    Dark — same hue, its own steps
    +
    + + topnewask +
    +
    +
    +
    keiferski46 minutes ago
    +

    The problem was never the tooling. Nobody agrees on what "done" means before they start.

    +
    +
    +
    seizethecheese44 minutes ago
    +

    Strong agree, though the estimate is usually fine. What drifts is the scope behind it.

    +
    +
    +
    +
    + +

    Theming mechanics

    +
      +
    • Ships Newsprint, keeps Ember's option open. Day one is the cheap change — + re-point tokens, delete the surface fills, carry nesting on the spine. But because + the values are generated rather than typed, the second palette costs one number + instead of another twelve hexes.
    • +
    • Neutrals are near-grey on purpose, not by accident. Chroma runs + 0.004–0.018 across the six neutral steps — enough that the paper is warm and + the rules agree with the accent, far too little to read as sepia. This is Ember's + stated failure mode, dodged by holding chroma down rather than by desaturating ink + as a special case. The two accent steps sit far outside that band, at 0.171 and + 0.181.
    • +
    • The accents are authored against the gamut boundary. Both are the exact + oklch() coordinates of the colours Newsprint arrived at by hand — + #c2450a and #ff7a33 — which is the useful proof that the + ramp can reach a hand-picked result rather than merely approximate one. It is also + where the first draft went wrong: the earlier dark accent asked for + L .780 C .150, which sRGB cannot hold at this hue, so the browser + clipped it and returned something visibly duller than the ramp specified. Ask for + more chroma than the space has and you get silent desaturation, not an error.
    • +
    • One correction to Ember's claim, and it matters. Ember says contrast is + preserved because only the hue channel moves. That is true of the neutrals and + false of the accent — see below.
    • +
    + +
    + + + + + + + + + +
    What actually happens when the hue moves (36 hues, light mode, on white)
    PairRange across all huesSpreadHolds AA?
    body on surface18.38 – 18.57:10.19AAA everywhere
    muted on surface5.91 – 6.08:10.17AA everywhere
    signal on surface3.92 – 5.19:11.27fails over much of the wheel
    header ink on signal3.73 – 4.94:11.20fails over much of the wheel
    +
    + +

    + The neutrals are effectively hue-independent — a fifth of a point of drift across the + whole wheel, because at chroma 0.018 there is nothing for sRGB to clip. The saturated + step is a different animal: hold its lightness and chroma fixed and the gamut boundary + moves under it, taking luminance with it. A teal accent lands at 3.92:1 where the + orange one sits at 5.04:1 — below the AA floor, and at this chroma it is below it for + most of the wheel rather than just near 180°. +

    +

    + So a free hue does not get the accent for free. Its lightness has to be solved per hue — + sweeping the solve, the correction lives in L 0.520–0.590 and brings + the worst case back to 4.50:1 — and its chroma has to be clamped to the boundary as + well, since 0.171 is unreachable at 19 of the 36 hues sampled. That is a 30-entry table + or a build step, not a run-time cost, but it is the part of Ember's "one number" promise + that has to be paid for. +

    + +
    + + + + + + + + + +
    Measured contrast — hue 45
    PairLightDark
    body on surface18.51:1AAA15.27:1AAA
    muted on surface6.04:1AA7.22:1AAA
    signal on surface5.04:1AA6.91:1AA
    header ink on signal4.80:1AA7.04:1AAA
    +
    +
    + + + +
    +

    Side by side

    + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    OverhaulOrangeGroundNew machineryEffortIdentity risk
    NewsprintSignal onlyWarm paperContrast axisLowLow — still reads as HN
    EmberEverythingWarm, one hueoklch() ramp from one hueMediumLow — more HN than HN
    SlateSignal onlyCool desaturatedSemantic colour layerMediumHigh — reads as a different product
    LetterpressSignal onlyWarm paper, generatedRamp from one hue, accent lightness solved per hueLow now, medium laterLow — ships as Newsprint
    +
    + +
    +

    Recommendation: Newsprint, with Ember's hue machinery underneath it. + That combination is specified above as Letterpress, so the + recommendation is a thing you can look at rather than an instruction to go and + assemble one. Newsprint is the cheapest and the least likely to be regretted — it + removes surfaces rather than adding colour, and the current design's weakest moments + have all been places where a fill fought the text on it.

    +

    Ember's real contribution is not the palette but the ramp: deriving the scale from one + --hnes-hue is worth building regardless of which look ships, because it + turns every future palette into one number. The caveat Letterpress adds is that the + number only buys you the neutrals for free — the accent needs its lightness solved per + hue or a teal build quietly fails AA. Slate is the most interesting and the one + to prototype behind the theme toggle rather than commit to — a cool Hacker News is a + product decision, not a styling one.

    +
    +
    + +
    + HNES 2.0.0 · MV3 + Contrast computed per WCAG 2.1 relative luminance + Composes with any comment-UX direction + Proposal — nothing here is implemented +
    + +
    + + diff --git a/zip.sh b/zip.sh index 429e18b..6fd2b7a 100755 --- a/zip.sh +++ b/zip.sh @@ -1,8 +1,8 @@ #!/bin/bash #package for firefox -zip -r -FS ../HNES-firefox.zip * -x \*.git\* *screenshots\* *notes* *zip.sh* js/jquery-3.2.1.js +zip -r -FS ../HNES-firefox.zip * -x \*.git\* *screenshots\* *proposals\* *notes* *zip.sh* js/jquery-3.2.1.js #package for chrome web store cd .. -zip -r -FS HNES-chrome.zip HNES -x \*.git\* *screenshots* HNES/notes HNES/zip.sh HNES/js/jquery-3.2.1.js +zip -r -FS HNES-chrome.zip HNES -x \*.git\* *screenshots* *proposals* HNES/notes HNES/zip.sh HNES/js/jquery-3.2.1.js From 0c8ef5ad7b8d5ef864c9f020d378c89389e20e95 Mon Sep 17 00:00:00 2001 From: John Bolliger Date: Wed, 12 Aug 2026 21:43:07 -0700 Subject: [PATCH 03/20] Add a palette option: newsprint, ember, slate, letterpress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships all four of the visual overhauls as a runtime choice rather than picking one. 'classic' is the default and the unset state, so nothing changes for anyone who does not touch the control. The stylesheet's colour block is split into seeds and derived values first, because that is what makes a palette eleven declarations instead of thirty. Seeds are what a palette replaces; the derived layer is color-mix() over them and is never restated. Derive within a family, not from fg/bg globally. The obvious scheme — every neutral a percentage of fg into bg — was tried and measured, and it misses by an Oklab dE of 0.02 to 0.09: the light and dark values here were tuned independently and do not share proportions, most sharply on the comment fade ladder, where light fades to 9.8% of the foreground and dark stops at 33.6%. Anchoring each family on its own endpoints absorbs that, and one percentage then serves both themes. The fade ladder keeps its two ends as seeds for the same reason: light text on a dark ground loses legibility faster than the ratio predicts, so the shallower dark ladder is deliberate and a single percentage would flatten it. Percentages were fitted to the values this stylesheet shipped with and checked in a browser, not just on paper: every derived token lands within dE 0.019 of what it replaced, and the ladder stays monotonic toward the background in all five palettes and both themes. The four palettes take their seven slots from the write-ups verbatim and generate the remaining four per theme from the transform classic uses, so they inherit its intent rather than its hexes. All collapse brand into orange — each picked an accent that works as a header surface and as accent text, which is the job classic needs two oranges for. Contrast measured across all five palettes: every load-bearing pair clears AA, most AAA, none below classic. Two things the split turned up. --hnes-new-comment used to resolve to --hnes-brand, so restyling the brand silently redefined what "new" looks like; state colours are now split from the brand. And .title a.on_story carried a hardcoded #3986f8 that every palette would have fought, now --hnes-current. The control is a menu rather than a third cycling toggle, reusing the .nav-drop-down surface the user and "more" menus already use: five values is four clicks to reach the last one. MODES descriptors gained a `ui` field to pick between the two renderings; both write the same attribute and storage key. Co-Authored-By: Claude Opus 5 (1M context) --- js/boot.js | 9 ++- js/hn.js | 137 +++++++++++++++++++++++++-------- style.css | 222 +++++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 303 insertions(+), 65 deletions(-) diff --git a/js/boot.js b/js/boot.js index a594edc..1b9e8d8 100644 --- a/js/boot.js +++ b/js/boot.js @@ -7,10 +7,10 @@ * while hn.js rewrites it. Putting the flag here rather than in CSS means a * page where the content script never runs is never hidden at all, and the * stylesheet's failsafe animation reveals the page even if hn.js throws. - * - Apply the saved theme override and view density. The storage read is async, + * - Apply the saved theme, view density and palette. The storage read is async, * so it can land after paint; that is harmless because the body is still - * hidden, and an unset theme just falls through to prefers-color-scheme - * while an unset density falls through to comfortable. + * hidden, and an unset value falls through to the stylesheet's own default — + * prefers-color-scheme for theme, comfortable for density, classic for palette. */ (function () { var root = document.documentElement; @@ -27,7 +27,8 @@ */ var MODES = [ { key: 'hnesTheme', attr: 'data-hnes-theme', values: ['auto', 'light', 'dark'] }, - { key: 'hnesDensity', attr: 'data-hnes-density', values: ['comfortable', 'compact', 'flow'] } + { key: 'hnesDensity', attr: 'data-hnes-density', values: ['comfortable', 'compact', 'flow'] }, + { key: 'hnesPalette', attr: 'data-hnes-palette', values: ['classic', 'newsprint', 'ember', 'slate', 'letterpress'] } ]; try { diff --git a/js/hn.js b/js/hn.js index ee2888d..ee52ef1 100644 --- a/js/hn.js +++ b/js/hn.js @@ -924,26 +924,37 @@ var HN = { }, /* - * The nav's cycling preference toggles. Each descriptor is the whole - * definition of one toggle: values[0] is the unset state and clears the - * attribute, so "which values are real" is derived from the list rather - * than restated as a condition somewhere else. Adding a mode is one entry - * in `values` plus the matching CSS block — and the same list in boot.js, - * which runs as a separate content script and cannot read this one. + * The nav's preference controls. Each descriptor is the whole definition of + * one control: values[0] is the unset state and clears the attribute, so + * "which values are real" is derived from the list rather than restated as a + * condition somewhere else. Adding a mode is one entry in `values` plus the + * matching CSS block — and the same list in boot.js, which runs as a + * separate content script and cannot read this one. * - * theme: auto -> light -> dark. 'auto' lets prefers-color-scheme decide; - * the explicit modes pin color-scheme, which is what the - * stylesheet's light-dark() tokens resolve against. - * view: comfortable -> compact -> flow. compact shrinks the scale, flow - * drops the card chrome and keeps the type readable. + * `ui` picks the control, not the behaviour: both render from the same + * descriptor and write the same attribute and storage key. Cycling is right + * up to three values and stops being right past that, which is why palette + * is a menu — five values is four clicks to reach the last one. + * + * theme: auto -> light -> dark. 'auto' lets prefers-color-scheme decide; + * the explicit modes pin color-scheme, which is what the + * stylesheet's light-dark() tokens resolve against. + * view: comfortable -> compact -> flow. compact shrinks the scale, flow + * drops the card chrome and keeps the type readable. + * palette: swaps the stylesheet's colour seeds. Orthogonal to the other two + * by construction — palettes own colour tokens, view owns geometry + * tokens, and the sets do not intersect. */ MODES: [ - { key: 'hnesTheme', attr: 'data-hnes-theme', label: 'theme', + { key: 'hnesTheme', attr: 'data-hnes-theme', label: 'theme', ui: 'cycle', title: 'Switch colour theme', values: ['auto', 'light', 'dark'] }, - { key: 'hnesDensity', attr: 'data-hnes-density', label: 'view', + { key: 'hnesDensity', attr: 'data-hnes-density', label: 'view', ui: 'cycle', title: 'Switch row density', - values: ['comfortable', 'compact', 'flow'] } + values: ['comfortable', 'compact', 'flow'] }, + { key: 'hnesPalette', attr: 'data-hnes-palette', label: 'palette', ui: 'menu', + title: 'Switch colour palette', + values: ['classic', 'newsprint', 'ember', 'slate', 'letterpress'] } ], applyMode: function(spec, value) { @@ -952,11 +963,19 @@ var HN = { else root.removeAttribute(spec.attr); }, + /* The one write path for every control, so a new `ui` cannot forget half of + it: paint, then persist. */ + commitMode: function(spec, value) { + HN.applyMode(spec, value); + HN.setLocalStorage(spec.key, value); + }, + /* - * boot.js already applied both stored values before first paint, so the only - * job on load is labelling. One storage read covers every toggle: separate - * reads resolve in separate tasks, which cost an extra round trip and leave - * the toggles' left-to-right order up to whichever callback lands first. + * boot.js already applied every stored value before first paint, so the only + * job on load is building the controls. One storage read covers all of them: + * separate reads resolve in separate tasks, which cost an extra round trip + * and leave the controls' left-to-right order up to whichever callback lands + * first. */ initModeToggles: function() { var nav = $('#top-navigation .nav-links').first(); @@ -967,21 +986,77 @@ var HN = { // Index rather than name as state — the name is one lookup away and // values[0] is the fallback for anything unset or unrecognised. var i = Math.max(spec.values.indexOf(items[spec.key]), 0), - link = $('
    ').attr('href', 'javascript:void(0)').attr('title', spec.title), - wrap = $('').addClass('hnes-nav-toggle').text('|').append(link); - - link.text(spec.label + ': ' + spec.values[i]); - link.click(function() { - i = (i + 1) % spec.values.length; - HN.applyMode(spec, spec.values[i]); - link.text(spec.label + ': ' + spec.values[i]); - HN.setLocalStorage(spec.key, spec.values[i]); - }); - - // Appended here so a toggle never appears unlabelled and inert. - nav.append(wrap); + build = spec.ui === 'menu' ? HN.buildModeMenu : HN.buildModeCycle; + + // Appended already built, so a control never appears unlabelled and inert. + nav.append(build(spec, i)); + }); + }); + }, + + buildModeCycle: function(spec, i) { + var link = $('').attr('href', 'javascript:void(0)').attr('title', spec.title), + wrap = $('').addClass('hnes-nav-toggle').text('|').append(link); + + link.text(spec.label + ': ' + spec.values[i]); + link.click(function() { + i = (i + 1) % spec.values.length; + link.text(spec.label + ': ' + spec.values[i]); + HN.commitMode(spec, spec.values[i]); + }); + + return wrap; + }, + + /* + * Reuses .nav-drop-down, the surface the user and "more" menus already use, + * so the palette list inherits their placement, elevation and hover states + * rather than growing a second menu style. + */ + buildModeMenu: function(spec, i) { + var link = $('').attr('href', 'javascript:void(0)').attr('title', spec.title), + menu = $('
    ').addClass('nav-drop-down'), + wrap = $('').addClass('hnes-nav-toggle hnes-nav-menu more-arrow') + .text('|').append(link).append(menu), + close = function() { menu.hide(); link.removeClass('active'); }; + + link.text(spec.label + ': ' + spec.values[i]); + + spec.values.forEach(function(value, index) { + var option = $('').attr('href', 'javascript:void(0)').text(value); + if (index === i) option.addClass('nav-active-link'); + + option.click(function(e) { + e.stopPropagation(); + menu.find('a').removeClass('nav-active-link'); + option.addClass('nav-active-link'); + i = index; + link.text(spec.label + ': ' + value); + HN.commitMode(spec, value); + close(); }); + + menu.append(option); + }); + + link.click(function(e) { + e.stopPropagation(); + // Any other open menu closes first; two floating surfaces at once read + // as a rendering bug rather than as two menus. Their triggers have to + // lose .active with them — the older menus toggle that class blindly, so + // leaving it set desyncs their next click from what is on screen. + $('.nav-drop-down').not(menu).hide(); + $('.more-arrow > a.active').not(link).removeClass('active'); + menu.toggle(); + link.toggleClass('active', menu.is(':visible')); }); + + // Click-away, which the older menus never got. The stopPropagation calls + // above are what keep clicks inside the menu from reaching this. Namespaced + // so it can be unbound without disturbing other document click handlers. + $(document).on('click.hnesMode', close); + + return wrap; }, /* diff --git a/style.css b/style.css index 39d22ad..5d2bb00 100644 --- a/style.css +++ b/style.css @@ -11,12 +11,30 @@ * in a single light-dark() declaration, so a theme change is one edit, not two * palettes kept in sync by hand. The manual override works by flipping * color-scheme, which is what light-dark() resolves against. +* +* Colour is split in two: a SEED layer that a palette replaces wholesale, and a +* DERIVED layer expressed as color-mix() over the seeds, which no palette ever +* restates. That is what makes a palette eleven declarations instead of thirty. +* +* The mix percentages are fitted to the values this stylesheet shipped with, so +* the split changed nothing on screen: every derived token lands within an Oklab +* dE of 0.012 of what it replaced, bar two fade rungs at 0.017. +* +* Derive within a family, not from fg/bg globally. The obvious scheme — every +* neutral a percentage of fg into bg — was tried and abandoned: it misses by dE +* 0.02-0.09, because the light and dark values here were tuned independently and +* do not share proportions. Anchoring each family on its own endpoints absorbs +* that, and one percentage then serves both themes. * --------------------------------------------------------------------------- */ :root { color-scheme: light dark; + /* ========================================================================= + SEEDS — the palette surface. A palette block replaces exactly these. + ========================================================================= */ + /* * Two oranges, deliberately. --hnes-brand paints large surfaces (header, * buttons) and is burnt so it does not glare; --hnes-orange is the accent used @@ -24,68 +42,111 @@ * a dark background. HN's #ff6600 was doing both jobs and doing neither well: * as a surface it vibrated against white text, and as accent text on white it * only reached about 3:1. + * + * The palettes below collapse the two back together, because each of them + * picked an accent that can hold both jobs. Keeping the tokens separate is + * what lets them make that choice without the stylesheet caring. */ --hnes-brand: light-dark(#ab470a, #8f3b08); --hnes-orange: light-dark(#bd4f0d, #ff8f45); /* Warm off-white rather than pure white — full white on saturated orange is the "too strong" pairing that makes the header feel like it is buzzing. */ - --hnes-orange-ink: #fff3e9; - /* Header ink at two weights plus the hover wash. These were spelled out as - raw rgba() in six places, which meant the header could not be retinted - from the token block the way everything else can. */ - --hnes-header-ink: rgba(255, 243, 233, .92); - --hnes-header-ink-dim: rgba(255, 243, 233, .82); - --hnes-header-hover: rgba(0, 0, 0, .16); + --hnes-orange-ink: light-dark(#fff3e9, #fff3e9); /* surfaces */ --hnes-bg: light-dark(#f6f6ef, #15150f); --hnes-surface: light-dark(#fffffd, #1d1d16); - --hnes-surface-alt: light-dark(#eeeee4, #22221a); - --hnes-surface-hi: light-dark(#e4e4d6, #2b2b21); /* text */ --hnes-fg: light-dark(#1b1b19, #e8e8de); --hnes-fg-muted: light-dark(#6a6a63, #9a9a8e); - --hnes-fg-subtle: light-dark(#95958c, #6f6f66); + + /* lines */ + --hnes-border: light-dark(#dedad0, #33332a); + + /* + * The two ends of HN's comment fade scale; the eight rungs between them are + * derived. These are seeds rather than a proportion of fg into bg because the + * two themes disagree on purpose: light fades to 9.8% of the foreground, + * dark stops at 33.6%. Light text on a dark ground loses legibility faster + * than the ratio predicts, so the dark ladder is deliberately shallower, and + * a single percentage would flatten that distinction. + */ + --hnes-fade-strong: light-dark(#5a5a5a, #c6c6bc); + --hnes-fade-weak: light-dark(#dddddd, #53534e); + + /* Text selection: a wash of the accent over the page, but not on the + accent-into-bg line either theme would predict, so it is stated. */ + --hnes-selection: light-dark(#ffd9bf, #5a3410); + + /* ========================================================================= + DERIVED — formulas over the seeds. Palette-independent; never restated. + ========================================================================= */ + + /* Header ink at two weights plus the hover wash. Alpha over the ink token + rather than raw rgba(), which is what lets a palette retint the header. */ + --hnes-header-ink: color-mix(in srgb, var(--hnes-orange-ink) 92%, transparent); + --hnes-header-ink-dim: color-mix(in srgb, var(--hnes-orange-ink) 82%, transparent); + /* A black scrim, not a tint — palette-independent by design. */ + --hnes-header-hover: rgba(0, 0, 0, .16); + + /* The two intermediate surfaces sit between the page and its rules, so they + ride on bg->border rather than on bg->fg: the rule colour is what a palette + uses to say how much separation it wants. */ + --hnes-surface-alt: color-mix(in oklab, var(--hnes-bg) 61%, var(--hnes-border)); + --hnes-surface-hi: color-mix(in oklab, var(--hnes-bg) 31%, var(--hnes-border)); + + --hnes-fg-subtle: color-mix(in oklab, var(--hnes-fg-muted) 61%, var(--hnes-border)); + /* Maximum contrast, not a step on any ramp — the one text token that is + deliberately outside the palette. */ --hnes-fg-strong: light-dark(#000000, #ffffff); /* links */ - --hnes-link: light-dark(#1b1b19, #ddddd2); + --hnes-link: color-mix(in oklab, var(--hnes-fg) 98%, var(--hnes-bg)); --hnes-link-hover: var(--hnes-orange); - --hnes-visited: light-dark(#6a6a63, #8d8d82); + --hnes-visited: color-mix(in oklab, var(--hnes-fg) 60%, var(--hnes-bg)); /* lines */ - --hnes-border: light-dark(#dedad0, #33332a); --hnes-spine: var(--hnes-border); --hnes-spine-active: var(--hnes-orange); - /* semantic */ + /* + * State colours, split from the brand on purpose: --hnes-new-comment used to + * resolve to --hnes-brand, so restyling the brand silently redefined what + * "new" looked like. Palette-independent, and a palette may still override + * any of them — none has needed to. + */ --hnes-danger: light-dark(#c0392b, #ff7a6e); --hnes-new-user: light-dark(#2f8f4e, #57c47c); --hnes-new-comment: var(--hnes-orange); --hnes-new-parent: light-dark(#bc9b85, #7a6154); - --hnes-selection: light-dark(#ffd9bf, #5a3410); + /* "You are here" on the index. Blue rather than the accent on purpose — + sharing the accent would make the current story indistinguishable from an + unread one. Was a hardcoded #3986f8, which every palette would have fought. */ + --hnes-current: light-dark(#3986f8, #6aa8ff); /* * HN's comment fade scale. news.css ships .c00 (a normal comment) as pure * black and fades downvoted comments toward white, which only works on a * light background — on a dark one .c00 is unreadable and the scale runs - * backwards, leaving the most-downvoted comments the most prominent. These - * pairs keep HN's light values and mirror the fade for dark, so in both - * themes the scale runs from full contrast toward the page background. + * backwards, leaving the most-downvoted comments the most prominent. Running + * the ladder between the two fade seeds keeps the scale pointed at the page + * background in both themes, whatever the palette. */ --hnes-c00: var(--hnes-fg); - --hnes-c5a: light-dark(#5a5a5a, #c6c6bc); - --hnes-c73: light-dark(#737373, #adada4); - --hnes-c82: light-dark(#828282, #9b9b92); - --hnes-c88: light-dark(#888888, #909087); - --hnes-c9c: light-dark(#9c9c9c, #81817a); - --hnes-cae: light-dark(#aeaeae, #73736c); - --hnes-cbe: light-dark(#bebebe, #686861); - --hnes-cce: light-dark(#cecece, #5c5c56); - --hnes-cdd: light-dark(#dddddd, #53534e); - - /* heat scale on comment counts */ + --hnes-c5a: var(--hnes-fade-strong); + --hnes-c73: color-mix(in oklab, var(--hnes-fade-strong) 80%, var(--hnes-fade-weak)); + --hnes-c82: color-mix(in oklab, var(--hnes-fade-strong) 66%, var(--hnes-fade-weak)); + --hnes-c88: color-mix(in oklab, var(--hnes-fade-strong) 59%, var(--hnes-fade-weak)); + --hnes-c9c: color-mix(in oklab, var(--hnes-fade-strong) 45%, var(--hnes-fade-weak)); + --hnes-cae: color-mix(in oklab, var(--hnes-fade-strong) 32%, var(--hnes-fade-weak)); + --hnes-cbe: color-mix(in oklab, var(--hnes-fade-strong) 21%, var(--hnes-fade-weak)); + --hnes-cce: color-mix(in oklab, var(--hnes-fade-strong) 10%, var(--hnes-fade-weak)); + --hnes-cdd: var(--hnes-fade-weak); + + /* Heat scale on comment counts. The two middle steps are off the fg->orange + line — they carry more yellow than an interpolation would give them — so + they stay literal rather than being flattened into the ramp. */ --hnes-heat-0: var(--hnes-fg); --hnes-heat-1: light-dark(#8a5a00, #c8a25a); --hnes-heat-2: light-dark(#c25e00, #e2913f); @@ -172,6 +233,88 @@ :root[data-hnes-theme="dark"] { color-scheme: only dark; } +/* --------------------------------------------------------------------------- + Palettes, set by the palette control; 'classic' removes the attribute and + falls back to the seeds above. Each block is seeds only — the derived layer + picks the change up for free, which is the whole reason the split exists. + + Answering one question four ways: how much orange is Hacker News? Newsprint + keeps it as signal on warm paper, Ember makes everything one warm hue, Slate + goes cool and reads as a different product, Letterpress is a computed ramp at + hue 45. Full write-ups in proposals/visual-overhauls.html. + + All four collapse --hnes-brand into --hnes-orange: each picked an accent that + works as a header surface *and* as accent text, which is the job classic + needed two oranges for. Their --hnes-orange-ink flips to a dark value in dark + mode for the same reason — the header there is the bright accent, not a burnt + one, so the ink on it has to invert. + + :where() so these weigh (0,0,1), matching the :root they override and winning + on source order alone — same reason the density blocks use it, and what keeps + the responsive steps at the foot of the file working without knowing palettes + exist. + + Contrast measured per palette per theme on the eight load-bearing pairs; all + clear WCAG AA, most AAA, none below classic. + --------------------------------------------------------------------------- */ + +:root:where([data-hnes-palette="newsprint"]) { + --hnes-bg: light-dark(#fdfdfc, #0d0d0c); + --hnes-surface: light-dark(#ffffff, #151513); + --hnes-fg: light-dark(#111110, #f3f2ec); + --hnes-fg-muted: light-dark(#5b5b57, #a3a29b); + --hnes-border: light-dark(#e4e4de, #2a2a27); + --hnes-brand: light-dark(#c2450a, #ff7a33); + --hnes-orange: light-dark(#c2450a, #ff7a33); + --hnes-orange-ink: light-dark(#ffffff, #1a0d04); + --hnes-fade-strong: light-dark(#545453, #cdcdc7); + --hnes-fade-weak: light-dark(#e2e2e1, #504f4d); + --hnes-selection: light-dark(#f7dfd6, #4f2d1c); +} + +:root:where([data-hnes-palette="ember"]) { + --hnes-bg: light-dark(#fbf6f1, #14100c); + --hnes-surface: light-dark(#fffcf9, #1e1712); + --hnes-fg: light-dark(#241a12, #f0e6dc); + --hnes-fg-muted: light-dark(#6b5443, #b09681); + --hnes-border: light-dark(#e8dbcd, #33281f); + --hnes-brand: light-dark(#a8480c, #ff8f45); + --hnes-orange: light-dark(#a8480c, #ff8f45); + --hnes-orange-ink: light-dark(#fff4ea, #1a0d04); + --hnes-fade-strong: light-dark(#635a52, #ccc3ba); + --hnes-fade-weak: light-dark(#e3ded8, #554f48); + --hnes-selection: light-dark(#efd8cc, #54341f); +} + +:root:where([data-hnes-palette="slate"]) { + --hnes-bg: light-dark(#f1f3f4, #0f1214); + --hnes-surface: light-dark(#ffffff, #181d20); + --hnes-fg: light-dark(#14181a, #e3e8ea); + --hnes-fg-muted: light-dark(#5a656b, #96a0a6); + --hnes-border: light-dark(#d8dde0, #283035); + --hnes-brand: light-dark(#ae470b, #ff7a33); + --hnes-orange: light-dark(#ae470b, #ff7a33); + --hnes-orange-ink: light-dark(#ffffff, #14181a); + --hnes-fade-strong: light-dark(#535759, #c1c5c7); + --hnes-fade-weak: light-dark(#d8dbdc, #4d5153); + --hnes-selection: light-dark(#e9d6ce, #513123); +} + +:root:where([data-hnes-palette="letterpress"]) { + --hnes-bg: light-dark(#fefaf9, #0f0b09); + --hnes-surface: light-dark(#ffffff, #1b1613); + --hnes-fg: light-dark(#19120f, #f0ece9); + --hnes-fg-muted: light-dark(#6d605b, #aea29c); + --hnes-border: light-dark(#e6deda, #38312e); + --hnes-brand: light-dark(#c04800, #ff7a34); + --hnes-orange: light-dark(#c04800, #ff7a34); + --hnes-orange-ink: light-dark(#fff8f5, #21110a); + --hnes-fade-strong: light-dark(#5b5451, #cbc7c4); + --hnes-fade-weak: light-dark(#e5e0df, #514c49); + --hnes-selection: light-dark(#f7dcd3, #502b19); +} + + /* --------------------------------------------------------------------------- View modes, set by the density toggle; 'comfortable' removes the attribute and falls back to the :root defaults. @@ -510,6 +653,25 @@ html body .nav-drop-down a:hover { rules above already cover it; only the cursor needs saying. */ .hnes-nav-toggle > a { cursor: pointer; } +/* + * The palette control owns its dropdown, unlike #user-hidden which pins itself + * to the page edge — hence the positioned parent here and nowhere else. + * + * Right-aligned because this control is appended last and is therefore always + * the rightmost thing in the nav: opening leftward is the only direction that + * cannot push the menu off the viewport and reintroduce horizontal scroll. + */ +.hnes-nav-menu { position: relative; } +.hnes-nav-menu > .nav-drop-down { right: 0; } + +/* The .nav-active-link rule above only matches direct children of .nav-links, + so the selected row inside the menu needs its own mark. Weight rather than a + fill: a filled row next to the hover fill reads as two hovers. */ +html body .hnes-nav-menu .nav-drop-down a.nav-active-link { + color: var(--hnes-orange) !important; + font-weight: 700; +} + .mourning { border-top: 5px solid var(--hnes-fg-strong); } @@ -611,7 +773,7 @@ html body .title { } html body .title a:link, html body .title a:visited { color: var(--hnes-fg); } -.title a.on_story { border-left: 2px solid #3986f8 !important; } +.title a.on_story { border-left: 2px solid var(--hnes-current) !important; } .link-highlight { border: 1px dashed var(--hnes-border); From b13fcc44fcbf9328482ce7501b358d8092bd192b Mon Sep 17 00:00:00 2001 From: John Bolliger Date: Wed, 12 Aug 2026 21:44:17 -0700 Subject: [PATCH 04/20] Update the palette write-up to match what shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proposal's central claim — derive every neutral as a percentage of fg into bg — did not survive measurement, so the document was describing a scheme that is not in the code. Rewritten as a record of what was built: family-endpoint derivation, why the fade ladder keeps its ends as seeds, and the measured deltas. Also records the one defect the measurement turned up: --hnes-fg-subtle is at 2.81:1 in classic light against a 4.5 floor for text at its size. It predates this work and is left alone, because raising it collapses it into --hnes-fg-muted and the real fix is a design decision. Co-Authored-By: Claude Opus 5 (1M context) --- proposals/README.md | 23 ++-- proposals/palettes.md | 239 +++++++++++++++++++----------------------- 2 files changed, 120 insertions(+), 142 deletions(-) diff --git a/proposals/README.md b/proposals/README.md index f9392a4..9e5b5f9 100644 --- a/proposals/README.md +++ b/proposals/README.md @@ -23,7 +23,7 @@ it correct. Verified against live `news.ycombinator.com` markup on 2026-07-31. | Phase 2 — markup drift | Partly done — the comment fade-class and vote-arrow breakages are fixed; the positional table walks are unaudited | | Phase 3 — hygiene | Not started | | Design tracks | Proposals only | -| Palette as a user option | Proposal only — [`palettes.md`](./palettes.md) | +| Palette as a user option | **Implemented** — [`palettes.md`](./palettes.md) | Implemented work is verified but **not committed** — 23/23 migration unit tests, 7/7 in-browser migration, 11 page types loading clean, 0 horizontal overflow at @@ -214,15 +214,20 @@ fill fighting the text on it. Ember's ramp is worth building whichever look ship the one to prototype behind the theme toggle rather than commit to — a cool Hacker News is a product decision, not a styling one. -### Track B′ — ship all four as a user option +### Track B′ — all four shipped as a user option *(implemented)* -An alternative to picking one: [`palettes.md`](./palettes.md) -([hosted](https://claude.ai/code/artifact/af4f0fd4-4733-4b56-b0d0-b5905f2f653e)) proposes a third runtime axis, -`data-hnes-palette`, alongside the existing theme and density toggles. It costs one entry in -`HN.MODES` plus a matching one in `boot.js`, and roughly eight tokens per palette — but only -after the colour block is split into seeds and `color-mix()`-derived values, which is where -the actual work is. `classic` stays the default and the unset state, so nothing changes for -anyone who ignores the toggle. +Rather than picking one, all four ship behind a third runtime axis, `data-hnes-palette`, +alongside the theme and density toggles. `classic` is the default and the unset state, so +nothing changes for anyone who ignores the control. + +The colour block is split into eleven seeds per palette and a `color-mix()` derived layer +over them. The derivation runs **within each family, from that family's own endpoints** — +the obvious scheme of "every neutral a percentage of fg into bg" was measured and abandoned, +because the light and dark values here were tuned independently and do not share proportions. + +Write-up, measurements and the one known pre-existing contrast defect: +[`palettes.md`](./palettes.md) +([hosted](https://claude.ai/code/artifact/af4f0fd4-4733-4b56-b0d0-b5905f2f653e)). ## Verification diff --git a/proposals/palettes.md b/proposals/palettes.md index 6813d00..0a78d98 100644 --- a/proposals/palettes.md +++ b/proposals/palettes.md @@ -1,159 +1,132 @@ -# Ship all four palettes as a user option +# The palette option — newsprint, ember, slate, letterpress -## What this asks that Track B didn't +**Status: implemented.** All four ship as a runtime choice alongside the theme and +density axes. `classic` is the default and the unset state, so nothing changes for +anyone who ignores the control. -[`visual-overhauls.html`](./visual-overhauls.html) asked *which* of Newsprint, Ember, Slate -and Letterpress should become the look. This asks for all four, selectable at runtime — -which turns a design decision into a mechanism decision. The design work is already done and -measured; what follows is about making four palettes cost roughly what one costs. +This document started as a proposal to do it. The plan survived contact with the +numbers in outline and not in detail; what follows is what was actually built, with +the place the original plan was wrong called out, because it is the interesting part. -The extension already has two runtime axes on `` — `data-hnes-theme` (auto/light/dark) -and `data-hnes-density` (comfortable/compact/flow). A palette is a third axis of exactly the -same shape, and every piece of machinery it needs already exists. +## What it looks like -## The one real obstacle +A third `` attribute, `data-hnes-palette`, next to `data-hnes-theme` and +`data-hnes-density`. One entry in `HN.MODES` (`js/hn.js`), the mirrored entry in +`js/boot.js` so the choice applies before first paint, and one seed block per palette +in `style.css`. -The mockups define each palette as **7 slots** — bg, surface, fg, muted, rule, accent, -onaccent. `style.css` defines **75 tokens, 29 of them `light-dark()` colour pairs**. Written -out literally, four palettes is 116 hand-picked hex values, every one of which needs its own -contrast measurement. That is not a stylesheet anyone will keep correct. - -So the palettes are cheap only if the token block is first split into **seeds** and -**derived values**. That split is the bulk of the work, and it is worth doing on its own -merits — it is also, not coincidentally, what Slate's write-up was arguing for. +5 palettes × 3 densities × 3 themes is 45 combinations and zero combinatorial CSS, +because palettes own colour tokens, density owns geometry tokens, and the two sets do +not intersect. ---- +## The seed / derive split -## Step 1 — Seed / derive split *(prerequisite, no visible change)* +The four palettes define **7 slots** each. `style.css` had **29 `light-dark()` colour +pairs**. Written literally that is 116 hand-picked values, so the colour block was +first split into: -Today's `:root` block is partly derived already (`--hnes-c00: var(--hnes-fg)`, -`--hnes-heat-3: var(--hnes-orange)`, `--hnes-spine: var(--hnes-border)`). This finishes -the job. +- **Seeds** (11 pairs) — what a palette replaces: `bg`, `surface`, `fg`, `fg-muted`, + `border`, `brand`, `orange`, `orange-ink`, `fade-strong`, `fade-weak`, `selection`. +- **Derived** — `color-mix()` over the seeds, palette-independent, never restated: + `surface-alt`, `surface-hi`, `fg-subtle`, `link`, `visited`, the eight interior rungs + of the fade ladder, and the two header inks. -**Seeds** — the only thing a palette declares, ~8 `light-dark()` pairs: +### Where the original plan was wrong -| Seed | Why it can't be derived | -|---|---| -| `--hnes-bg`, `--hnes-surface` | the two grounds everything else mixes toward | -| `--hnes-fg` | the ink | -| `--hnes-brand`, `--hnes-orange`, `--hnes-orange-ink` | brand surface, accent, ink on brand | -| `--hnes-danger`, `--hnes-new-user` | independent hues — see below | +The proposal said: derive every neutral as a percentage of `fg` into `bg`, with the +fade ladder as the showcase — ten pairs collapsing into ten percentages. -**Derived** — one palette-independent block, `color-mix(in oklab, …)`: +Measured, that scheme misses by an Oklab dE of **0.02 to 0.09**. It fails because the +light and dark values in this stylesheet were tuned independently and do not share +proportions. The fade ladder is the sharpest case: light fades to 9.8% of the +foreground, dark stops at 33.6%. Deriving both from one percentage would have made +dark-mode downvoted comments dramatically dimmer — a visible redesign smuggled in +under a refactor. -`--hnes-surface-alt`, `--hnes-surface-hi`, `--hnes-border`, `--hnes-fg-muted`, -`--hnes-fg-subtle`, `--hnes-visited`, `--hnes-selection`, `--hnes-new-parent`, -`--hnes-heat-1/2`, `--hnes-header-ink*`, and the entire `c5a…cdd` fade ladder. +What works instead is **deriving within a family, from that family's own endpoints**. +Anchoring absorbs the light/dark divergence, and one percentage then serves both +themes: -The fade ladder is where this pays off most. The existing comment already says what those -ten pairs *are* — "the scale runs from full contrast toward the page background" — so state -it instead of restating it twenty times: - -```css ---hnes-c73: color-mix(in oklab, var(--hnes-fg) 60%, var(--hnes-bg)); -``` - -Ten pairs become ten percentages, correct in every palette and every theme for free. -The percentages get fitted to today's rendered values during implementation — sRGB hex to -an oklab mix ratio is not a clean linear map, so `[100, 72, 60, 54, 51, 44, 36, 30, 24, 18]` -is a starting ladder to be checked against the current output, not a claim. - -**Why `--hnes-danger` and `--hnes-new-user` become seeds rather than derivations:** this is -Slate's "split brand from state" argument, and it applies whichever palette ships. Today -`--hnes-new-comment` traces back to `--hnes-brand`, so any palette that moves the brand -silently changes what "new" looks like. Once the seeds are separate, a palette can move the -brand without moving the state colours — or move both deliberately. - -`color-mix()` and `oklch()` are Chrome 111+; the manifest floor is already 123. - -## Step 2 — The palette axis - -One entry in `HN.MODES` (`js/hn.js:929`) and the mirrored entry in `js/boot.js` — the -mirroring is deliberate and already documented in both files; skip the boot.js half and the -palette flashes to `classic` on every cold load. +| Token | Derivation | Worst dE | +|---|---|---| +| `surface-alt` | `bg` 61% into `border` | 0.008 | +| `surface-hi` | `bg` 31% into `border` | 0.008 | +| `fg-subtle` | `fg-muted` 61% into `border` | 0.004 | +| `visited` | `fg` 60% into `bg` | 0.007 | +| `c73`…`cce` | between `fade-strong` and `fade-weak` | 0.017 | -```js -{ key: 'hnesPalette', attr: 'data-hnes-palette', label: 'palette', - title: 'Switch colour palette', - values: ['classic', 'newsprint', 'ember', 'slate', 'letterpress'] } -``` +So the ladder's two ends stay seeds rather than becoming a proportion. That is not a +concession — light text on a dark ground loses legibility faster than the contrast +ratio predicts, so the shallower dark ladder is a deliberate call, and it now survives +into every palette instead of being flattened. -`values[0]` is the unset state and leaves the attribute off, per the existing convention — -so **`classic` is today's look and nobody who ignores the toggle sees any change.** -`HN.applyMode` and the storage write need no modification at all. +`border` and `fg-muted` refused to derive cleanly (best dE 0.012 and 0.015) and stayed +seeds. Both are slots the palettes supply anyway, as "rule" and "muted". -Each palette is then one seed block, weighted with `:where()` for the same reason the -density blocks are: +### What the palettes supply -```css -:root:where([data-hnes-palette="ember"]) { - --hnes-bg: light-dark(#fbf6f1, #14100c); - --hnes-surface: light-dark(#fffcf9, #1e1712); - --hnes-fg: light-dark(#241a12, #f0e6dc); - --hnes-orange: light-dark(#a8480c, #ff8f45); - /* …five more */ -} -``` +Seven slots come from `visual-overhauls.html` verbatim. The remaining four are +generated per theme by applying classic's own transform to that palette's colours, so +a new palette inherits classic's *intent* rather than its hexes. -Seed values for all four come straight out of `visual-overhauls.html:255-292`, where they -are already paired light/dark and already measured. +All four collapse `--hnes-brand` into `--hnes-orange`: each picked an accent that works +as a header surface *and* as accent text, which is the job classic needs two oranges +for. Their `--hnes-orange-ink` flips dark in dark mode for the same reason — the header +there is the bright accent, not a burnt one. An early pass derived `brand` by darkening +the accent the way classic does, which put light ink on a bright header and failed AA +at 2.0–2.5:1 in all four; the mockups' own pairing was right and the derivation was not. -**Orthogonality holds:** palettes own colour tokens, density owns geometry tokens, and the -two sets do not intersect. 5 palettes × 3 densities × 3 themes is 45 combinations and zero -combinatorial CSS. +## Two things the split turned up -## Step 3 — What deliberately does *not* come along - -- **Newsprint's "no card fills"** is `--hnes-com-fill: transparent` — which is already - `view: flow`. Keeping it there preserves the orthogonality; Newsprint-the-palette is its - colour half, and the documented recipe for the full look is **palette: newsprint + view: - flow**. Folding a geometry change into a palette would be the one thing that breaks the - axis model. -- **Newsprint's `data-hnes-contrast` axis** — defer. If it is wanted later it becomes a - multiplier on the derived mix percentages, which is only cheap *because* of step 1. -- **Ember's user-selectable `--hnes-hue`** — ship Ember at fixed hue 45. Ember's own - measurements say the accent lightness has to be solved per hue (a 30-entry table, because - the target chroma is unreachable at 19 of 36 sampled hues). A hue slider is its own - project; the ramp underneath it is what step 1 delivers. -- **Slate's semantic layer** — already absorbed into step 1, for every palette. +- `--hnes-new-comment` resolved to `--hnes-brand`, so restyling the brand silently + redefined what "new" looks like. State colours are now split from the brand. +- `.title a.on_story` carried a hardcoded `#3986f8` that every palette would have + fought. Now `--hnes-current`, still blue on purpose: sharing the accent would make + the current story indistinguishable from an unread one. -## Step 4 — Presentation - -Three cycling text toggles in a 13.5px nav, one of them cycling five values, is the wrong -control: four clicks to reach `letterpress`, and the label is long. +## The control -| Option | Cost | Trade | -|---|---|---| -| Cycle, like the other two | none | 4 clicks worst case, longest label in the nav | -| **Dropdown** reusing `.nav-drop-down` | small | one click to any palette; component exists at `js/hn.js:1659-1700`, styled at `style.css:476-507` | -| Options page (`options_ui`) | medium | conventional home for 3+ prefs, but a new surface, and `boot.js` still needs its own storage read | +A menu, not a third cycling toggle — five values is four clicks to reach the last one. +It reuses `.nav-drop-down`, the surface the user and "more" menus already use, so it +inherits their placement, elevation and hover states. `MODES` descriptors gained a `ui` +field (`cycle` or `menu`); both renderings write the same attribute and storage key. -**Recommendation: the dropdown.** Give the `MODES` descriptor a `ui` field (`cycle` or -`menu`); theme and density keep cycling, palette renders as a menu. The storage key, the -attribute write and `boot.js` are identical either way — only the rendering branch differs. +It is right-aligned because it is appended last and is therefore always the rightmost +thing in the nav: opening leftward is the only direction that cannot push the menu off +the viewport and reintroduce horizontal scroll. ## Verification -Per palette (×2 themes), only the **seeds** need measuring — every derived token is a mix of -two already-measured seeds: - -1. `--hnes-fg` on `--hnes-bg` and on `--hnes-surface`; `--hnes-orange-ink` on `--hnes-brand`; - `--hnes-orange` on `--hnes-bg`. Same twelve pairs this session already measured for classic. -2. Fade ladder renders monotonic `c00 → cdd`, and the steps at `c88` and below still clear the - floor for de-emphasised text — construction guarantees monotone lightness, not legibility. -3. **`setTopColor` (`js/hn.js:1809`)**: on HN's memorial days the header `bgcolor` is an inline - style that beats `--hnes-brand`. Confirm `--hnes-orange-ink` still reads on HN's tint in each - palette — this is the one place the token layer is not in charge. -4. Cold-load flash check per palette: hard reload with cache disabled, confirm no `classic` - frame — i.e. `boot.js` really did get the third entry. - -## Sequencing - -1. **Commit what exists first.** All of this lands on ~1,449 uncommitted lines (MV3 port + - stylesheet rebuild). A seed/derive refactor is a bad thing to have tangled with that diff. -2. Seed/derive split — no user-visible change, carries the real risk, own commit. -3. Palette axis + four seed blocks. -4. Menu UI. - -Steps 2-4 are each independently shippable; stopping after 2 still leaves the stylesheet -better than it is now. +Run against a real Chrome with the extension loaded, on live Hacker News. + +- **Token resolution** — custom properties are substitution-only, so reading them back + needs a probe element using each token in a real property, then a canvas to convert + the resulting `oklab()`/`color(srgb …)` to bytes. Every derived token lands within + dE 0.019 of the literal it replaced; the ladder is monotonic toward the background in + all five palettes and both themes. +- **Contrast** — every load-bearing pair clears AA across all five palettes and both + themes, most AAA, none below classic. +- **End to end** — the control builds with all five options; picking one writes the + attribute, relabels, closes the menu and persists; the choice survives a reload with + the attribute already set before the reveal; palette and density do not disturb each + other; no page errors on the index or a 300-comment thread. +- **The header** — `#header` resolves to the palette's brand on both page shapes + (classic `#8f3b08`, slate `#ff7a33` in dark), and HN's own `bgcolor="#ff6600"` does + not win. The remaining edge is `setTopColor` (`js/hn.js`), which writes an inline + style on HN's memorial days and is the one place the token layer is not in charge. + +## Known defect, pre-existing, not fixed here + +`--hnes-fg-subtle` measures **2.81:1 on the page background in classic light**, against +a 4.5:1 AA floor for text at its size (12px). It is the same in every palette +(2.85–3.23 light, 3.59–4.25 dark) because they all inherit the same relationship. + +It applies to the non-link words in the subtext line — "points by", "ago" — plus +`.paren`, `.hnes-age`, `.hnes-actions` separators and `.input-help`. The links in that +line are `--hnes-fg-muted` and pass at 5.02. + +This predates the palette work; the derived value is dE 0.003 from the literal it +replaced. It is not fixed here because the fix is a design decision, not a token edit: +raising `fg-subtle` to 4.5 collapses it into `fg-muted` and loses the distinction, so +the real options are to accept a smaller gap or to move the text uses of `fg-subtle` +onto `fg-muted` and keep `fg-subtle` for the non-text ones. From a054a281919a6c23c01730d14f00669fa3d2192b Mon Sep 17 00:00:00 2001 From: John Bolliger Date: Wed, 12 Aug 2026 22:03:55 -0700 Subject: [PATCH 05/20] Simplify the palette work after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four cleanup passes over the palette change. No behaviour change; the token harness and the end-to-end run give the same numbers as before. --hnes-brand was restated as a literal identical to --hnes-orange in all four palettes. The collapse is now one rule ahead of the blocks, keyed on the presence of the attribute — classic is the unset state, so having the attribute *is* "not classic". Retinting a palette is one edit again; restating the pair twice is how a header surface silently stops matching its accent in one theme. hn.js no longer re-reads the three mode keys boot.js already read. Content scripts of one extension share an isolated world, so boot.js publishes its result and hn.js consumes it — verified, not assumed. The round trip is the smaller half: initModeControls runs immediately before reveal(), so a fresh storage read landed after the page was visible and the nav visibly grew. A promise settled back in boot.js resolves in the same microtask checkpoint, so the controls arrive before first paint. The menu tracked its open state by asking jQuery ':visible', which measures the element and forces a synchronous layout of the whole document — costly on a long thread, for a fact the handler already knows. Now a local boolean. Also: `ui` was declared as a named control type but dispatched as menu-or-not, so MODE_UI makes it a real lookup; a dead `i = index` write in the menu builder, whose index is a starting selection rather than state; initModeToggles renamed to initModeControls now that one of them is not a toggle; and light-dark() with two identical arms reverted to the literal it was. Comment fixes where the prose was wrong rather than merely stale: the :where() specificity is (0,1,0), not (0,0,1) — :root is itself a pseudo-class — and the claim that boot.js and hn.js cannot share state was true only in the direction that runs first. The DERIVED banner said "formulas over the seeds" while half the block is palette-independent literals; it now says so, and they stay beside the ramps they belong to rather than being sorted into a section by mechanism. Co-Authored-By: Claude Opus 5 (1M context) --- js/boot.js | 37 ++++++++++++++++++-------- js/hn.js | 77 ++++++++++++++++++++++++++++++++++++++---------------- style.css | 56 +++++++++++++++++++++++++-------------- 3 files changed, 116 insertions(+), 54 deletions(-) diff --git a/js/boot.js b/js/boot.js index 1b9e8d8..4ea8fdf 100644 --- a/js/boot.js +++ b/js/boot.js @@ -19,8 +19,9 @@ root.classList.add('hnes-pending'); /* - * Mirrors HN.MODES in hn.js — deliberately, not accidentally: this is a - * separate content script at document_start, so it cannot read hn.js's copy. + * Mirrors HN.MODES in hn.js — deliberately, not accidentally. This script runs + * at document_start, before hn.js exists, so it cannot read hn.js's copy; the + * reverse direction does work, which is what window.hnesModes below is for. * Same convention, so the two stay comparable at a glance: values[0] is the * unset state and leaves the attribute off. Adding a mode means adding it in * both places, or it works after paint and flashes on every cold load. @@ -31,14 +32,28 @@ { key: 'hnesPalette', attr: 'data-hnes-palette', values: ['classic', 'newsprint', 'ember', 'slate', 'letterpress'] } ]; - try { - chrome.storage.local.get(MODES.map(function (m) { return m.key; }), function (items) { - MODES.forEach(function (m) { - var value = items && items[m.key]; - if (m.values.indexOf(value) > 0) root.setAttribute(m.attr, value); + /* + * Published for hn.js, which needs the same three values to label the nav + * controls: content scripts of one extension share an isolated world, so this + * saves a second round trip to the same keys. It matters beyond the trip — + * hn.js reveals the page immediately after building the controls, so a fresh + * storage read lands after the reveal and the controls visibly pop in, while a + * promise settled back here resolves in the same microtask checkpoint and they + * arrive before the first paint. Always assigned, and never rejects, so the + * consumer has one path rather than two. + */ + window.hnesModes = new Promise(function (resolve) { + try { + chrome.storage.local.get(MODES.map(function (m) { return m.key; }), function (items) { + MODES.forEach(function (m) { + var value = items && items[m.key]; + if (m.values.indexOf(value) > 0) root.setAttribute(m.attr, value); + }); + resolve(items || {}); }); - }); - } catch (e) { - /* Storage unavailable — prefers-color-scheme and comfortable still apply. */ - } + } catch (e) { + /* Storage unavailable — the stylesheet's own defaults still apply. */ + resolve({}); + } + }); })(); diff --git a/js/hn.js b/js/hn.js index ee52ef1..10881b4 100644 --- a/js/hn.js +++ b/js/hn.js @@ -928,13 +928,15 @@ var HN = { * one control: values[0] is the unset state and clears the attribute, so * "which values are real" is derived from the list rather than restated as a * condition somewhere else. Adding a mode is one entry in `values` plus the - * matching CSS block — and the same list in boot.js, which runs as a - * separate content script and cannot read this one. + * matching CSS block — and the same list in boot.js, which runs first and so + * cannot read this one. * - * `ui` picks the control, not the behaviour: both render from the same - * descriptor and write the same attribute and storage key. Cycling is right - * up to three values and stops being right past that, which is why palette - * is a menu — five values is four clicks to reach the last one. + * `ui` names an entry in MODE_UI, so a third kind of control is a builder + * plus a data change rather than another branch. It picks the control, not + * the behaviour: every rendering writes the same attribute and storage key + * through commitMode. Cycling is right up to three values and stops being + * right past that, which is why palette is a menu — five values is four + * clicks to reach the last one. * * theme: auto -> light -> dark. 'auto' lets prefers-color-scheme decide; * the explicit modes pin color-scheme, which is what the @@ -972,21 +974,33 @@ var HN = { /* * boot.js already applied every stored value before first paint, so the only - * job on load is building the controls. One storage read covers all of them: - * separate reads resolve in separate tasks, which cost an extra round trip - * and leave the controls' left-to-right order up to whichever callback lands - * first. + * job on load is building the controls. + * + * The values come from boot.js's read rather than a second one. Beyond + * saving the round trip, it is what keeps the controls out of the reveal: + * initModeControls is called immediately before HN.reveal(), so a fresh + * storage read would land after the page is visible and the nav would + * visibly grow. boot.js's promise is already settled by document_end, so + * .then runs in this task's microtask checkpoint — before the first paint. + * + * The fallback covers hn.js running somewhere boot.js does not; today the + * manifest injects boot.js on the HN hosts only, and initModeControls is + * reached on those alone, but the guard costs one line. */ - initModeToggles: function() { + initModeControls: function() { var nav = $('#top-navigation .nav-links').first(); if (!nav.length) return; - chrome.storage.local.get(HN.MODES.map(function(spec) { return spec.key; }), function(items) { + var stored = window.hnesModes || new Promise(function(resolve) { + chrome.storage.local.get(HN.MODES.map(function(spec) { return spec.key; }), resolve); + }); + + stored.then(function(items) { HN.MODES.forEach(function(spec) { - // Index rather than name as state — the name is one lookup away and - // values[0] is the fallback for anything unset or unrecognised. + // The starting selection, by index — values[0] is the fallback for + // anything unset or unrecognised. var i = Math.max(spec.values.indexOf(items[spec.key]), 0), - build = spec.ui === 'menu' ? HN.buildModeMenu : HN.buildModeCycle; + build = HN.MODE_UI[spec.ui]; // Appended already built, so a control never appears unlabelled and inert. nav.append(build(spec, i)); @@ -994,6 +1008,8 @@ var HN = { }); }, + /* `i` is genuinely state here — each click reads it, advances it and writes + it back. The menu below only needs it as a starting selection. */ buildModeCycle: function(spec, i) { var link = $('').attr('href', 'javascript:void(0)').attr('title', spec.title), wrap = $('').addClass('hnes-nav-toggle').text('|').append(link); @@ -1018,7 +1034,8 @@ var HN = { menu = $('
    ').addClass('nav-drop-down'), wrap = $('').addClass('hnes-nav-toggle hnes-nav-menu more-arrow') .text('|').append(link).append(menu), - close = function() { menu.hide(); link.removeClass('active'); }; + open = false, + close = function() { open = false; menu.hide(); link.removeClass('active'); }; link.text(spec.label + ': ' + spec.values[i]); @@ -1030,7 +1047,6 @@ var HN = { e.stopPropagation(); menu.find('a').removeClass('nav-active-link'); option.addClass('nav-active-link'); - i = index; link.text(spec.label + ': ' + value); HN.commitMode(spec, value); close(); @@ -1047,13 +1063,19 @@ var HN = { // leaving it set desyncs their next click from what is on screen. $('.nav-drop-down').not(menu).hide(); $('.more-arrow > a.active').not(link).removeClass('active'); - menu.toggle(); - link.toggleClass('active', menu.is(':visible')); + // Tracked rather than read back off the DOM: jQuery's :visible measures + // the element, which forces a synchronous layout of the whole document — + // expensive on a long thread, and for a fact we already know. + open = !open; + menu.toggle(open); + link.toggleClass('active', open); }); - // Click-away, which the older menus never got. The stopPropagation calls - // above are what keep clicks inside the menu from reaching this. Namespaced - // so it can be unbound without disturbing other document click handlers. + // Click-away, which the older menus never got. The trigger's + // stopPropagation is the load-bearing one — without it, opening the menu + // would immediately close it again. The options' call is belt-and-braces: + // they close explicitly, so bubbling here would be harmless. Namespaced so + // it can be unbound without disturbing other document click handlers. $(document).on('click.hnesMode', close); return wrap; @@ -2046,6 +2068,15 @@ var HN = { } } +/* Keyed by a descriptor's `ui`, so a third kind of control is an entry here and + a value there rather than another branch in initModeControls. Out here rather + than inside the literal above because the builders it points at are members of + that literal, and HN is not bound until it closes. */ +HN.MODE_UI = { + cycle: HN.buildModeCycle, + menu: HN.buildModeMenu +}; + //show new comment count on hckrnews.com if (window.location.host == "hckrnews.com") { @@ -2096,7 +2127,7 @@ else { }); } - HN.initModeToggles(); + HN.initModeControls(); HN.reveal(); }); } diff --git a/style.css b/style.css index 5d2bb00..2efcdce 100644 --- a/style.css +++ b/style.css @@ -14,7 +14,7 @@ * * Colour is split in two: a SEED layer that a palette replaces wholesale, and a * DERIVED layer expressed as color-mix() over the seeds, which no palette ever -* restates. That is what makes a palette eleven declarations instead of thirty. +* restates. That is what makes a palette ten declarations instead of thirty. * * The mix percentages are fitted to the values this stylesheet shipped with, so * the split changed nothing on screen: every derived token lands within an Oklab @@ -50,8 +50,11 @@ --hnes-brand: light-dark(#ab470a, #8f3b08); --hnes-orange: light-dark(#bd4f0d, #ff8f45); /* Warm off-white rather than pure white — full white on saturated orange is - the "too strong" pairing that makes the header feel like it is buzzing. */ - --hnes-orange-ink: light-dark(#fff3e9, #fff3e9); + the "too strong" pairing that makes the header feel like it is buzzing. + One value for both themes only because classic's brand is burnt in both; + the palettes below do split it, since their header is the bright accent in + dark and the ink on it has to invert. */ + --hnes-orange-ink: #fff3e9; /* surfaces */ --hnes-bg: light-dark(#f6f6ef, #15150f); @@ -80,7 +83,14 @@ --hnes-selection: light-dark(#ffd9bf, #5a3410); /* ========================================================================= - DERIVED — formulas over the seeds. Palette-independent; never restated. + DERIVED — everything below is palette-independent and never restated by a + palette block. Two kinds live here: color-mix() formulas over the seeds, + which move when a seed moves, and fixed literals that answer to nothing + (--hnes-fg-strong, --hnes-danger, --hnes-new-user, --hnes-new-parent, + --hnes-current, --hnes-header-hover, --hnes-heat-1/2). The literals stay + beside the formulas they ramp with rather than in a section of their own — + splitting the heat scale or the state colours down the middle to sort them + by mechanism would cost more than it explains. ========================================================================= */ /* Header ink at two weights plus the hover wash. Alpha over the ink token @@ -245,26 +255,34 @@ All four collapse --hnes-brand into --hnes-orange: each picked an accent that works as a header surface *and* as accent text, which is the job classic - needed two oranges for. Their --hnes-orange-ink flips to a dark value in dark - mode for the same reason — the header there is the bright accent, not a burnt - one, so the ink on it has to invert. - - :where() so these weigh (0,0,1), matching the :root they override and winning - on source order alone — same reason the density blocks use it, and what keeps - the responsive steps at the foot of the file working without knowing palettes - exist. + needed two oranges for. That collapse is the rule below rather than a line in + each block, so retinting a palette is one edit — restating the same literal + twice per palette is how a header surface silently stops matching its accent. + Their --hnes-orange-ink flips to a dark value in dark mode for the same + reason — the header there is the bright accent, not a burnt one, so the ink + on it has to invert. + + :where() so these weigh (0,1,0) — the same as the bare :root they override, + since :root is itself a pseudo-class — winning on source order alone. Same + reason the density blocks use it, and what keeps the responsive steps at the + foot of the file working without knowing palettes exist. Contrast measured per palette per theme on the eight load-bearing pairs; all clear WCAG AA, most AAA, none below classic. --------------------------------------------------------------------------- */ +/* classic is the unset state — applyMode and boot.js only ever set the + attribute for values past the first — so the presence of the attribute is + exactly "some palette other than classic". Ahead of the blocks below, so a + future palette that wants its two oranges back can just say so. */ +:root:where([data-hnes-palette]) { --hnes-brand: var(--hnes-orange); } + :root:where([data-hnes-palette="newsprint"]) { --hnes-bg: light-dark(#fdfdfc, #0d0d0c); --hnes-surface: light-dark(#ffffff, #151513); --hnes-fg: light-dark(#111110, #f3f2ec); --hnes-fg-muted: light-dark(#5b5b57, #a3a29b); --hnes-border: light-dark(#e4e4de, #2a2a27); - --hnes-brand: light-dark(#c2450a, #ff7a33); --hnes-orange: light-dark(#c2450a, #ff7a33); --hnes-orange-ink: light-dark(#ffffff, #1a0d04); --hnes-fade-strong: light-dark(#545453, #cdcdc7); @@ -278,7 +296,6 @@ --hnes-fg: light-dark(#241a12, #f0e6dc); --hnes-fg-muted: light-dark(#6b5443, #b09681); --hnes-border: light-dark(#e8dbcd, #33281f); - --hnes-brand: light-dark(#a8480c, #ff8f45); --hnes-orange: light-dark(#a8480c, #ff8f45); --hnes-orange-ink: light-dark(#fff4ea, #1a0d04); --hnes-fade-strong: light-dark(#635a52, #ccc3ba); @@ -292,7 +309,6 @@ --hnes-fg: light-dark(#14181a, #e3e8ea); --hnes-fg-muted: light-dark(#5a656b, #96a0a6); --hnes-border: light-dark(#d8dde0, #283035); - --hnes-brand: light-dark(#ae470b, #ff7a33); --hnes-orange: light-dark(#ae470b, #ff7a33); --hnes-orange-ink: light-dark(#ffffff, #14181a); --hnes-fade-strong: light-dark(#535759, #c1c5c7); @@ -306,7 +322,6 @@ --hnes-fg: light-dark(#19120f, #f0ece9); --hnes-fg-muted: light-dark(#6d605b, #aea29c); --hnes-border: light-dark(#e6deda, #38312e); - --hnes-brand: light-dark(#c04800, #ff7a34); --hnes-orange: light-dark(#c04800, #ff7a34); --hnes-orange-ink: light-dark(#fff8f5, #21110a); --hnes-fade-strong: light-dark(#5b5451, #cbc7c4); @@ -326,7 +341,7 @@ comment page nearly all the wasted height is decoration rather than text, so flow lands close to compact's density without shrinking a single glyph. - Both blocks are wrapped in :where() so they weigh (0,0,1) — the same as the + Both blocks are wrapped in :where() so they weigh (0,1,0) — the same as the :root they override, winning only on source order. That is what lets the responsive blocks at the foot of the file retune a density-owned token without knowing density exists; at plain [data-hnes-density] specificity @@ -657,9 +672,10 @@ html body .nav-drop-down a:hover { * The palette control owns its dropdown, unlike #user-hidden which pins itself * to the page edge — hence the positioned parent here and nowhere else. * - * Right-aligned because this control is appended last and is therefore always - * the rightmost thing in the nav: opening leftward is the only direction that - * cannot push the menu off the viewport and reintroduce horizontal scroll. + * Right-aligned so the panel opens leftward, which is the direction that cannot + * push it off the viewport and reintroduce horizontal scroll: a nav control is + * never near the left edge, but this one is appended last and can sit close to + * the right one. */ .hnes-nav-menu { position: relative; } .hnes-nav-menu > .nav-drop-down { right: 0; } From c6a283e9c4061b6f239c5c8c09de32c544541684 Mon Sep 17 00:00:00 2001 From: John Bolliger Date: Wed, 12 Aug 2026 22:23:19 -0700 Subject: [PATCH 06/20] Fix the login-page throw, and keep fg-subtle off actual words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes that came out of driving the extension over every page type. /login threw on an unguarded deref of the submit button. HN serves that URL with no form far more often than it looks — a 429 while you are being rate limited, an error body, an already-logged-in redirect — and this reproduced first try during the page sweep. The throw stopped hn.js before reveal(), so the page sat blank for the full two seconds until the stylesheet's failsafe animation fired, and then showed a half-rewritten login form. It now returns before touching the document, so HN's own page is left standing: verified against a live 429, which goes from a two-second blank to visible at 300ms with no error. doCreateAccount got the same guard on its own submit button; the early-return shape is the one that function already used. --hnes-fg-subtle measured 2.81:1 in classic light, against the 4.5 floor for text at the 12px it was used at, and every palette inherits the same relationship. It is now scoped to things that carry no information — the "(" and ")" around a domain, the "|" and "[ ]" between actions, the tooltip arrow, the voted dash — while anything a reader actually reads takes --hnes-fg-muted at 5.02. That keeps two real steps; raising the token instead would have put it on top of fg-muted with no second step left. The .hnes-actions brackets needed their own rule so moving the container did not drag them up with it. Co-Authored-By: Claude Opus 5 (1M context) --- js/hn.js | 28 ++++++++++++++++++++++++---- style.css | 25 ++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/js/hn.js b/js/hn.js index 10881b4..cbd4720 100644 --- a/js/hn.js +++ b/js/hn.js @@ -1112,6 +1112,21 @@ var HN = { }, doLogin: function() { + /* + * HN serves this URL with no form more often than it looks: a 429 while + * you are being rate limited, an error body, an already-logged-in + * redirect. Everything below assumes the form and its submit button, so + * bail before touching the document rather than rewriting half of it. + * + * Reaching the deref used to throw, which stopped hn.js before reveal() + * and left the page blank until the stylesheet's failsafe animation fired + * two seconds later — so the symptom was a long blank page followed by a + * half-built one, on the page where a user is least able to guess why. + * Same early-return shape doCreateAccount already uses below. + */ + var submitButton = $('form input[type="submit"]').get(0); + if (!submitButton) return; + $('body').attr('id', 'login-body'); document.title = "Login | Hacker News"; @@ -1128,7 +1143,7 @@ var HN = { // remove login header, submit button (will be re-added later) $('body > b:first').remove(); - var buttonHtml = $('form input[type="submit"]').get(0).outerHTML; + var buttonHtml = submitButton.outerHTML; $('form:first input[type=submit]').remove(); var headerHtml = '
    top|new|best
    '; @@ -1173,9 +1188,14 @@ var HN = { // rebuild title/form inside the existing table $('tr#content > td:last').append(formContent); - var buttonHtml = $('#register-form > input[type="submit"]').get(0).outerHTML; - $('#register-form > input[type="submit"]').remove(); - $('#register-form tr:last').after('' + buttonHtml + ''); + + // Same reason as doLogin: a create-account form without a submit button + // is markup we do not recognise, and the heading is still worth adding. + var submitButton = $('#register-form > input[type="submit"]').get(0); + if (submitButton) { + $('#register-form > input[type="submit"]').remove(); + $('#register-form tr:last').after('' + submitButton.outerHTML + ''); + } $('#register-form').before('

    Create Account

    '); }, diff --git a/style.css b/style.css index 2efcdce..54ad38e 100644 --- a/style.css +++ b/style.css @@ -106,6 +106,15 @@ --hnes-surface-alt: color-mix(in oklab, var(--hnes-bg) 61%, var(--hnes-border)); --hnes-surface-hi: color-mix(in oklab, var(--hnes-bg) 31%, var(--hnes-border)); + /* + * Not for words. This lands around 2.8:1 on the page in light mode, against + * the 4.5:1 WCAG floor for text at the sizes it gets used at, so it is scoped + * to things that carry no information: the "(" and ")" around a domain, the + * "|" and "[ ]" between actions, the tooltip arrow, the voted dash. Anything + * a reader actually reads uses --hnes-fg-muted, which clears AA in every + * palette. Reaching 4.5 here would put it on top of --hnes-fg-muted and there + * would be no second step left. + */ --hnes-fg-subtle: color-mix(in oklab, var(--hnes-fg-muted) 61%, var(--hnes-border)); /* Maximum contrast, not a step on any ramp — the one text token that is deliberately outside the palette. */ @@ -823,10 +832,13 @@ td #more.link-highlight { =========================================================================== */ .submitter { margin-left: var(--hnes-gap-1); } +/* The words here — "points by", "ago" — are real text at 12px, so they take the + muted step. The links in this line are already muted (below); only the + punctuation between them stays subtle. */ html body .subtext, html body .subtext td, html body .submitter { - color: var(--hnes-fg-subtle); + color: var(--hnes-fg-muted); font-family: var(--hnes-font); font-size: var(--hnes-size-xs) !important; padding-bottom: var(--hnes-gap-3); @@ -855,12 +867,17 @@ html body .default { .hnes-actions { margin-left: .5em; font-size: var(--hnes-size-xs); - color: var(--hnes-fg-subtle); + color: var(--hnes-fg-muted); } +/* The frame around the actions is punctuation, so it keeps the subtle step + while the words it wraps take the muted one — otherwise moving the container + would drag the brackets up with it and flatten the two apart. */ .hnes-actions a:after { content: " | "; color: var(--hnes-fg-subtle); } +.hnes-actions:before, +.hnes-actions:after { color: var(--hnes-fg-subtle); } .hnes-actions a:last-child:after { content: ""; } .hnes-actions:before { content: "[ "; } .hnes-actions:after { content: " ]"; } @@ -1329,7 +1346,7 @@ form[action="//hn.algolia.com/"] { margin: 0 auto; } .reply_form { width: 100%; max-width: 640px; } .reply_form input[type="submit"] { margin: 3px; } a[href^="reply"]:visited { color: var(--hnes-link); } -.input-help { color: var(--hnes-fg-subtle); font-size: var(--hnes-size-xs); } +.input-help { color: var(--hnes-fg-muted); font-size: var(--hnes-size-xs); } /* Submit page */ form[action="/r"] table { width: 100%; max-width: 440px; } @@ -1442,6 +1459,8 @@ body#login-body tr#content form td:first-child { /* news.css sets .yclinks{font-size:8pt}; the colour below was already armored with !important but the font-size next to it was not. */ +/* Colour lands on the " | " text nodes only — every word in here is a link and + takes the link colour — so this one stays subtle. */ html body .yclinks { display: block; padding: var(--hnes-gap-3); From 43e6077f5f603dfb9c75d3502ca0cdec5060a7b4 Mon Sep 17 00:00:00 2001 From: John Bolliger Date: Wed, 12 Aug 2026 22:38:29 -0700 Subject: [PATCH 07/20] Add browser-driven tests for the parts that kept breaking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo has never had a test suite, and the verification checklist in proposals/README.md is a list of things to remember to do by hand. These four run. migration.mjs is the one that matters. The MV2 -> MV3 storage migration gets exactly one attempt per user; if it fails their tags, vote counts and read positions are gone and no later fix recovers them. Chrome will not load an MV2 extension any more, so the MV2 half is simulated where it counts — the legacy data lived in localStorage on the extension origin and that origin is unchanged across the upgrade — while the upgrade itself is real, driven by bumping the manifest version between two launches of one profile so Chrome fires onInstalled({reason:'update'}). On Chrome that also exercises the offscreen document, since a service worker cannot read localStorage directly. It checks the legacy bare-number conversion, that existing keys are not clobbered, the expiry sweep, and that localStorage survives so a failed run can retry. tokens.mjs checks every derived colour token against the literal it replaced, the fade ladder's monotonicity in all five palettes and both themes, and contrast. Each pair carries its own floor and the reason for it: fg-subtle and the border are not text and are checked only for being visible, so the run either says all 110 combinations pass or names the ones that do not. A harness that always prints failures is one nobody reads. pages.mjs walks every page type logged out. Its README warns to read the http column first: HN rate-limits a fast sweep, and a 429 body has no form and no story rows, so it can pass or fail the assertions for reasons that have nothing to do with the extension. A row that is not 200 is untested, not passing. That is not hypothetical — a 429 body is exactly what surfaced the /login throw. controls.mjs drives the nav controls: the palette menu opens, persists, survives a reload with the attribute set before the reveal, and does not interact with density. zip.sh excludes test/ from both packages, verified by inspecting the built archive with a node_modules present — it contains only the extension. Co-Authored-By: Claude Opus 5 (1M context) --- test/.gitignore | 4 + test/README.md | 79 ++++++++++++++++++ test/controls.mjs | 99 ++++++++++++++++++++++ test/migration.mjs | 123 +++++++++++++++++++++++++++ test/package.json | 15 ++++ test/pages.mjs | 99 ++++++++++++++++++++++ test/tokens.mjs | 203 +++++++++++++++++++++++++++++++++++++++++++++ zip.sh | 4 +- 8 files changed, 624 insertions(+), 2 deletions(-) create mode 100644 test/.gitignore create mode 100644 test/README.md create mode 100644 test/controls.mjs create mode 100644 test/migration.mjs create mode 100644 test/package.json create mode 100644 test/pages.mjs create mode 100644 test/tokens.mjs diff --git a/test/.gitignore b/test/.gitignore new file mode 100644 index 0000000..eeac99e --- /dev/null +++ b/test/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +package-lock.json +screenshots/ +.migtest/ diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..862325a --- /dev/null +++ b/test/README.md @@ -0,0 +1,79 @@ +# Tests + +There is no unit-test suite — almost everything HNES does is rewriting a page it +does not control, so the useful tests drive a real Chrome with the extension +loaded. These four cover what manual checking kept missing. + +```sh +cd test && npm install # playwright only +npm run migration # the one that cannot be redone +npm run tokens # colour tokens, contrast, fade ladder +npm run controls # nav controls, persistence, orthogonality +npm run pages # every page type, logged out +``` + +Screenshots land in `test/screenshots/`. + +## migration.mjs — run this before any release that changes storage + +The MV2 → MV3 storage migration gets exactly one attempt per user: if it fails, +their tags, vote counts and read positions are gone and no later fix recovers +them. + +Chrome will not load an MV2 extension any more, so the MV2 half is simulated +where it matters — the legacy data lived in `localStorage` on the extension +origin, and that origin does not change across the upgrade, so seeding it from +an extension page is the same starting state the real upgrade sees. The upgrade +itself is real: the manifest version is bumped between two launches of one +profile, so Chrome fires `onInstalled({reason: 'update'})`, which is the trigger +the shipping code hangs off. On Chrome that also exercises the offscreen +document, since a service worker cannot read `localStorage` directly. + +Checks: legacy bare-number vote counts convert, the object form survives +untouched, **keys the new build already wrote are not clobbered**, live thread +read-state is kept, stale entries are swept, string flags are not eaten as +values, and `localStorage` is left intact so a failed run can retry. + +## tokens.mjs — colour, in a browser rather than on paper + +Custom properties are substitution-only, so reading one back gives unresolved +text; it has to be used in a real property and then converted through a canvas, +because Chrome hands back `oklab()` / `color(srgb …)`. + +Checks every derived token against the literal it replaced (worst drift should +stay under an Oklab dE of 0.02), that the comment fade ladder stays monotonic +toward the page background in all five palettes and both themes, and contrast on +the load-bearing pairs. + +Two expected non-failures in its output: `border / bg` is a hairline, not text, +and `fg-subtle / bg` is scoped to punctuation that carries no information — see +the comment on `--hnes-fg-subtle` in `style.css`. + +## controls.mjs — the nav controls end to end + +Builds the controls, opens the palette menu, picks one, and checks the attribute +is written, the label updates, the menu closes and the choice persists across a +reload with the attribute set *before* the reveal. Also checks palette and +density do not disturb each other. + +## pages.mjs — every page type, logged out + +Walks the index variants, a comment thread, a poll, a user page, `/threads`, +`/login`, `/submit`, and page 2, checking each one reveals, gets styled, builds +its controls, and throws nothing. These are the pages built on positional table +walks, so they are where a throw actually lands. + +**Read the `http` column before believing a row.** HN rate-limits a fast sweep, +and a 429 body is not a page type — it has no form and no story rows, so it can +look like a clean page or like a broken one depending on what you assert. Any +row that is not 200 is untested, not passing. The script paces itself, but on a +warm rate limiter you may need to rerun the stragglers later. + +That rate limiting is worth keeping in mind rather than working around: a 429 +body with no form is exactly what used to make `/login` throw. + +## What these do not cover + +Logged-in flows — voting, tagging, inline replies, `/threads` with real content — +all need a session, so they are still manual. So is Firefox, which loads the +same manifest as an event page (`about:debugging` → Load Temporary Add-on). diff --git a/test/controls.mjs b/test/controls.mjs new file mode 100644 index 0000000..e3260d3 --- /dev/null +++ b/test/controls.mjs @@ -0,0 +1,99 @@ +/* + * Load the unpacked extension into a real Chrome and drive the palette control + * on a live Hacker News page. Checks the parts the token harness cannot: that + * the nav control is built, that clicking an option writes the attribute and + * persists it, and that the choice survives a reload without a flash of classic. + */ +import { chromium } from 'playwright'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { dirname, join as pjoin } from 'path'; +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const SHOTS = pjoin(ROOT, 'test', 'screenshots'); + + +const EXT = ROOT; +const userDataDir = mkdtempSync(join(tmpdir(), 'hnes-')); + +const ctx = await chromium.launchPersistentContext(userDataDir, { + channel: 'chromium', + args: [`--disable-extensions-except=${EXT}`, `--load-extension=${EXT}`], +}); + +const page = await ctx.newPage(); +const errors = []; +page.on('pageerror', e => errors.push(String(e))); +page.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); + +await page.goto('https://news.ycombinator.com/', { waitUntil: 'domcontentloaded' }); +await page.waitForTimeout(2500); + +const shot = p => page.screenshot({ path: `${SHOTS}/${p}`, fullPage: false }); + +// 1. did the rewrite finish, and is the page actually visible? +const state = await page.evaluate(() => ({ + pending: document.documentElement.classList.contains('hnes-pending'), + visible: getComputedStyle(document.body).visibility, + toggles: [...document.querySelectorAll('.hnes-nav-toggle > a')].map(a => a.textContent), + menuOptions: [...document.querySelectorAll('.hnes-nav-menu .nav-drop-down a')].map(a => a.textContent), + rows: document.querySelectorAll('tr.athing').length, +})); +console.log('after load:', JSON.stringify(state, null, 2)); +await shot('01-classic.png'); + +// 2. open the palette menu and pick ember +await page.click('.hnes-nav-menu > a'); +await page.waitForTimeout(200); +const menuVisible = await page.isVisible('.hnes-nav-menu .nav-drop-down'); +console.log('menu opens:', menuVisible); +await shot('02-menu-open.png'); + +await page.click('.hnes-nav-menu .nav-drop-down a:has-text("ember")'); +await page.waitForTimeout(300); +const afterPick = await page.evaluate(() => ({ + attr: document.documentElement.getAttribute('data-hnes-palette'), + label: document.querySelector('.hnes-nav-menu > a').textContent, + menuOpen: getComputedStyle(document.querySelector('.hnes-nav-menu .nav-drop-down')).display, + bg: getComputedStyle(document.body).backgroundColor, +})); +console.log('after picking ember:', JSON.stringify(afterPick)); +await shot('03-ember.png'); + +// 3. does it survive a reload, and does boot.js apply it before the reveal? +await page.reload({ waitUntil: 'domcontentloaded' }); +const early = await page.evaluate(() => ({ + attr: document.documentElement.getAttribute('data-hnes-palette'), + pending: document.documentElement.classList.contains('hnes-pending'), +})); +await page.waitForTimeout(2000); +const late = await page.evaluate(() => ({ + attr: document.documentElement.getAttribute('data-hnes-palette'), + label: document.querySelector('.hnes-nav-menu > a')?.textContent, + bg: getComputedStyle(document.body).backgroundColor, +})); +console.log('right after reload:', JSON.stringify(early)); +console.log('settled after reload:', JSON.stringify(late)); +await shot('04-ember-reload.png'); + +// 4. palette x density are orthogonal: flow must not disturb the palette +await page.click('.hnes-nav-toggle:has-text("view") > a'); +await page.click('.hnes-nav-toggle:has-text("view") > a'); +await page.waitForTimeout(300); +console.log('palette x view:', JSON.stringify(await page.evaluate(() => ({ + palette: document.documentElement.getAttribute('data-hnes-palette'), + density: document.documentElement.getAttribute('data-hnes-density'), + bg: getComputedStyle(document.body).backgroundColor, +})))); +await shot('05-ember-flow.png'); + +// 5. a comment page, where the fade ladder and the spine live +await page.goto('https://news.ycombinator.com/item?id=' + (await page.evaluate(() => + document.querySelector('tr.athing')?.id) || '1'), { waitUntil: 'domcontentloaded' }).catch(() => {}); +await page.waitForTimeout(2500); +await shot('06-ember-comments.png'); +console.log('comment page comments:', await page.evaluate(() => document.querySelectorAll('.comtr, tr.athing.comtr').length)); + +console.log('\npage errors:', errors.length ? errors : 'none'); +await ctx.close(); diff --git a/test/migration.mjs b/test/migration.mjs new file mode 100644 index 0000000..3facf5d --- /dev/null +++ b/test/migration.mjs @@ -0,0 +1,123 @@ +/* + * End-to-end test of the MV2 -> MV3 storage migration — the one step that + * cannot be redone once a user updates. + * + * Chrome will not load an MV2 extension any more, so the MV2 half is simulated + * where it actually matters: the legacy data lived in localStorage on the + * extension origin, and that origin is unchanged across the upgrade. Seeding it + * from an extension page is the same starting state the real upgrade sees. + * + * The upgrade itself is real: the version in the manifest is bumped between two + * launches of the same profile, so Chrome fires onInstalled({reason:'update'}), + * which is exactly the trigger the shipping code hangs off. + */ +import { chromium } from 'playwright'; +import { mkdtempSync, cpSync, readFileSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { dirname, join as pjoin } from 'path'; +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const SHOTS = pjoin(ROOT, 'test', 'screenshots'); + + +const SRC = ROOT; +const WORK = pjoin(ROOT, 'test', '.migtest'); +const PROFILE = mkdtempSync(join(tmpdir(), 'hnes-mig-')); + +rmSync(WORK, { recursive: true, force: true }); +cpSync(SRC, WORK, { recursive: true, filter: s => !s.includes('/.git') && !s.includes('/proposals') }); + +const manifestPath = join(WORK, 'manifest.json'); +const setVersion = v => { + const m = JSON.parse(readFileSync(manifestPath, 'utf8')); + m.version = v; + writeFileSync(manifestPath, JSON.stringify(m, null, 2)); +}; + +const launch = () => chromium.launchPersistentContext(PROFILE, { + channel: 'chromium', + args: [`--disable-extensions-except=${WORK}`, `--load-extension=${WORK}`], +}); + +async function extensionPage(ctx) { + // The service worker's URL carries the extension id. + let [sw] = ctx.serviceWorkers(); + if (!sw) sw = await ctx.waitForEvent('serviceworker', { timeout: 15000 }); + const id = new URL(sw.url()).host; + const page = await ctx.newPage(); + await page.goto(`chrome-extension://${id}/offscreen.html`); + return { page, id }; +} + +// What an MV2 user's localStorage actually looks like: everything is a string, +// vote counts in both the legacy bare-number form and the later object form, +// thread read-state with an expire stamp, and the odd bare flag. +const NOW = Date.now(); +const LEGACY = { + 'etcet': '1', // legacy bare number + 'pg': '7', // legacy bare number + 'dang': '{"votes":3,"tag":"moderator"}', // already migrated shape + 'patio11': '{"votes":12}', + '49274600': JSON.stringify({ id: 49274600, num: 120, expire: NOW + 5 * 864e5 }), + '11111111': JSON.stringify({ id: 11111111, num: 8, expire: NOW - 864e5 }), // stale + 'update_profile': 'false', + 'expired': 'true', +}; + +console.log('=== launch 1: fresh install, seed the legacy store ==='); +let ctx = await launch(); +let { page } = await extensionPage(ctx); + +// Let the fresh-install migration finish and claim the flag, then put the +// profile back into the state a real pre-upgrade user is in. +await page.waitForTimeout(1500); +await page.evaluate(async legacy => { + for (const [k, v] of Object.entries(legacy)) localStorage.setItem(k, v); + await chrome.storage.local.clear(); + // A key the v2 build already owns: the migration must not clobber it. + await chrome.storage.local.set({ 'dang': '{"votes":99,"tag":"DO NOT CLOBBER"}' }); +}, LEGACY); + +const seeded = await page.evaluate(() => ({ + local: Object.keys(localStorage).length, + sync: Object.keys(localStorage).sort(), +})); +console.log('seeded localStorage keys:', seeded.local, seeded.sync.join(', ')); +await ctx.close(); + +console.log('\n=== launch 2: version bump -> onInstalled(update) -> migration ==='); +setVersion('2.0.1'); +ctx = await launch(); +({ page } = await extensionPage(ctx)); +await page.waitForTimeout(3000); + +const after = await page.evaluate(async () => { + const all = await chrome.storage.local.get(null); + return { all, localStorageStillThere: Object.keys(localStorage).length }; +}); +await ctx.close(); + +// ---- assertions ---------------------------------------------------------- +const a = after.all; +const checks = [ + ['migration flag set', a.hnesMigratedFromLocalStorage === true], + ['legacy "1" -> {"votes":1}', a.etcet === '{"votes":1}'], + ['legacy "7" -> {"votes":7}', a.pg === '{"votes":7}'], + ['object form untouched', a.patio11 === '{"votes":12}'], + ['existing key NOT clobbered', a.dang === '{"votes":99,"tag":"DO NOT CLOBBER"}'], + ['live thread read-state kept', !!a['49274600'] && JSON.parse(a['49274600']).num === 120], + ['stale entry swept', a['11111111'] === undefined], + ['string flag preserved', a.update_profile === 'false'], + ['"true" not eaten as a number', a.expired === 'true'], + ['localStorage left intact', after.localStorageStillThere > 0], +]; + +console.log(''); +let failed = 0; +for (const [name, ok] of checks) { + if (!ok) failed++; + console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}`); +} +console.log('\nmigrated store:', JSON.stringify(a, null, 2)); +console.log(failed ? `\n${failed} FAILED` : '\nall migration checks passed'); diff --git a/test/package.json b/test/package.json new file mode 100644 index 0000000..df31655 --- /dev/null +++ b/test/package.json @@ -0,0 +1,15 @@ +{ + "name": "hnes-tests", + "private": true, + "type": "module", + "description": "Browser-driven checks for the HNES extension. Not part of the packaged extension.", + "scripts": { + "migration": "node migration.mjs", + "tokens": "node tokens.mjs", + "controls": "node controls.mjs", + "pages": "node pages.mjs" + }, + "devDependencies": { + "playwright": "^1.62.0" + } +} diff --git a/test/pages.mjs b/test/pages.mjs new file mode 100644 index 0000000..b03bae0 --- /dev/null +++ b/test/pages.mjs @@ -0,0 +1,99 @@ +/* + * Walk every page type the extension rewrites, logged out, and record whether + * the rewrite finished. These are the pages built on positional table walks + * ($('body > center > table > tbody > tr').eq(2) and friends), so they are where + * a throw leaves the page unstyled — the failsafe reveals it after 2s, which + * means a broken page now looks merely wrong rather than blank, and only a + * console error tells you apart. + */ +import { chromium } from 'playwright'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { dirname, join as pjoin } from 'path'; +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const SHOTS = pjoin(ROOT, 'test', 'screenshots'); + + +const EXT = ROOT; +const OUT = SHOTS; + +const PAGES = [ + ['front', 'https://news.ycombinator.com/'], + ['newest', 'https://news.ycombinator.com/newest'], + ['ask', 'https://news.ycombinator.com/ask'], + ['show', 'https://news.ycombinator.com/show'], + ['jobs', 'https://news.ycombinator.com/jobs'], + ['best', 'https://news.ycombinator.com/best'], + ['comments', 'https://news.ycombinator.com/item?id=49274600'], + ['poll', 'https://news.ycombinator.com/item?id=126809'], + ['user', 'https://news.ycombinator.com/user?id=pg'], + ['threads', 'https://news.ycombinator.com/threads?id=pg'], + ['login', 'https://news.ycombinator.com/login'], + ['submit', 'https://news.ycombinator.com/submit'], + ['newcomments','https://news.ycombinator.com/newcomments'], + ['front-p2', 'https://news.ycombinator.com/news?p=2'], +]; + +const ctx = await chromium.launchPersistentContext(mkdtempSync(join(tmpdir(), 'hnes-')), { + channel: 'chromium', + args: [`--disable-extensions-except=${EXT}`, `--load-extension=${EXT}`], +}); +const page = await ctx.newPage(); + +const rows = []; +for (const [name, url] of PAGES) { + const errors = []; + const onErr = e => errors.push(String(e).split('\n')[0]); + const onConsole = m => { if (m.type() === 'error') errors.push('console: ' + m.text().slice(0, 120)); }; + page.on('pageerror', onErr); + page.on('console', onConsole); + + let status = 0; + try { + const r = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); + status = r ? r.status() : 0; + } catch (e) { errors.push('goto: ' + e.message.split('\n')[0]); } + await page.waitForTimeout(2600); + // HN rate-limits a fast sweep; pace it so a 429 body is not mistaken for a page type. + await new Promise(r => setTimeout(r, 6000)); + + const state = await page.evaluate(() => ({ + pending: document.documentElement.classList.contains('hnes-pending'), + visibility: getComputedStyle(document.body).visibility, + // Did HNES actually restyle, or is this raw HN? #hnmain is HN's; the + // controls only exist if initModeControls got that far. + controls: document.querySelectorAll('.hnes-nav-toggle').length, + bodyFont: getComputedStyle(document.body).fontFamily.slice(0, 22), + rows: document.querySelectorAll('tr.athing').length, + overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, + })); + + page.off('pageerror', onErr); + page.off('console', onConsole); + + // A 429 is HN rate-limiting the test, not the extension. + const real = errors.filter(e => !e.includes('429') && !e.includes('Failed to load resource')); + rows.push({ name, status, ...state, errors: real }); + await page.screenshot({ path: `${SHOTS}/page-${name}.png`, clip: { x: 0, y: 0, width: 1280, height: 500 } }).catch(() => {}); +} +await ctx.close(); + +const pad = (s, n) => String(s).padEnd(n); +console.log(pad('page', 13) + pad('http', 6) + pad('revealed', 10) + pad('styled', 8) + + pad('ctrls', 7) + pad('rows', 6) + pad('hscroll', 9) + 'errors'); +console.log('-'.repeat(86)); +let bad = 0; +for (const r of rows) { + const revealed = !r.pending && r.visibility === 'visible'; + const styled = !r.bodyFont.toLowerCase().includes('verdana'); + if (!revealed || r.errors.length || r.overflow) bad++; + console.log( + pad(r.name, 13) + pad(r.status, 6) + pad(revealed ? 'yes' : 'NO', 10) + + pad(styled ? 'yes' : 'NO', 8) + pad(r.controls, 7) + pad(r.rows, 6) + + pad(r.overflow ? 'YES' : '-', 9) + (r.errors[0] || '') + ); + for (const e of r.errors.slice(1)) console.log(' '.repeat(51) + e); +} +console.log(bad ? `\n${bad} page(s) need attention` : '\nall pages clean'); diff --git a/test/tokens.mjs b/test/tokens.mjs new file mode 100644 index 0000000..5cbda1c --- /dev/null +++ b/test/tokens.mjs @@ -0,0 +1,203 @@ +/* + * Ask a real browser what the refactored tokens resolve to, and compare classic + * against the literals the stylesheet shipped with. Offline arithmetic said the + * split was lossless; this is the part that checks the browser agrees. + */ +import { chromium } from 'playwright'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join as pjoin } from 'path'; +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const SHOTS = pjoin(ROOT, 'test', 'screenshots'); + + +const CSS = readFileSync(pjoin(ROOT, 'style.css'), 'utf8'); + +// What these tokens were before the seed/derive split. +const BEFORE = { + light: { + '--hnes-surface-alt': '#eeeee4', '--hnes-surface-hi': '#e4e4d6', + '--hnes-fg-subtle': '#95958c', '--hnes-link': '#1b1b19', '--hnes-visited': '#6a6a63', + '--hnes-c5a': '#5a5a5a', '--hnes-c73': '#737373', '--hnes-c82': '#828282', + '--hnes-c88': '#888888', '--hnes-c9c': '#9c9c9c', '--hnes-cae': '#aeaeae', + '--hnes-cbe': '#bebebe', '--hnes-cce': '#cecece', '--hnes-cdd': '#dddddd', + '--hnes-header-ink': 'rgba(255, 243, 233, 0.92)', + '--hnes-header-ink-dim': 'rgba(255, 243, 233, 0.82)', + }, + dark: { + '--hnes-surface-alt': '#22221a', '--hnes-surface-hi': '#2b2b21', + '--hnes-fg-subtle': '#6f6f66', '--hnes-link': '#ddddd2', '--hnes-visited': '#8d8d82', + '--hnes-c5a': '#c6c6bc', '--hnes-c73': '#adada4', '--hnes-c82': '#9b9b92', + '--hnes-c88': '#909087', '--hnes-c9c': '#81817a', '--hnes-cae': '#73736c', + '--hnes-cbe': '#686861', '--hnes-cce': '#5c5c56', '--hnes-cdd': '#53534e', + '--hnes-header-ink': 'rgba(255, 243, 233, 0.92)', + '--hnes-header-ink-dim': 'rgba(255, 243, 233, 0.82)', + }, +}; + +const ALL = [ + '--hnes-bg','--hnes-surface','--hnes-surface-alt','--hnes-surface-hi', + '--hnes-fg','--hnes-fg-muted','--hnes-fg-subtle','--hnes-link','--hnes-visited', + '--hnes-border','--hnes-brand','--hnes-orange','--hnes-orange-ink', + '--hnes-selection','--hnes-current', + '--hnes-c00','--hnes-c5a','--hnes-c73','--hnes-c82','--hnes-c88', + '--hnes-c9c','--hnes-cae','--hnes-cbe','--hnes-cce','--hnes-cdd', + '--hnes-header-ink','--hnes-header-ink-dim', +]; + +const PALETTES = ['classic', 'newsprint', 'ember', 'slate', 'letterpress']; +const THEMES = ['light', 'dark']; + +// --- colour helpers, on resolved rgb() strings ------------------------------ +const parse = v => v; // already {r,g,b,a} in 0..1, converted in-page +const hexParse = h => ({ r: parseInt(h.slice(1,3),16)/255, g: parseInt(h.slice(3,5),16)/255, b: parseInt(h.slice(5,7),16)/255, a: 1 }); +const toLin = c => (c <= 0.04045 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4)); +function oklab({r,g,b}) { + const R=toLin(r),G=toLin(g),B=toLin(b); + const l=Math.cbrt(0.4122214708*R+0.5363325363*G+0.0514459929*B); + const m=Math.cbrt(0.2119034982*R+0.6806995451*G+0.1073969566*B); + const s=Math.cbrt(0.0883024619*R+0.2817188376*G+0.6299787005*B); + return [0.2104542553*l+0.7936177850*m-0.0040720468*s, + 1.9779984951*l-2.4285922050*m+0.4505937099*s, + 0.0259040371*l+0.7827717662*m-0.8086757660*s]; +} +const dE = (a,b) => { const x=oklab(a), y=oklab(b); return Math.hypot(x[0]-y[0],x[1]-y[1],x[2]-y[2]); }; +const relLum = ({r,g,b}) => 0.2126*toLin(r)+0.7152*toLin(g)+0.0722*toLin(b); +const ratio = (a,b) => { const l1=relLum(a), l2=relLum(b); const [hi,lo]=l1>l2?[l1,l2]:[l2,l1]; return (hi+0.05)/(lo+0.05); }; + +const browser = await chromium.launch(); +const results = {}; + +for (const theme of THEMES) { + const ctx = await browser.newContext({ colorScheme: theme }); + const page = await ctx.newPage(); + await page.setContent('
    '); + await page.addStyleTag({ content: CSS }); + + for (const palette of PALETTES) { + await page.evaluate(p => { + const r = document.documentElement; + if (p === 'classic') r.removeAttribute('data-hnes-palette'); + else r.setAttribute('data-hnes-palette', p); + }, palette); + + // Two layers of indirection to get through. Custom properties are + // substitution-only, so getPropertyValue hands back unresolved text — only + // using one in a real property resolves light-dark() and color-mix(). And + // the resolved value comes back as oklab()/color(srgb ...), so a canvas does + // the final conversion to sRGB bytes rather than a regex guessing at units. + results[`${palette}/${theme}`] = await page.evaluate(names => { + const host = document.getElementById('probe'); + while (host.firstChild) host.removeChild(host.firstChild); + + const els = names.map(n => { + const el = document.createElement('span'); + el.style.color = `var(${n})`; + host.appendChild(el); + return el; + }); + + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + + const out = {}; + names.forEach((n, i) => { + const resolved = getComputedStyle(els[i]).color; + ctx.clearRect(0, 0, 1, 1); + ctx.fillStyle = resolved; + ctx.fillRect(0, 0, 1, 1); + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + out[n] = { r: r / 255, g: g / 255, b: b / 255, a: a / 255, raw: resolved }; + }); + return out; + }, ALL); + } + await ctx.close(); +} +await browser.close(); + +// --- 1. did the refactor change classic? ----------------------------------- +console.log('=== CLASSIC: derived vs the literals they replaced ===\n'); +let worst = 0, worstName = ''; +for (const theme of THEMES) { + console.log(theme); + for (const [tok, before] of Object.entries(BEFORE[theme])) { + const after = results[`classic/${theme}`][tok]; + if (!after) { console.log(` ${tok.padEnd(24)} MISSING`); continue; } + const a = parse(after); + const b = before.startsWith('#') ? hexParse(before) : parse(before); + const d = dE(a, b); + const alphaOff = Math.abs(a.a - b.a) > 0.005; + if (d > worst) { worst = d; worstName = `${tok} (${theme})`; } + const flag = alphaOff ? 'ALPHA' : d < 0.008 ? 'lossless' : d < 0.020 ? 'ok' : 'CHANGED'; + const hex = '#' + ['r','g','b'].map(k => Math.round(a[k]*255).toString(16).padStart(2,'0')).join('') + + (a.a < 0.999 ? ` @${a.a.toFixed(2)}` : ''); + console.log(` ${tok.padEnd(24)} ${before.padEnd(24)} -> ${hex.padEnd(16)} dE ${d.toFixed(4)} ${flag}`); + } + console.log(''); +} +console.log(`worst drift: ${worst.toFixed(4)} on ${worstName}\n`); + +// --- 2. does every palette resolve, and is the ladder monotonic? ------------ +console.log('=== LADDER MONOTONICITY (c00 -> cdd must fade toward bg) ===\n'); +const LADDER = ['--hnes-c00','--hnes-c5a','--hnes-c73','--hnes-c82','--hnes-c88', + '--hnes-c9c','--hnes-cae','--hnes-cbe','--hnes-cce','--hnes-cdd']; +for (const palette of PALETTES) { + for (const theme of THEMES) { + const r = results[`${palette}/${theme}`]; + const bg = parse(r['--hnes-bg']); + const ratios = LADDER.map(t => ratio(parse(r[t]), bg)); + let ok = true; + for (let i = 1; i < ratios.length; i++) if (ratios[i] > ratios[i-1] + 0.02) ok = false; + console.log(`${(palette+'/'+theme).padEnd(22)} ${ratios.map(v=>v.toFixed(1).padStart(5)).join(' ')} ${ok ? 'monotonic' : 'NOT MONOTONIC'}`); + } +} + +/* + * --- 3. contrast, as the browser resolves it ------------------------------ + * + * Each pair carries its own floor, because they are not all text. A pair judged + * against the wrong floor is worse than no check: a harness that always prints + * failures is one nobody reads, and a real regression hides in the noise. + * + * 4.5 is the WCAG AA floor for text below 18pt, which is everything HNES sets. + * The two low floors are not exemptions granted to make the numbers work — + * they are pairs that never carry words, so no text floor applies to them. + */ +console.log('\n=== CONTRAST, browser-resolved ===\n'); +const HAIRLINE = 1.15; // visible as a line at all, nothing more +const PAIRS = [ + ['fg / bg', '--hnes-fg', '--hnes-bg', 4.5, 'body text'], + ['fg / surface', '--hnes-fg', '--hnes-surface', 4.5, 'body text on a card'], + ['fg-muted / bg', '--hnes-fg-muted', '--hnes-bg', 4.5, 'subtext words, comment headers'], + ['fg-muted / surface', '--hnes-fg-muted', '--hnes-surface', 4.5, 'comment header on a card'], + ['link / bg', '--hnes-link', '--hnes-bg', 4.5, 'story titles'], + ['visited / surface', '--hnes-visited', '--hnes-surface', 4.5, 'visited titles'], + ['orange / bg', '--hnes-orange', '--hnes-bg', 4.5, 'accent text'], + ['orange-ink / brand', '--hnes-orange-ink', '--hnes-brand', 4.5, 'nav text on the header'], + ['c5a / surface', '--hnes-c5a', '--hnes-surface', 4.5, 'least-faded comment'], + // Not text. --hnes-fg-subtle is scoped to punctuation that carries no + // information — "(" ")" "|" "[ ]" — and the border is a hairline. Both are + // checked only for "still visible", see style.css. + ['fg-subtle / bg', '--hnes-fg-subtle', '--hnes-bg', HAIRLINE, 'punctuation only, not text'], + ['border / bg', '--hnes-border', '--hnes-bg', HAIRLINE, 'hairline rule, not text'], +]; + +const failures = []; +for (const palette of PALETTES) { + console.log(palette); + for (const [label, fg, bgTok, floor, why] of PAIRS) { + const line = THEMES.map(theme => { + const v = ratio(parse(results[`${palette}/${theme}`][fg]), + parse(results[`${palette}/${theme}`][bgTok])); + const bad = v < floor; + if (bad) failures.push(`${palette}/${theme} ${label} = ${v.toFixed(2)} (floor ${floor})`); + return `${theme} ${v.toFixed(2).padStart(6)}${bad ? ' FAIL' : ' '}`; + }).join(' '); + console.log(` ${label.padEnd(20)} ${line} ${floor === HAIRLINE ? '' : 'AA'} ${why}`); + } +} +console.log(failures.length + ? `\n${failures.length} FAILURES\n ` + failures.join('\n ') + : `\nall ${PAIRS.length * PALETTES.length * THEMES.length} pair/palette/theme combinations meet their floor`); diff --git a/zip.sh b/zip.sh index 6fd2b7a..72113c2 100755 --- a/zip.sh +++ b/zip.sh @@ -1,8 +1,8 @@ #!/bin/bash #package for firefox -zip -r -FS ../HNES-firefox.zip * -x \*.git\* *screenshots\* *proposals\* *notes* *zip.sh* js/jquery-3.2.1.js +zip -r -FS ../HNES-firefox.zip * -x \*.git\* *screenshots\* *proposals\* \*test\* *notes* *zip.sh* js/jquery-3.2.1.js #package for chrome web store cd .. -zip -r -FS HNES-chrome.zip HNES -x \*.git\* *screenshots* *proposals* HNES/notes HNES/zip.sh HNES/js/jquery-3.2.1.js +zip -r -FS HNES-chrome.zip HNES -x \*.git\* *screenshots* *proposals* \*test\* HNES/notes HNES/zip.sh HNES/js/jquery-3.2.1.js From 53bba9cc6b828b48b97d45a9ad84582a1175db5e Mon Sep 17 00:00:00 2001 From: John Bolliger Date: Wed, 12 Aug 2026 22:45:17 -0700 Subject: [PATCH 08/20] Add degenerate-body regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks in today's /login fix and covers the failure mode the extension is most exposed to: HNES hides the page at document_start and reveals it at the end of the rewrite, so anything throwing in between leaves the user on a blank Hacker News. Every response is served by route interception, so there is no network and nothing to rate limit — unlike pages.mjs, these are deterministic. The bodies are the ones HN actually returns when something is off: a 429 while being rate limited, an empty body, an expired link, a login form missing its submit button. Each asserts the page is usable within the failsafe window, sampled at 1.2s so the check is that hn.js revealed the page rather than that the stylesheet's 2s animation bailed it out. It does not assert the rewrite succeeded — against markup this broken, doing nothing is the right outcome. Verified to actually catch the bug rather than merely pass: with the doLogin guard removed, exactly the two /login cases fail with the original TypeError; with it, all eight pass. The expired-link case expects a redirect instead — HNES sets a flag and location.replace("/")s there, so the execution context being torn down is correct behaviour, and asserting "revealed" would have been asserting the wrong thing. Co-Authored-By: Claude Opus 5 (1M context) --- test/README.md | 28 ++++++++++ test/degenerate.mjs | 122 ++++++++++++++++++++++++++++++++++++++++++++ test/package.json | 1 + 3 files changed, 151 insertions(+) create mode 100644 test/degenerate.mjs diff --git a/test/README.md b/test/README.md index 862325a..ebfa014 100644 --- a/test/README.md +++ b/test/README.md @@ -8,10 +8,15 @@ loaded. These four cover what manual checking kept missing. cd test && npm install # playwright only npm run migration # the one that cannot be redone npm run tokens # colour tokens, contrast, fade ladder +npm run degenerate # broken markup must not brick the page npm run controls # nav controls, persistence, orthogonality npm run pages # every page type, logged out ``` +`migration`, `tokens` and `degenerate` need no network and are deterministic. +`controls` and `pages` hit live Hacker News and can be rate limited — see the +warning under `pages.mjs`. + Screenshots land in `test/screenshots/`. ## migration.mjs — run this before any release that changes storage @@ -49,6 +54,29 @@ Two expected non-failures in its output: `border / bg` is a hairline, not text, and `fg-subtle / bg` is scoped to punctuation that carries no information — see the comment on `--hnes-fg-subtle` in `style.css`. +## degenerate.mjs — broken markup must not brick the page + +The failure mode this extension is most exposed to. HNES hides the page at +`document_start` and reveals it at the end of the rewrite, so anything that +throws in between leaves the user on a blank Hacker News. The stylesheet's +failsafe animation caps that at two seconds, but two seconds of blank followed +by a half-rewritten page is still a bug. + +Every response is served by route interception, so there is no network and no +rate limiting. The cases are the bodies HN actually returns when something is +off: a 429 while you are being rate limited, an empty body, an expired-link +page, a login form missing its submit button. Each one asserts the page is +usable within the failsafe window — it deliberately does **not** assert the +rewrite succeeded, because against markup this broken, doing nothing is the +right outcome. + +Sampling happens at 1.2s, inside the 2s failsafe, so the check is that `hn.js` +revealed the page itself rather than that the stylesheet bailed it out. + +This is a real regression test, not a smoke test: removing the guard in +`doLogin` makes exactly the two `/login` cases fail with the original +`TypeError`, and restoring it makes all eight pass. + ## controls.mjs — the nav controls end to end Builds the controls, opens the palette menu, picks one, and checks the attribute diff --git a/test/degenerate.mjs b/test/degenerate.mjs new file mode 100644 index 0000000..92fe428 --- /dev/null +++ b/test/degenerate.mjs @@ -0,0 +1,122 @@ +/* + * Degenerate-body tests. No network: every response is served by route + * interception, so these are deterministic and immune to the rate limiting that + * makes pages.mjs flaky. + * + * This is the shape of failure that matters most in this extension. HNES hides + * the page at document_start and reveals it at the very end of the rewrite, so + * anything that throws in between leaves the user on a blank Hacker News with + * no clue why. The stylesheet's failsafe animation caps that at two seconds, + * but two seconds of blank followed by a half-rewritten page is still a bug — + * and the bodies that cause it are not exotic. A 429 while you are being rate + * limited is a body with no form, and that is what broke /login. + * + * Each case asserts the extension leaves the page usable: revealed, and no + * uncaught throw. It deliberately does NOT assert the rewrite succeeded — + * against markup this broken, doing nothing is the correct outcome. + */ +import { chromium } from 'playwright'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); + +const page404 = 'Unknown or expired link.'; +const rateLimited = 'Sorry, we\'re not able to serve your requests this quickly.'; +const emptyBody = ''; +const loginNoSubmit = 'Login
    ' + + '' + + '
    username:
    '; +const loginOk = 'Login
    ' + + '
    username:
    ' + + '
    '; + +/* + * `redirects` marks the one case where leaving the page is the correct answer: + * on an expired link HNES sets a flag and location.replace("/")s to the front + * page, so the execution context is expected to be torn down. + */ +const CASES = [ + ['front: rate-limited body', 'https://news.ycombinator.com/', rateLimited, 429], + ['front: empty body', 'https://news.ycombinator.com/', emptyBody, 200], + ['item: expired link', 'https://news.ycombinator.com/item?id=1', page404, 200, true], + ['login: rate-limited body', 'https://news.ycombinator.com/login', rateLimited, 429], + ['login: form with no submit', 'https://news.ycombinator.com/login', loginNoSubmit, 200], + ['login: well-formed', 'https://news.ycombinator.com/login', loginOk, 200], + ['user: empty body', 'https://news.ycombinator.com/user?id=x', emptyBody, 200], + ['threads: rate-limited body', 'https://news.ycombinator.com/threads?id=x', rateLimited, 429], +]; + +const ctx = await chromium.launchPersistentContext(mkdtempSync(join(tmpdir(), 'hnes-')), { + channel: 'chromium', + args: [`--disable-extensions-except=${ROOT}`, `--load-extension=${ROOT}`], +}); +const page = await ctx.newPage(); + +const results = []; +for (const [name, url, body, status, redirects] of CASES) { + const errors = []; + const onErr = e => errors.push(String(e).split('\n')[0]); + page.on('pageerror', onErr); + + // Reset between cases: several cases reuse the same URL, and renavigating to + // the current one races the evaluate below against the teardown. + await page.goto('about:blank').catch(() => {}); + + await page.route('**://news.ycombinator.com/**', r => + r.fulfill({ status, contentType: 'text/html; charset=utf-8', body })); + + // 'commit' rather than 'domcontentloaded': with a fulfilled route and the + // extension attached, waiting for DOMContentLoaded hangs indefinitely. The + // explicit timeout is deliberate — a harness that can hang forever is worse + // than one that fails. + await page.goto(url, { waitUntil: 'commit', timeout: 15000 }) + .catch(e => errors.push('goto: ' + e.message.split('\n')[0])); + + // Sample before the 2s failsafe: the point is that hn.js revealed the page + // itself, not that the stylesheet bailed it out. + await page.waitForTimeout(1200); + let state = { pending: true, vis: 'hidden' }; + try { + state = await page.evaluate(() => ({ + pending: document.documentElement.classList.contains('hnes-pending'), + vis: document.body ? getComputedStyle(document.body).visibility : 'no-body', + })); + } catch (e) { + errors.push('probe: ' + e.message.split('\n')[0]); + } + + const landed = page.url(); + await page.unroute('**://news.ycombinator.com/**'); + page.off('pageerror', onErr); + + let revealedEarly, note = ''; + if (redirects) { + // Success here is having left for the front page, not having revealed. + revealedEarly = landed === 'https://news.ycombinator.com/'; + note = 'redirected to ' + landed; + // The teardown that redirecting causes is expected, not a failure. + const i = errors.findIndex(e => e.startsWith('probe:')); + if (i >= 0) errors.splice(i, 1); + } else { + revealedEarly = !state.pending && state.vis === 'visible'; + } + results.push({ name, revealedEarly, errors, note }); +} +await ctx.close(); + +const pad = (s, n) => String(s).padEnd(n); +console.log(pad('case', 30) + pad('handled <2s', 15) + 'uncaught error / note'); +console.log('-'.repeat(78)); +let failed = 0; +for (const r of results) { + const ok = r.revealedEarly && r.errors.length === 0; + if (!ok) failed++; + console.log(pad(r.name, 30) + pad(r.revealedEarly ? 'yes' : 'NO', 15) + (r.errors[0] || r.note || '-')); +} +console.log(failed + ? `\n${failed} of ${results.length} degenerate bodies leave the page broken` + : `\nall ${results.length} degenerate bodies leave the page usable`); +process.exit(failed ? 1 : 0); diff --git a/test/package.json b/test/package.json index df31655..c0325a9 100644 --- a/test/package.json +++ b/test/package.json @@ -6,6 +6,7 @@ "scripts": { "migration": "node migration.mjs", "tokens": "node tokens.mjs", + "degenerate": "node degenerate.mjs", "controls": "node controls.mjs", "pages": "node pages.mjs" }, From 065b4bf38e87c29de822f279edcf6978785db9b6 Mon Sep 17 00:00:00 2001 From: John Bolliger Date: Wed, 12 Aug 2026 22:45:47 -0700 Subject: [PATCH 09/20] Bring the plan's status up to date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status table still said the work was uncommitted and listed the /login throw as a known gap; both are stale. Records what the tests now cover, and what still needs a human — logged-in flows and Firefox — plus the four page types that have only ever returned 429 to an automated run and are therefore untested rather than passing. Co-Authored-By: Claude Opus 5 (1M context) --- proposals/README.md | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/proposals/README.md b/proposals/README.md index 9e5b5f9..9ef1a85 100644 --- a/proposals/README.md +++ b/proposals/README.md @@ -17,21 +17,34 @@ it correct. Verified against live `news.ycombinator.com` markup on 2026-07-31. | Phase | State | |---|---| -| Phase 0 — data migration | **Implemented**, uncommitted | -| Phase 1 — MV3 port | **Implemented**, uncommitted | -| Stylesheet rebuild | **Implemented**, uncommitted (not in the original plan; added during the same pass) | -| Phase 2 — markup drift | Partly done — the comment fade-class and vote-arrow breakages are fixed; the positional table walks are unaudited | +| Phase 0 — data migration | **Implemented and committed**, tested end to end | +| Phase 1 — MV3 port | **Implemented and committed** | +| Stylesheet rebuild | **Implemented and committed** (not in the original plan; added during the same pass) | +| Phase 2 — markup drift | Partly done — fade-class, vote arrows and the `/login` throw are fixed; the positional table walks are still unaudited | | Phase 3 — hygiene | Not started | | Design tracks | Proposals only | | Palette as a user option | **Implemented** — [`palettes.md`](./palettes.md) | +| Tests | **Added** — see [`../test/README.md`](../test/README.md) | -Implemented work is verified but **not committed** — 23/23 migration unit tests, 7/7 -in-browser migration, 11 page types loading clean, 0 horizontal overflow at -1280/900/780/600/420/375, and WCAG AA or better on every measured text pair. +The verification below is no longer a list to work through by hand; most of it runs. +`test/migration.mjs` drives a real version-bump upgrade and checks the data survives, +`test/tokens.mjs` checks all 110 contrast pair/palette/theme combinations, and +`test/degenerate.mjs` checks that the bodies HN returns when something is off do not +brick the page. -Known gap: `/login` throws on an unguarded `$('form input[type=submit]').get(0)` when HN -returns a body with no form. Pre-existing, not a regression — but it is the kind of thing -Phase 2's try/catch hardening is for. +Two things the tests will not tell you, and both still need a human: + +- **Logged-in flows** — voting, tagging, inline replies, `/threads` with real content. +- **Firefox** — `about:debugging` → Load Temporary Add-on, and confirm the shared + manifest loads as an event page. + +Also unverified: `poll`, `user`, `threads` and `submit` against real bodies. Every +automated attempt so far drew a 429, and a 429 body is not a page type — rerun +`npm run pages` once HN's rate limiter has cooled rather than reading anything into it. + +Fixed since this document was written: `/login` threw on an unguarded +`$('form input[type=submit]').get(0)` whenever HN returned a body with no form — which a +429 is. It now returns early, and `test/degenerate.mjs` is the regression test. --- From aa0f7d2e30d126ec4e2396c357040d2f552eb874 Mon Sep 17 00:00:00 2001 From: John Bolliger Date: Thu, 13 Aug 2026 22:49:39 -0700 Subject: [PATCH 10/20] Put the settings that were never settings behind the gear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel was built for theme, view and palette. Everything added here was already something HNES did, unconditionally and with no way to see it: Reading new-comment highlighting, hckrnews.com counts — always on Keyboard the shortcuts, and the bindings, which were written down nowhere Sections which of the 14 are header tabs — two hardcoded arrays Storage how much is held, and clear the part that never expires Storage is the only one that fixes something. background.js says so in its own comment: the sweep only understands string values carrying an expire stamp, and comment collapse state is written per comment as an object, so it has never been swept. getBytesInUse on open, a full read only when clicked. The three original modes paint an attribute onto and are free to change live. These mostly do not — rewriteNavigation has to know which sections are tabs before it builds the header — so modes.js grew the load/commit/watch split and specs split into painting and behaviour families. rewriteNavigation and reveal both queue on HNESModes.ready, which fires in order, so the header is built before the page is shown rather than painting defaults and correcting them. In practice that waits for nothing: boot.js issues the read at document_start. load resolves with defaults when storage throws, so a reveal cannot be lost to it. Also fixes a bug the toggle turned up. The shortcuts were guarded by one flag set by the search box's own focus handler, leaving every comment box and the submit form unguarded — j typed mid-reply scrolled the page out from under it. Asking the focused element covers all of them. Closes the open item on a panel not following another tab, via HNESModes.subscribe, and adds role=switch/aria-checked/aria-expanded. controls 33/33, tokens 110/110, degenerate 8/8, migration and pages clean. Co-Authored-By: Claude Opus 5 (1M context) --- js/boot.js | 57 +--- js/hn.js | 629 +++++++++++++++++++++++------------- js/modes.js | 274 ++++++++++++++++ manifest.json | 2 +- proposals/README.md | 4 +- proposals/settings-panel.md | 320 ++++++++++++++++++ style.css | 321 +++++++++++++++--- test/README.md | 40 ++- test/controls.mjs | 338 ++++++++++++++++--- test/migration.mjs | 4 +- test/pages.mjs | 4 +- 11 files changed, 1627 insertions(+), 366 deletions(-) create mode 100644 js/modes.js create mode 100644 proposals/settings-panel.md diff --git a/js/boot.js b/js/boot.js index 4ea8fdf..d5f3637 100644 --- a/js/boot.js +++ b/js/boot.js @@ -7,10 +7,18 @@ * while hn.js rewrites it. Putting the flag here rather than in CSS means a * page where the content script never runs is never hidden at all, and the * stylesheet's failsafe animation reveals the page even if hn.js throws. - * - Apply the saved theme, view density and palette. The storage read is async, - * so it can land after paint; that is harmless because the body is still - * hidden, and an unset value falls through to the stylesheet's own default — - * prefers-color-scheme for theme, comfortable for density, classic for palette. + * - Start the settings read and apply the painting ones — theme, view density + * and palette. The read is async, so it can land after paint; that is + * harmless because the body is still hidden, and an unset value falls + * through to the stylesheet's own default — prefers-color-scheme for theme, + * comfortable for density, classic for palette. + * + * Issuing that read here rather than in hn.js is also what keeps it off the + * critical path: it is in flight while HN's markup is parsing, so hn.js's + * HNESModes.ready() at document_end almost always resolves without waiting. + * + * The settings themselves live in modes.js, which the manifest injects just + * ahead of this file; hn.js and its settings panel read the same one. */ (function () { var root = document.documentElement; @@ -18,42 +26,9 @@ root.classList.add('hnes-pending'); - /* - * Mirrors HN.MODES in hn.js — deliberately, not accidentally. This script runs - * at document_start, before hn.js exists, so it cannot read hn.js's copy; the - * reverse direction does work, which is what window.hnesModes below is for. - * Same convention, so the two stay comparable at a glance: values[0] is the - * unset state and leaves the attribute off. Adding a mode means adding it in - * both places, or it works after paint and flashes on every cold load. - */ - var MODES = [ - { key: 'hnesTheme', attr: 'data-hnes-theme', values: ['auto', 'light', 'dark'] }, - { key: 'hnesDensity', attr: 'data-hnes-density', values: ['comfortable', 'compact', 'flow'] }, - { key: 'hnesPalette', attr: 'data-hnes-palette', values: ['classic', 'newsprint', 'ember', 'slate', 'letterpress'] } - ]; + var MODES = globalThis.HNESModes; + if (!MODES) return; - /* - * Published for hn.js, which needs the same three values to label the nav - * controls: content scripts of one extension share an isolated world, so this - * saves a second round trip to the same keys. It matters beyond the trip — - * hn.js reveals the page immediately after building the controls, so a fresh - * storage read lands after the reveal and the controls visibly pop in, while a - * promise settled back here resolves in the same microtask checkpoint and they - * arrive before the first paint. Always assigned, and never rejects, so the - * consumer has one path rather than two. - */ - window.hnesModes = new Promise(function (resolve) { - try { - chrome.storage.local.get(MODES.map(function (m) { return m.key; }), function (items) { - MODES.forEach(function (m) { - var value = items && items[m.key]; - if (m.values.indexOf(value) > 0) root.setAttribute(m.attr, value); - }); - resolve(items || {}); - }); - } catch (e) { - /* Storage unavailable — the stylesheet's own defaults still apply. */ - resolve({}); - } - }); + MODES.load(function (items) { MODES.applyAll(root, items); }); + MODES.watch(root); })(); diff --git a/js/hn.js b/js/hn.js index cbd4720..46eca29 100644 --- a/js/hn.js +++ b/js/hn.js @@ -135,7 +135,15 @@ var CommentTracker = { HN.getLocalStorage(page_info.id, function(response) { var data = response.data; var prev_last_id = CommentTracker.process(data, page_info); - CommentTracker.highlightNewComments(prev_last_id); + // The read position is recorded either way: it is what hckrnews.com's + // unread counts are drawn from, and it is what makes turning the + // highlighting back on later resume from the right place rather than + // from whenever it was re-enabled. Only the marking is optional. + HNESModes.ready(function() { + if (HNESModes.on('hnesNewComments')) { + CommentTracker.highlightNewComments(prev_last_id); + } + }); }); }, @@ -918,167 +926,326 @@ var HN = { * dropping it here is what reveals the finished rewrite. The stylesheet also * reveals the page on a timer, so a throw before this point costs the user some * styling rather than a blank Hacker News. + * + * Held behind the settings read because rewriteNavigation is: the header is + * built from a stored list of sections, and revealing first would show the + * default tabs and then swap them. In practice this waits for nothing — + * boot.js issued the read at document_start and it has landed by now — and + * HNESModes.load resolves even when storage throws, so a reveal cannot be + * lost to it. Queued after rewriteNavigation's callback, which is what puts + * the nav on screen before the page is. */ reveal: function() { - document.documentElement.classList.remove('hnes-pending'); + HNESModes.ready(function() { + document.documentElement.classList.remove('hnes-pending'); + }); }, /* - * The nav's preference controls. Each descriptor is the whole definition of - * one control: values[0] is the unset state and clears the attribute, so - * "which values are real" is derived from the list rather than restated as a - * condition somewhere else. Adding a mode is one entry in `values` plus the - * matching CSS block — and the same list in boot.js, which runs first and so - * cannot read this one. - * - * `ui` names an entry in MODE_UI, so a third kind of control is a builder - * plus a data change rather than another branch. It picks the control, not - * the behaviour: every rendering writes the same attribute and storage key - * through commitMode. Cycling is right up to three values and stops being - * right past that, which is why palette is a menu — five values is four - * clicks to reach the last one. + * The Bootstrap Icons "gear-fill" glyph (MIT). Inline rather than a file so + * it takes currentColor and rides the header link's own colour and hover + * states. Solid rather than a stroked outline: at 15px on a saturated + * ground, hairline strokes go muddy where a filled silhouette stays crisp. * - * theme: auto -> light -> dark. 'auto' lets prefers-color-scheme decide; - * the explicit modes pin color-scheme, which is what the - * stylesheet's light-dark() tokens resolve against. - * view: comfortable -> compact -> flow. compact shrinks the scale, flow - * drops the card chrome and keeps the type readable. - * palette: swaps the stylesheet's colour seeds. Orthogonal to the other two - * by construction — palettes own colour tokens, view owns geometry - * tokens, and the sets do not intersect. + * fill-rule="evenodd" is what punches the centre out. The inner circle is a + * second subpath, and under the default nonzero rule its winding direction + * decides whether it is a hole or a disc — evenodd makes that not matter. */ - MODES: [ - { key: 'hnesTheme', attr: 'data-hnes-theme', label: 'theme', ui: 'cycle', - title: 'Switch colour theme', - values: ['auto', 'light', 'dark'] }, - { key: 'hnesDensity', attr: 'data-hnes-density', label: 'view', ui: 'cycle', - title: 'Switch row density', - values: ['comfortable', 'compact', 'flow'] }, - { key: 'hnesPalette', attr: 'data-hnes-palette', label: 'palette', ui: 'menu', - title: 'Switch colour palette', - values: ['classic', 'newsprint', 'ember', 'slate', 'letterpress'] } - ], - - applyMode: function(spec, value) { - var root = document.documentElement; - if (spec.values.indexOf(value) > 0) root.setAttribute(spec.attr, value); - else root.removeAttribute(spec.attr); - }, - - /* The one write path for every control, so a new `ui` cannot forget half of - it: paint, then persist. */ - commitMode: function(spec, value) { - HN.applyMode(spec, value); - HN.setLocalStorage(spec.key, value); - }, + GEAR_SVG: '', /* - * boot.js already applied every stored value before first paint, so the only - * job on load is building the controls. + * The settings panel: one gear at the end of the nav, one panel behind it, + * every mode in HNESModes drawn into it. + * + * This used to be three controls sitting in the nav — a cycle each for theme + * and view, a menu for palette. The shapes differed because nav width decided + * them and not because the settings differ, and neither shape had room to say + * what `flow` or `newsprint` actually do. Behind a gear there is room, and the + * nav is back to its own links plus an icon. * - * The values come from boot.js's read rather than a second one. Beyond - * saving the round trip, it is what keeps the controls out of the reveal: - * initModeControls is called immediately before HN.reveal(), so a fresh - * storage read would land after the page is visible and the nav would - * visibly grow. boot.js's promise is already settled by document_end, so - * .then runs in this task's microtask checkpoint — before the first paint. + * The panel body is built on first open rather than at init. That is what lets + * it read its selection off instead of storage: boot.js's read has + * certainly landed by the time someone clicks, so there is no second round + * trip and no promise to thread from document_start to here. * - * The fallback covers hn.js running somewhere boot.js does not; today the - * manifest injects boot.js on the HN hosts only, and initModeControls is - * reached on those alone, but the guard costs one line. + * Recomputing the marks rather than tracking them is what makes a change + * from another tab show up correctly here: there is no second copy of the + * state to go stale. Every open recomputes, and so does the subscription + * below, which covers a panel already on screen when the other tab writes. */ - initModeControls: function() { - var nav = $('#top-navigation .nav-links').first(); - if (!nav.length) return; + initSettings: function() { + /* + * The header's third cell — the login link when logged out, the user menu + * and karma when logged in — so the gear sits at the right edge rather + * than in among the section tabs, which are navigation and not settings. + * That cell is right-aligned by the stylesheet, so appending puts the gear + * last. Falling back to the cell itself covers a page where HN ships no + * .pagetop in it. + */ + var cell = $('#header td:nth-child(3)').first(), + slot = cell.find('.pagetop').first(); + if (!slot.length) slot = cell; + if (!slot.length) return; + + var link = $('
    ').attr('href', 'javascript:void(0)') + .addClass('hnes-gear') + .attr('title', 'Display settings') + .attr('aria-label', 'Display settings') + .attr('aria-expanded', 'false') + .html(HN.GEAR_SVG), + host = $('').addClass('hnes-settings-host').append(link), + panel = null, + // Tracked rather than read back off the DOM: jQuery's :visible measures + // the element, which forces a synchronous layout of the whole document — + // expensive on a long thread, and for a fact we already know. Same + // reason display is set directly rather than through .toggle(), which + // resolves the default display by appending a probe element to . + open = false, + close = function() { + if (!open) return; + open = false; + panel.css('display', 'none'); + link.removeClass('active').attr('aria-expanded', 'false'); + // Unbound with the panel: a document keydown handler otherwise sits + // in front of every keystroke in a comment box for a panel that is + // shut. Namespaced, so nothing else on the document is disturbed. + $(document).off('.hnesSettings'); + }; - var stored = window.hnesModes || new Promise(function(resolve) { - chrome.storage.local.get(HN.MODES.map(function(spec) { return spec.key; }), resolve); + link.click(function(e) { + e.stopPropagation(); + if (open) return close(); + + // Any other open menu closes first; two floating surfaces at once reads + // as a rendering bug rather than as two menus. Their triggers have to + // lose .active with them — the older menus toggle that class blindly, so + // leaving it set desyncs their next click from what is on screen. + $('.nav-drop-down').not(panel).hide(); + $('.more-arrow > a.active').removeClass('active'); + + if (!panel) host.append(panel = HN.buildSettingsPanel()); + open = true; + HN.markSettings(panel); + panel.css('display', 'block'); + link.addClass('active').attr('aria-expanded', 'true'); + + // Click-away and Esc. The stopPropagation above is what makes binding + // here safe as well as necessary: without it this very click would carry + // on to the document and shut the panel again. + $(document).on('click.hnesSettings', close) + .on('keydown.hnesSettings', function(e) { + if (e.key === 'Escape') close(); + }); }); - stored.then(function(items) { - HN.MODES.forEach(function(spec) { - // The starting selection, by index — values[0] is the fallback for - // anything unset or unrecognised. - var i = Math.max(spec.values.indexOf(items[spec.key]), 0), - build = HN.MODE_UI[spec.ui]; + // A panel left open while another tab changes something: boot.js has + // already restyled the page underneath it, so without this its marks say + // one thing and the page says another. + HNESModes.subscribe(function() { + if (open) HN.markSettings(panel); + }); - // Appended already built, so a control never appears unlabelled and inert. - nav.append(build(spec, i)); - }); + // The `h` binding's way in. Guarded rather than a bare trigger, because + // clicking the gear while it is open closes it — which would make the key + // a toggle that fights whatever put the panel on screen. + HN.openSettings = function() { if (!open) link.trigger('click'); }; + + slot.append(host); + }, + + /* + * Reuses .nav-drop-down, the surface the user and "more" menus already use, + * so the panel inherits their placement and elevation rather than growing a + * second menu style. .hnes-settings then overrides the row styling, which is + * the only part a list of options does differently from a list of links. + */ + buildSettingsPanel: function() { + var panel = $('
    ').addClass('nav-drop-down hnes-settings'); + + // Stopped once, at the panel, rather than per option: the click-away + // handler is on the document, so without this a click on a group heading + // or on the panel's own padding would close it. Picking an option still + // reaches this on the way up, which is what keeps the panel open to pick + // again. + panel.click(function(e) { e.stopPropagation(); }); + + // Consecutive specs sharing a label share one heading, which is what puts + // two switches under a single "Reading" instead of a heading each. + var group = null, heading = null; + HNESModes.list.forEach(function(spec) { + if (spec.label !== heading) { + heading = spec.label; + group = $('
    ').addClass('hnes-settings-group') + .append($('
    ').addClass('hnes-settings-label') + .text(spec.label)); + panel.append(group); + } + group.append(HN.buildSettingsRows(spec, panel)); }); + + panel.append(HN.buildStorageGroup()); + return panel; }, - /* `i` is genuinely state here — each click reads it, advances it and writes - it back. The menu below only needs it as a starting selection. */ - buildModeCycle: function(spec, i) { - var link = $('').attr('href', 'javascript:void(0)').attr('title', spec.title), - wrap = $('').addClass('hnes-nav-toggle').text('|').append(link); - - link.text(spec.label + ': ' + spec.values[i]); - link.click(function() { - i = (i + 1) % spec.values.length; - link.text(spec.label + ': ' + spec.values[i]); - HN.commitMode(spec, spec.values[i]); + buildSettingsRows: function(spec, panel) { + var opts = $('
    ').addClass('hnes-settings-opts'); + + // A swatch group's rows *are* the swatches — see the note in style.css. + if (spec.ui === 'swatch') opts.addClass('hnes-settings-swatches'); + + // A switch is one row for the whole spec: the state is the switch, so + // drawing values[0] and values[1] as two rows would say it twice. + if (spec.ui === 'toggle') { + opts.append(HN.buildSettingsOpt(spec, spec.values[0], panel)); + if (spec.help) opts.append(HN.buildKeyHelp(spec.help)); + return opts; + } + + // Above the rows, inside the same box: a set needs a line saying what + // being in it means, which a list of named choices does not. + if (spec.hint) opts.append($('
    ').addClass('hnes-settings-note').text(spec.hint)); + spec.values.forEach(function(value) { + opts.append(HN.buildSettingsOpt(spec, value, panel)); }); + return opts; + }, - return wrap; + buildSettingsOpt: function(spec, value, panel) { + var toggle = spec.ui === 'toggle', + row = $('').attr('href', 'javascript:void(0)') + .addClass('hnes-settings-opt') + .attr('data-hnes-opt', spec.key + ':' + value.id), + text = $('').addClass('hnes-settings-text') + .append($('').addClass('hnes-settings-name') + .text(toggle ? spec.name : value.label)), + hint = toggle ? spec.hint : value.hint; + + // The row carries the palette, so it paints itself in that palette's own + // ground, ink and accent. It cannot drift from what picking it does, + // because it is the same stylesheet rule doing both. + if (spec.ui === 'swatch') { + row.attr('data-hnes-palette', value.id) + .append($('').addClass('hnes-swatch-bar')); + } + // Fourteen sections with a line of prose each would be the whole panel. + // They carry it as a tooltip instead — which is where that text already + // lives, on the nav links these rows decide the placement of. + if (hint && spec.ui === 'multi') row.attr('title', hint); + else if (hint) text.append($('').addClass('hnes-settings-hint').text(hint)); + row.append(text); + // The switch is an with no text, so on its own it is invisible to a + // screen reader — the row would read as its label and say nothing about + // which way it is set. markSettings keeps aria-checked in step. + if (toggle) row.addClass('hnes-settings-switchrow') + .attr('role', 'switch') + .append($('').addClass('hnes-settings-switch')); + + row.click(function() { + HNESModes.commit(spec, HN.nextSetting(spec, value)); + HN.markSettings(panel); + }); + + return row; }, /* - * Reuses .nav-drop-down, the surface the user and "more" menus already use, - * so the palette list inherits their placement, elevation and hover states - * rather than growing a second menu style. + * What clicking a row means, which is the only thing that differs between + * the `ui` kinds: a list picks, a switch flips, a set adds or removes. */ - buildModeMenu: function(spec, i) { - var link = $('').attr('href', 'javascript:void(0)').attr('title', spec.title), - menu = $('
    ').addClass('nav-drop-down'), - wrap = $('').addClass('hnes-nav-toggle hnes-nav-menu more-arrow') - .text('|').append(link).append(menu), - open = false, - close = function() { open = false; menu.hide(); link.removeClass('active'); }; - - link.text(spec.label + ': ' + spec.values[i]); - - spec.values.forEach(function(value, index) { - var option = $('').attr('href', 'javascript:void(0)').text(value); - if (index === i) option.addClass('nav-active-link'); - - option.click(function(e) { - e.stopPropagation(); - menu.find('a').removeClass('nav-active-link'); - option.addClass('nav-active-link'); - link.text(spec.label + ': ' + value); - HN.commitMode(spec, value); - close(); - }); + nextSetting: function(spec, value) { + if (spec.ui === 'toggle') { + return HNESModes.current(spec) === spec.values[0].id + ? spec.values[1].id : spec.values[0].id; + } + if (spec.ui === 'multi') { + var selected = HNESModes.selected(spec), + at = selected.indexOf(value.id); + if (at >= 0) selected.splice(at, 1); + else selected.push(value.id); + return selected.join(','); + } + return value.id; + }, + + /* + * The keyboard bindings, listed rather than settable. Rebinding is a real + * feature with a real cost — capture, conflict checking, a reset — and the + * thing actually missing was that they were nowhere written down. + */ + buildKeyHelp: function(keys) { + var list = $('
    ').addClass('hnes-keys'); + keys.forEach(function(key) { + list.append($('').text(key.id)) + .append($('').text(key.label)); + }); + return list; + }, - menu.append(option); + /* + * Not a setting — the one place in the extension that can say how much it + * is holding, and empty the one store that never shrinks. Comment collapse + * state is written per comment and carries no expire stamp, so the sweep in + * background.js steps over it and it has grown for the life of the + * extension with no way to see it, let alone clear it. + */ + buildStorageGroup: function() { + var group = $('
    ').addClass('hnes-settings-group') + .append($('
    ').addClass('hnes-settings-label').text('Storage')), + note = $('
    ').addClass('hnes-settings-note'), + row = $('').attr('href', 'javascript:void(0)') + .addClass('hnes-settings-opt hnes-settings-action') + .append($('').addClass('hnes-settings-text') + .append($('').addClass('hnes-settings-name') + .text('Clear collapsed comments')) + .append($('').addClass('hnes-settings-hint') + .text('Threads already open keep their state until reloaded'))); + + // getBytesInUse rather than reading the store: this runs on every open, + // and the store it is measuring is the one that gets large. + chrome.storage.local.getBytesInUse(null, function(bytes) { + note.text(HN.formatBytes(bytes) + ' stored'); }); - link.click(function(e) { - e.stopPropagation(); - // Any other open menu closes first; two floating surfaces at once read - // as a rendering bug rather than as two menus. Their triggers have to - // lose .active with them — the older menus toggle that class blindly, so - // leaving it set desyncs their next click from what is on screen. - $('.nav-drop-down').not(menu).hide(); - $('.more-arrow > a.active').not(link).removeClass('active'); - // Tracked rather than read back off the DOM: jQuery's :visible measures - // the element, which forces a synchronous layout of the whole document — - // expensive on a long thread, and for a fact we already know. - open = !open; - menu.toggle(open); - link.toggleClass('active', open); + row.click(function() { + chrome.storage.local.get(null, function(all) { + var keys = Object.keys(all).filter(function(key) { + var value = all[key]; + return value && typeof value === 'object' && 'isCollapsed' in value; + }); + chrome.storage.local.remove(keys, function() { + chrome.storage.local.getBytesInUse(null, function(bytes) { + note.text(keys.length + ' cleared — ' + HN.formatBytes(bytes) + ' left'); + }); + }); + }); }); - // Click-away, which the older menus never got. The trigger's - // stopPropagation is the load-bearing one — without it, opening the menu - // would immediately close it again. The options' call is belt-and-braces: - // they close explicitly, so bubbling here would be harmless. Namespaced so - // it can be unbound without disturbing other document click handlers. - $(document).on('click.hnesMode', close); + return group.append(note).append($('
    ').addClass('hnes-settings-opts').append(row)); + }, + + formatBytes: function(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + }, - return wrap; + /* Recomputed on every open rather than tracked, so the panel is right after + a change made in another tab as well as one made in this one. */ + markSettings: function(panel) { + panel.find('.hnes-settings-opt').removeClass('hnes-settings-on'); + HNESModes.list.forEach(function(spec) { + // A switch that is off has no row to mark: its row is values[0], the + // on state, so absence of the mark is what draws it off. + var ids = spec.ui === 'multi' ? HNESModes.selected(spec) + : [HNESModes.current(spec)]; + ids.forEach(function(id) { + panel.find('[data-hnes-opt="' + spec.key + ':' + id + '"]') + .addClass('hnes-settings-on'); + }); + }); + panel.find('.hnes-settings-switchrow').each(function() { + $(this).attr('aria-checked', $(this).hasClass('hnes-settings-on')); + }); }, /* @@ -1805,9 +1972,14 @@ var HN = { user_id = user_id + "'s"; new_active.text(user_id + " " + new_active.text()); } - $('#top-navigation .nav-links').append($('') - .text('|') - .append(new_active)); + // Queued rather than appended: the tab strip this reaches into is built + // from a stored setting now, so it may not exist yet. ready() fires in + // order, and rewriteNavigation queued first. + HNESModes.ready(function() { + $('#top-navigation .nav-links').append($('') + .text('|') + .append(new_active)); + }); } hidden_div.append( @@ -1825,31 +1997,33 @@ var HN = { hidden_div.hide(); HN.setTopColor(); }, + /* + * Which sections are header tabs and which sit under "more" is a stored + * preference now, so the header cannot be built until the read lands. + * reveal() waits on the same queue and was queued after this, so the page is + * never shown wearing the default tabs and then corrected. + */ rewriteNavigation: function() { + HNESModes.ready(function() { + var chosen = HNESModes.selected(HNESModes.spec('hnesNav')), + visible_pages = [], + hidden_pages = []; + + // Split in HNESModes.sections order rather than in the order they were + // picked, so moving one section across never reorders the others. + HNESModes.sections.forEach(function(section) { + (chosen.indexOf(section.id) >= 0 ? visible_pages : hidden_pages).push(section); + }); + + HN.paintNavigation(visible_pages, hidden_pages); + }); + }, + + paintNavigation: function(visible_pages, hidden_pages) { var topsel = $('.topsel'); - var more_nav = $('
    ').attr('id', 'morenav') - .addClass('topsel'); var navigation = $('td:nth-child(2) .pagetop'); navigation.attr('id', 'top-navigation'); - var visible_pages = [ ['top', '/news', 'Top stories'], - ['new', '/newest', 'Newest stories'], - ['best', '/best', 'Best stories'], - ['submit', '/submit', 'Submit a story'], - ]; - - var hidden_pages = [ ['show', '/show', 'Show HN'], - ['shownew', '/shownew', 'New Show HN posts'], - ['classic', '/classic', 'Only count votes from accounts older than one year'], - ['active', '/active', 'Active stories'], - ['ask', '/ask', 'Ask Hacker News'], - ['jobs', '/jobs', 'Sponsored job postings'], - ['bestcomments', '/bestcomments', 'Best comments'], - ['newcomments', '/newcomments', 'New comments'], - ['noobstories', '/noobstories', 'Stories by new users'], - ['noobcomments', '/noobcomments', 'Comments by new users'] - ]; - if (topsel.length == 0) { topsel = $('').addClass('nav-links'); navigation.append(topsel); @@ -1858,21 +2032,18 @@ var HN = { topsel.removeClass('topsel').addClass('nav-links'); topsel.empty(); } - for (var i in visible_pages) { - var link_text = visible_pages[i][0]; - var link_href = visible_pages[i][1]; - + visible_pages.forEach(function(section) { var span = $('').text('|'); - var new_link = $('').attr('href', link_href) - .text(link_text) - .addClass(link_text) - .attr('title', visible_pages[i][2]); + var new_link = $('').attr('href', section.href) + .text(section.label) + .addClass(section.label) + .attr('title', section.hint); - if (window.location.pathname == link_href) + if (window.location.pathname == section.href) new_link.addClass('nav-active-link') topsel.append(span.prepend(new_link)); - } + }); if (window.location.pathname == '/') $('.top').addClass('nav-active-link'); @@ -1886,23 +2057,23 @@ var HN = { .addClass('nav-drop-down'); var new_active = false; - for (var i in hidden_pages) { - var link_text = hidden_pages[i][0]; - var link_href = hidden_pages[i][1]; + hidden_pages.forEach(function(section) { + var new_link = $('').attr('href', section.href) + .attr('title', section.hint) + .text(section.label) + .addClass(section.label); - var new_link = $('').attr('href', link_href) - .attr('title', hidden_pages[i][2]) - .text(link_text) - .addClass(link_text); - - if (window.location.pathname == link_href) + if (window.location.pathname == section.href) new_active = new_link.clone().addClass('nav-active-link') .addClass('new-active-link'); hidden_div.append(new_link); - } + }); - topsel.append(more_link).append(hidden_div); + // Nothing left over means no menu to open: promoting every section is a + // reachable choice now, and a "more" with an empty drawer under it is + // the kind of dead affordance the panel exists to avoid. + if (hidden_pages.length) topsel.append(more_link).append(hidden_div); if (new_active) topsel.append($('').text('|').append(new_active)); @@ -1916,8 +2087,10 @@ var HN = { more_link.click(toggle_more_link); hidden_div.click(toggle_more_link); - hidden_div.offset({'left': more_link.position().left}); - hidden_div.hide(); + if (hidden_pages.length) { + hidden_div.offset({'left': more_link.position().left}); + hidden_div.hide(); + } }, toggleMoreNavLinks: function(e) { @@ -1944,21 +2117,23 @@ var HN = { var text = "Search on " + domain; $("input[name='q']").val(text); el.focus(function(){ - HN.searchInputFocused = true; if (el.val() == text) { el.val(""); } }); el.blur(function(){ - HN.searchInputFocused = false; if (el.val() == "") { el.val(text); } }); }, - searchInputFocused: false, - + /* + * The settings are read inside the handler rather than gating the binding, + * so turning shortcuts off in one tab is honoured by every open tab at the + * next keystroke rather than at its next load. It costs a cached lookup per + * keydown, on a handler that already runs on every keydown. + */ init_keys: function(){ var j = 74, // Next Item k = 75, // Previous Item @@ -1970,26 +2145,33 @@ var HN = { b = 66, // Open comments and link in new tab shiftKey = 16; // allow modifier $(document).keydown(function(e){ - //Keyboard shortcuts disabled when search focused - if (!HN.searchInputFocused && !e.ctrlKey) { - if (e.which == j) { - HN.next_story(); - } else if (e.which == k) { - HN.previous_story(); - } else if (e.which == l) { - HN.open_story_in_new_tab(); - } else if (e.which == o) { - HN.open_story_in_current_tab(); - } else if (e.which == p) { - HN.open_comments_in_current_tab(); - } else if (e.which == c) { - HN.open_comments_in_new_tab(); - } else if (e.which == h) { - //HN.open_help(); - } else if (e.which == b) { - HN.open_comments_in_new_tab(); - HN.open_story_in_new_tab(); - } + // Typing is not navigation. This used to check one flag set by the + // search box's own focus handler, which left every comment box and + // the submit form unguarded — `j` mid-reply scrolled the page out + // from under it. Asking the focused element covers all of them, and + // covers boxes HN adds later without being told about them. + var el = e.target; + if (el && (el.isContentEditable || + /^(?:INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return; + if (e.ctrlKey || !HNESModes.on('hnesKeys')) return; + + if (e.which == j) { + HN.next_story(); + } else if (e.which == k) { + HN.previous_story(); + } else if (e.which == l) { + HN.open_story_in_new_tab(); + } else if (e.which == o) { + HN.open_story_in_current_tab(); + } else if (e.which == p) { + HN.open_comments_in_current_tab(); + } else if (e.which == h) { + // The help this key was bound to was never written; the panel lists + // these bindings, so it is the screen the binding always meant. + if (HN.openSettings) HN.openSettings(); + } else if (e.which == b) { + HN.open_comments_in_new_tab(); + HN.open_story_in_new_tab(); } }) }, @@ -2088,31 +2270,28 @@ var HN = { } } -/* Keyed by a descriptor's `ui`, so a third kind of control is an entry here and - a value there rather than another branch in initModeControls. Out here rather - than inside the literal above because the builders it points at are members of - that literal, and HN is not bound until it closes. */ -HN.MODE_UI = { - cycle: HN.buildModeCycle, - menu: HN.buildModeMenu -}; - - //show new comment count on hckrnews.com if (window.location.host == "hckrnews.com") { - $('ul.entries li').each(function() { - HN.getLocalStorage($(this).attr('id'), function(response) { - if (response.data != undefined) { - var data = JSON.parse(response.data); - var id = data.id; - var num = data.num ? data.num : 0; - var now = Number($('#'+id).find('.comments').text()); - var unread = Math.max(now - num, 0); - var prepend = unread == 0 ? "" + unread + " / " : ""+unread+" / "; - $(document).ready(function() { - $('#'+id).find('.comments').prepend(prepend); - }); - } + // Gated on the setting because this is the one place HNES touches a host + // other than Hacker News, and until the panel existed there was no way to + // find that out, let alone stop it. The read is skipped, not just the + // rendering — the point of switching it off is the reads. + HNESModes.ready(function() { + if (!HNESModes.on('hnesHckrnews')) return; + $('ul.entries li').each(function() { + HN.getLocalStorage($(this).attr('id'), function(response) { + if (response.data != undefined) { + var data = JSON.parse(response.data); + var id = data.id; + var num = data.num ? data.num : 0; + var now = Number($('#'+id).find('.comments').text()); + var unread = Math.max(now - num, 0); + var prepend = unread == 0 ? "" + unread + " / " : ""+unread+" / "; + $(document).ready(function() { + $('#'+id).find('.comments').prepend(prepend); + }); + } + }); }); }); } @@ -2147,7 +2326,7 @@ else { }); } - HN.initModeControls(); + HN.initSettings(); HN.reveal(); }); } diff --git a/js/modes.js b/js/modes.js new file mode 100644 index 0000000..0ea6dae --- /dev/null +++ b/js/modes.js @@ -0,0 +1,274 @@ +/* + * Every stored preference, defined once, plus the persistence around them. + * + * Loaded as a document_start content script ahead of boot.js, which applies the + * stored values before first paint, and read again by hn.js at document_end, + * which builds the settings panel from it and asks it what to do. Content + * scripts of one extension share one isolated world, so both see this without a + * module system. + * + * This used to be two lists — one in boot.js, one in hn.js — because hn.js does + * not exist yet at document_start. Adding a mode to one and not the other made + * it work after paint and flash on every cold load, which is a bug you only + * catch on a cold profile. + * + * There are two families here now. A spec with `attr` paints: boot.js writes it + * onto before paint and the stylesheet does the rest, so it can change + * live and cross-tab. A spec without one is behaviour — hn.js reads it and + * decides what to build or bind, which means it takes effect on the next load + * of a page rather than under a page already on screen. + * + * values[0] is the unset state: applying it removes the attribute instead of + * setting it, so "which values are real" is derived from the list rather than + * restated as a condition somewhere else. For a toggle that makes values[0] the + * shipped default, which is `on` for all three — every one of them describes + * something HNES did unconditionally before it had a switch. + * + * `ui` picks how a spec's options are drawn in the panel, not what they do: + * `list` is a name plus a line of explanation, `swatch` trades that line for a + * rendering of the palette itself (five colour schemes are not a thing prose is + * good at), `toggle` is one row and a switch, `multi` is a set rather than a + * choice. Specs sharing a `label` share one heading in the panel. + */ +(function () { + /* + * Hacker News' own section pages. The split into header tabs and "more" was + * hardcoded in hn.js and is now a preference — someone who lives on /ask + * should not go through a dropdown for it every time. Order here is the order + * they are drawn in, in both places. + */ + var SECTIONS = [ + { id: 'top', href: '/news', label: 'top', hint: 'Top stories' }, + { id: 'new', href: '/newest', label: 'new', hint: 'Newest stories' }, + { id: 'best', href: '/best', label: 'best', hint: 'Best stories' }, + { id: 'submit', href: '/submit', label: 'submit', hint: 'Submit a story' }, + { id: 'show', href: '/show', label: 'show', hint: 'Show HN' }, + { id: 'shownew', href: '/shownew', label: 'shownew', hint: 'New Show HN posts' }, + { id: 'classic', href: '/classic', label: 'classic', hint: 'Only counts votes from accounts over a year old' }, + { id: 'active', href: '/active', label: 'active', hint: 'Active stories' }, + { id: 'ask', href: '/ask', label: 'ask', hint: 'Ask Hacker News' }, + { id: 'jobs', href: '/jobs', label: 'jobs', hint: 'Sponsored job postings' }, + { id: 'bestcomments', href: '/bestcomments', label: 'bestcomments', hint: 'Best comments' }, + { id: 'newcomments', href: '/newcomments', label: 'newcomments', hint: 'New comments' }, + { id: 'noobstories', href: '/noobstories', label: 'noobstories', hint: 'Stories by new users' }, + { id: 'noobcomments', href: '/noobcomments', label: 'noobcomments', hint: 'Comments by new users' } + ]; + + /* The bindings hn.js has always had. `h` opened a help screen that was never + written — the line was commented out where it was bound — so it opens the + panel this list is drawn in, which is the help it was reaching for. */ + var KEYS = [ + { id: 'j', label: 'Next story' }, + { id: 'k', label: 'Previous story' }, + { id: 'o', label: 'Open the story' }, + { id: 'l', label: 'Open the story in a new tab' }, + { id: 'p', label: 'Open the comments' }, + { id: 'c', label: 'Open the comments in a new tab' }, + { id: 'b', label: 'Open both in new tabs' }, + { id: 'h', label: 'Open these settings' } + ]; + + var ON_OFF = [{ id: 'on' }, { id: 'off' }]; + + var MODES = [ + { + key: 'hnesTheme', attr: 'data-hnes-theme', label: 'Theme', ui: 'list', + values: [ + { id: 'auto', label: 'Auto', hint: 'Follow the system setting' }, + { id: 'light', label: 'Light' }, + { id: 'dark', label: 'Dark' } + ] + }, + { + /* Stored as hnesDensity and shown as "View": the key predates the label + and renaming it would strand everyone's existing choice. */ + key: 'hnesDensity', attr: 'data-hnes-density', label: 'View', ui: 'list', + values: [ + { id: 'comfortable', label: 'Comfortable', hint: 'Roomy rows and cards' }, + { id: 'compact', label: 'Compact', hint: 'Smaller type, more rows per screen' }, + { id: 'flow', label: 'Flow', hint: 'Full-size type, no card chrome' } + ] + }, + { + key: 'hnesPalette', attr: 'data-hnes-palette', label: 'Palette', ui: 'swatch', + values: [ + { id: 'classic', label: 'Classic' }, + { id: 'newsprint', label: 'Newsprint' }, + { id: 'ember', label: 'Ember' }, + { id: 'slate', label: 'Slate' }, + { id: 'letterpress', label: 'Letterpress' } + ] + }, + { + key: 'hnesNewComments', label: 'Reading', ui: 'toggle', values: ON_OFF, + name: 'Highlight new comments', + hint: 'Marks replies posted since your last visit to a thread' + }, + { + key: 'hnesHckrnews', label: 'Reading', ui: 'toggle', values: ON_OFF, + name: 'hckrnews.com counts', + hint: 'Unread comment counts on hckrnews.com, from the same read state' + }, + { + key: 'hnesKeys', label: 'Keyboard', ui: 'toggle', values: ON_OFF, help: KEYS, + name: 'Shortcuts', + hint: 'Ignored while a text box has focus' + }, + { + /* A set rather than a choice, so it is stored as a comma-joined list. + Empty is a real answer — it means every section lives under "more" — + which is why the default lives here and not in a `|| fallback`. */ + key: 'hnesNav', label: 'Sections', ui: 'multi', values: SECTIONS, + dflt: 'top,new,best,submit', + hint: 'Shown in the header; the rest stay under "more"' + } + ]; + + var VALUES = {}, + loaded = false, + started = false, + waiting = [], + subscribers = []; + + globalThis.HNESModes = { + list: MODES, + sections: SECTIONS, + + keys: function () { + return MODES.map(function (spec) { return spec.key; }); + }, + + spec: function (key) { + for (var i = 0; i < MODES.length; i++) { + if (MODES[i].key === key) return MODES[i]; + } + return null; + }, + + /* -1 for anything this build does not know, which callers treat exactly + like values[0] — that is what makes a value written by a newer build + degrade to the default rather than stick as an unstyled attribute. */ + indexOf: function (spec, value) { + for (var i = 0; i < spec.values.length; i++) { + if (spec.values[i].id === value) return i; + } + return -1; + }, + + /* + * The value in force. A painting spec is read off the document rather than + * the cache, because that is what the page is actually wearing: boot.js has + * already put it there and its cross-tab listener keeps it current. + */ + current: function (spec) { + var raw = spec.attr + ? document.documentElement.getAttribute(spec.attr) + : VALUES[spec.key]; + return spec.values[Math.max(this.indexOf(spec, raw), 0)].id; + }, + + /* The `multi` counterpart of current(). */ + selected: function (spec) { + var raw = VALUES[spec.key]; + if (raw === undefined || raw === null) raw = spec.dflt; + return String(raw).split(',').filter(function (id) { return id !== ''; }); + }, + + /* Convenience for the behaviour toggles, which is all hn.js wants of them. */ + on: function (key) { + var spec = this.spec(key); + return !spec || this.current(spec) === 'on'; + }, + + apply: function (root, spec, value) { + if (this.indexOf(spec, value) > 0) root.setAttribute(spec.attr, value); + else root.removeAttribute(spec.attr); + }, + + applyAll: function (root, items) { + var self = this; + MODES.forEach(function (spec) { + if (spec.attr) self.apply(root, spec, items[spec.key]); + }); + }, + + /* + * The one write path, so a new `ui` cannot forget half of it: cache, paint, + * persist. Persisting is what every other open tab hears through watch(). + * Values are stringified because that is what the rest of this extension's + * storage does, and the cache has to match what a reload would read back. + */ + commit: function (spec, value) { + var item = {}; + VALUES[spec.key] = String(value); + if (spec.attr) this.apply(document.documentElement, spec, value); + item[spec.key] = String(value); + try { chrome.storage.local.set(item); } catch (e) { /* see load() */ } + }, + + /* + * One read for every key, cached. Called by boot.js at document_start so it + * is in flight while HN's markup is still parsing; by the time hn.js asks, + * it has almost always landed and ready() runs without waiting at all. + */ + load: function (callback) { + started = true; + var done = function (items) { + VALUES = items || {}; + loaded = true; + if (callback) callback(VALUES); + while (waiting.length) waiting.shift()(VALUES); + }; + /* Storage unavailable — hand out the defaults rather than never + resolving, or hn.js would wait for a reveal that cannot come. */ + try { chrome.storage.local.get(this.keys(), done); } catch (e) { done({}); } + }, + + ready: function (callback) { + if (loaded) return callback(VALUES); + waiting.push(callback); + /* Self-starting, so a caller that runs without boot.js — a page where the + document_start script was skipped — still gets its values. */ + if (!started) this.load(); + }, + + /* + * The settings panel paints its own tab directly, so this is what every + * *other* open Hacker News tab hears. Without it two tabs disagree until + * each is reloaded, which reads as the setting not having saved — and a + * panel reads as global settings in a way three nav links did not. + * + * Cheaper than the alternative as well: no tabs permission (dropped in + * 2a907f8 for store review), no message plumbing, and background tabs and + * second windows are covered without being told to be. + * + * Behaviour specs update the cache but cannot repaint anything — the nav + * they decided is already built. They are correct on this tab's next load. + */ + watch: function (root) { + var self = this; + try { + chrome.storage.onChanged.addListener(function (changes, area) { + if (area !== 'local') return; + var touched = []; + MODES.forEach(function (spec) { + if (!(spec.key in changes)) return; + VALUES[spec.key] = changes[spec.key].newValue; + if (spec.attr) self.apply(root, spec, changes[spec.key].newValue); + touched.push(spec); + }); + if (touched.length) { + subscribers.forEach(function (callback) { callback(touched); }); + } + }); + } catch (e) { /* see load() */ } + }, + + /* For anything that has to react as well as repaint — the open settings + panel, whose marks are drawn from these values and would otherwise sit + stale under a page the listener above has already restyled. */ + subscribe: function (callback) { + subscribers.push(callback); + } + }; +})(); diff --git a/manifest.json b/manifest.json index 306b941..1fee23c 100644 --- a/manifest.json +++ b/manifest.json @@ -22,7 +22,7 @@ "content_scripts": [ { "run_at": "document_start", "css": [ "style.css" ], - "js": [ "js/boot.js" ], + "js": [ "js/modes.js", "js/boot.js" ], "matches": [ "https://news.ycombinator.com/*", "https://hackerne.ws/*"] diff --git a/proposals/README.md b/proposals/README.md index 9ef1a85..e0b2a19 100644 --- a/proposals/README.md +++ b/proposals/README.md @@ -24,6 +24,7 @@ it correct. Verified against live `news.ycombinator.com` markup on 2026-07-31. | Phase 3 — hygiene | Not started | | Design tracks | Proposals only | | Palette as a user option | **Implemented** — [`palettes.md`](./palettes.md) | +| Controls out of the nav | **Implemented** — [`settings-panel.md`](./settings-panel.md) | | Tests | **Added** — see [`../test/README.md`](../test/README.md) | The verification below is no longer a list to work through by hand; most of it runs. @@ -230,7 +231,8 @@ product decision, not a styling one. ### Track B′ — all four shipped as a user option *(implemented)* Rather than picking one, all four ship behind a third runtime axis, `data-hnes-palette`, -alongside the theme and density toggles. `classic` is the default and the unset state, so +alongside the theme and view axes, all three now behind the settings gear +([`settings-panel.md`](./settings-panel.md)). `classic` is the default and the unset state, so nothing changes for anyone who ignores the control. The colour block is split into eleven seeds per palette and a `color-mix()` derived layer diff --git a/proposals/settings-panel.md b/proposals/settings-panel.md new file mode 100644 index 0000000..f92fb4f --- /dev/null +++ b/proposals/settings-panel.md @@ -0,0 +1,320 @@ +# The settings panel — theme, view and palette behind one gear + +**Status: implemented.** The three nav controls are gone; a gear at the right end +of the header opens a panel holding all three. + +This document started as a proposal to move them into a popup. The reasons held +up; the surface changed twice while it was being built, and the interesting part +is which of the plan's supporting arguments turned out to be doing no work. + +## What it looks like + +One `` carrying an inline gear at the right end of the header — in the third +cell, beside the login link or the user menu, rather than among the section tabs +— and one panel built into the page behind it. Three groups — Theme, View, +Palette — each a list of options with the current one marked. Theme and view +carry a line of explanation each; the palette rows *are* the swatches. + +The stored keys (`hnesTheme`, `hnesDensity`, `hnesPalette`) and the `data-hnes-*` +attributes on `` are unchanged, so there was no migration and no version +gate. Everyone's existing choice survived the change without being touched. + +## What was there before + +Three controls appended to the nav on every page load: `theme: auto`, +`view: comfortable`, `palette: classic` — around 45 characters of permanent +chrome, for settings a user touches about twice. + +Their shapes disagreed for a reason that was about the nav rather than about the +settings: palette was a menu because five labels do not fit, theme and view were +cycles because three do. Neither shape had anywhere to say what `flow` or +`newsprint` actually do, which is most of what someone choosing between them +wants to know. + +## The three surfaces, and why this one + +| | Discoverable | Reach | Live apply | Extra plumbing | +|---|---|---|---|---| +| Browser-action popup | Only if pinned | Any tab | needs storage plumbing | manifest `action` | +| Options page | Only via the extensions menu | Any tab | needs storage plumbing | `options_ui`, a second document | +| **In-page panel** | **Always** | HN tabs | free — same document | none | + +The first two were both built out on paper before the third won, and the argument +that settled it is that HNES only affects two hosts. "Reach from any tab" is the +structural advantage a popup or an options page has, and changing the Hacker News +palette while looking at a spreadsheet is not a thing anyone does — so both were +paying the real cost (invisible unless you go looking) for a benefit worth +approximately nothing here. + +Discoverability was the whole risk in this change. The old controls were +impossible to miss. A gear in the same place is a smaller thing to notice but not +a hidden one, and it is the only one of the three options that keeps the setting +where the thing it changes is. + +## What shipped, and what the plan got wrong + +``` +js/modes.js +264 the descriptor list and its persistence, once +js/hn.js +390/-225 panel in, six mode functions out; four behaviours gated +js/boot.js +16/-41 mirrored list out, load/watch in +style.css +278/-43 panel styles in, nav-toggle styles out; seeds split out +manifest.json +1 modes.js ahead of boot.js at document_start +``` + +Three things the plan called for and did not survive: + +- **`css/tokens.css`.** The split existed so a second document could share the + colour tokens. There is no second document, so there is nothing to share and + the split would have been churn for its own sake. +- **The iframe.** Same reason — it was buying style isolation for a panel living + in a document that is not HN's. A panel in HN's own DOM needs no isolation from + a stylesheet this repo also owns; `.hnes-settings` out-specifies the two + `.nav-drop-down` rules it inherits and that is the entire cost. +- **`web_accessible_resources`.** Followed the iframe out. + +One thing the plan had for the wrong reason. It argued that taking the controls +out of the page removes the render-critical-path coupling — `initModeControls` +awaited `boot.js`'s stored values so the nav would not visibly grow after paint. +True, but that is not what removed it. **The panel body is built on first click,** +not at init, and that is what does it: a click happens long after boot.js's read +has landed, so the panel reads its selection straight off `` and there is +nothing to wait for. The same laziness is why a choice made in another tab shows +up correctly here — the marks are recomputed on every open rather than tracked, +so there is no second copy of the state to go stale. + +`window.hnesModes` and its microtask-timing comment are gone either way. + +That holds for the panel, and it stopped holding for the page: the Sections +setting put a storage read back in front of the reveal, because the header +cannot be built from a value nobody has read yet. See below for what that costs +and why it is not the same coupling. + +## The swatch, and the one stylesheet change it forced + +A palette control that renders the word `ember` was the best the nav could do. +The panel has room for the palette itself. + +Each palette row carries `data-hnes-palette` and so paints itself in that +palette's own ground, ink, rule and accent. The colours had to come from the +palette blocks rather than from a copied list, or the swatch would drift from +what picking it does. That meant the palette blocks had to be able to match an +element and not just the document: + +```css +:root:where([data-hnes-palette="ember"]) -> [data-hnes-palette="ember"] +``` + +Both weigh (0,1,0), so the source-order argument the blocks were built on — and +the responsive steps at the foot of the file that depend on it — is untouched. + +The rows butt together with no gap, and that is the design rather than a detail. +These five palettes are all warm papers whose grounds sit within a few percent of +each other in light mode; separated by white space and text that difference is +invisible, and a small chip of it is invisible twice over. At a shared edge it is +not — simultaneous contrast does work no chip size can. The alternative, drawing +a miniature page with the difference amplified, would make the swatch lie about +what picking it does. + +Two consequences worth knowing: + +- **Classic needed a rule of its own.** It is the unset state on ``, so a + classic swatch with no attribute would inherit whatever the page is currently + set to. The seeds moved into `:root, [data-hnes-palette="classic"]`, and the + brand-collapse rule picked up a `:not(:where([data-hnes-palette="classic"]))` + so a classic swatch keeps classic's two oranges. The `:where()` is load-bearing + rather than decorative: a bare `:not()` takes its argument's specificity, which + would quietly put that one rule above the (0,1,0) every other palette rule is + built on, and break the "a future palette can just say so" extension point the + comment above it advertises. +- **The swatch is drawn from seeds only.** The derived layer stays on `:root`, + because a custom property has its `var()`s substituted at computed-value time — + a swatch inherits root's already-resolved derivations, not its own. bg, orange, + fg, fg-muted and border are all seeds, so this costs nothing today, but a + swatch that reached for `--hnes-surface-alt` would silently show the page's. + +`test/tokens.mjs` still passes all 110 pair/palette/theme combinations, which is +what says the restructuring changed nothing on screen. + +## A bug the move surfaced + +Putting the gear in the header's third cell put the panel inside the reach of +`html body #header td:nth-child(3) a` — a catch-all pill rule at (1,1,4), which +out-specifies the `.nav-drop-down` rules by a comfortable margin. Every option +row came out as an inline-block pill and the panel rendered as a horizontal +smear. + +The panel was not the only casualty. `#user-hidden`, the logged-in user menu, +hangs off that same cell, and had been getting the same treatment for as long as +the rule has existed — a row of pills where a list was intended. It went unseen +because none of the harnesses log in, and the one written for this change had to +fake a session to confirm the fix. + +Excluding dropdown contents from the catch-all — `a:not(.nav-drop-down a)` — is +the fix for both, and is the right scope for that rule regardless: a menu row is +a list item, not a pill. + +## Cross-tab sync + +`boot.js` grew a `chrome.storage.onChanged` listener. The panel paints its own +tab directly; this is what every other open HN tab hears. + +Not in the original scope, and added because the panel makes it necessary rather +than nice: three nav links read as this page's controls, and a settings panel +reads as global settings. Two tabs disagreeing until each is reloaded reads as +the setting not having saved. + +It is also the cheap way round — no `tabs` permission (dropped in `2a907f8` for +store review), no messaging, and background tabs and second windows are covered +without being told to be. The extension still requests exactly the permissions it +did before this change. + +## What it holds now + +The three modes were the reason to build the surface. What the surface then made +possible is the rest of this list, and none of it is a new feature — every entry +is something HNES already did, unconditionally and invisibly. + +| Group | | Was | +|---|---|---| +| Theme, View, Palette | three lists | three nav controls | +| Reading | highlight new comments, hckrnews.com counts | always on | +| Keyboard | shortcuts on/off, and the bindings listed | always on, written down nowhere | +| Sections | which of the 14 are header tabs | two hardcoded arrays in `rewriteNavigation` | +| Storage | how much is held, and clear the part that never expires | no surface at all | + +Three of those are worth their own note. + +**Storage is the only one that fixes something.** `background.js` says so in its +own comment: the expiry sweep only understands string values carrying an +`expire` stamp, and comment collapse state is written per comment as an object, +so it has never been swept and has grown for the life of the extension. Nothing +could see it and nothing could clear it. The row reports `getBytesInUse` on open +— cheap, and the store it is measuring is the one that gets large — and reads +the store in full only when clicked, which is also when it can say how many +entries it removed. + +**The keyboard bindings are listed, not rebindable.** `h` was bound to a help +screen that was never written; the call was commented out where it was bound. +The panel lists these bindings, so `h` opens the panel — which is the screen that +binding was always reaching for. Rebinding is a real feature with a real cost +(capture, conflict checking, a reset) and the thing actually missing was that +nobody could find out what the keys were. + +**Sections is the one that changes markup rather than style**, and it is why the +persistence had to move. See below. + +## Behaviour settings, and what they cost + +The original three all paint: boot.js writes an attribute onto `` before +first paint and the stylesheet does the rest, which is what makes them free to +change live and across tabs. The four added here mostly do not. `rewriteNavigation` +has to *know* which sections are tabs before it builds the header, and no +attribute on `` can tell it that. + +So `modes.js` grew the `load` / `commit` / `watch` split that the previous round +listed as still open, and specs split into two families: one with an `attr`, +which paints, and one without, which hn.js reads and acts on. + +The cost is one ordering rule. `rewriteNavigation` and `reveal` both queue on +`HNESModes.ready`, and ready fires its callbacks in order, so the header is built +before the page is shown — otherwise the default tabs would paint and then be +corrected. In practice that waits for nothing: boot.js issues the read at +document_start, so it has landed long before document_end. Two guards keep a +storage failure from costing a reveal rather than a setting — `load` resolves +with defaults when `chrome.storage` throws, and the stylesheet's failsafe +animation still reveals the page on a timer. + +`rewriteUserNav` had to queue on the same thing. It appends the current user page +into `#top-navigation .nav-links`, which now might not exist yet — a one-line +reach into markup another function owns, only reachable while logged in on +`/upvoted` or a profile, and exactly the kind of thing a test that never logs in +cannot see. + +## A second bug the panel surfaced + +The keyboard shortcuts were guarded by one flag, `HN.searchInputFocused`, set by +the search box's own focus handler. Every other text box on Hacker News — +every comment box, the submit form, the profile editor — was unguarded, so `j` +typed mid-reply scrolled the page out from under it. + +Asking the focused element instead (`INPUT`, `TEXTAREA`, `SELECT`, +`isContentEditable`) covers all of them, and covers boxes HN adds later without +being told about them. The flag it replaces is gone. + +That fix is not what the toggle is for, but it is what looking for a reason to +want the toggle turned it up. + +## Tests + +`test/controls.mjs` was rewritten and now asserts rather than logs — 33 checks, +exit code and all. Beyond the old coverage (attribute written, choice persists, +applied before the reveal, palette and view orthogonal) it adds: + +- the panel is **lazy** — nothing exists in the DOM until the gear is clicked +- every group is drawn, with the right **number** of marks in each: one for a + list, two for the two switches under Reading, four for the chosen sections, + none for Storage +- the five swatches resolve to **five distinct grounds**, which is what fails if + a swatch ever inherits the page's palette instead of carrying its own +- a **second tab** follows a change without being reloaded, and an **open panel** + re-marks itself when the other tab is the one that wrote +- click-away and Escape both close it +- a **switch flips**, and `h` stops opening the panel when shortcuts are off — + the behavioural assertion, since a behaviour setting writes no attribute to + look at +- **typing is not navigation**: `h` with the search box focused does nothing +- a chosen section is a **header tab after a reload**, and has left the `more` + menu — the one setting that rebuilds markup rather than restyling it +- clearing storage **reports what it freed** + +Two things about the harness itself. Hacker News rate-limits a driven browser +readily, and a 429 is neither a pass nor a failure — it is a page the run never +got to look at. The comment-page check now says `skip` and prints the status +rather than reporting a gear that was never built, which is the same thing +`test/pages.mjs` does with its sweep. Console errors are filtered to script +errors for the same reason: a failed request is HN's answer to being driven, not +a bug in the extension. + +One thing that test learned the hard way: there is no inert pixel on the left of +an HNES front page. The comment count and score are gutter columns, and both are +links, so the click-away target has to be hunted with `elementFromPoint` rather +than guessed at — clicking a link closes the panel by navigating, which passes a +naive check for the wrong reason. + +`test/pages.mjs` now counts gears instead of toggles. On the last sweep the six +pages that returned 200 all built it; the rest drew a 429 and are untested rather +than passing, as ever. + +## Still open + +- **Collapse state still grows.** The panel can now measure it and empty it, but + clearing it is a thing someone has to think to do. The fix at the right depth + is an expiry stamp on the collapse entries so `background.js` can sweep them + like everything else, which means changing what `HNComments.storeMeta` writes + and reading both shapes for a release. +- **User tags and karma have no surface either.** `setUserTag` and + `upvoteUserData` write one record per username with no way to list, edit or + clear them. That is a list view rather than a panel row — closer to a + sub-page, and the reason it is not here. +- **Keyboard navigation inside the panel.** The options are + ``, so they are focusable and the panel closes on + Escape; the gear carries `aria-expanded` and the switches `role="switch"` with + `aria-checked` kept in step by `markSettings`. Arrow-key navigation within a + group is still not implemented, which the fourteen-row Sections list is the + first group long enough to want. +- **Firefox** loads the same manifest as an event page and supports + `storage.onChanged`, but this has not been driven by hand there yet. +- **Three floating menus, three implementations.** The `more` menu and the user + menu each toggle their own visibility and their trigger's `.active` blindly, + and neither closes on a click elsewhere. The panel is the only one that does, + which is why opening it has to reach in and clean up after the other two + (`hn.js`, `$('.more-arrow > a.active').removeClass('active')`). That is + knowledge a fourth surface would have to learn as well. The fix is one + `HN.bindDropdown(trigger, menu)` — paint, close siblings, click-away, Escape — + called from all three sites, which would also give the older two the + click-away they have never had. Deliberately not done here: it rewrites two + components this change does not otherwise touch. +- ~~An already-open panel does not follow another tab.~~ Closed by the same + refactor: `HNESModes.subscribe` hands the panel the notification `watch` + already receives, so a panel left open while another tab changes something + re-marks itself instead of contradicting the page under it. diff --git a/style.css b/style.css index 54ad38e..4b444c4 100644 --- a/style.css +++ b/style.css @@ -28,12 +28,24 @@ * --------------------------------------------------------------------------- */ -:root { - color-scheme: light dark; - - /* ========================================================================= - SEEDS — the palette surface. A palette block replaces exactly these. - ========================================================================= */ +/* ========================================================================= + SEEDS — the palette surface. A palette block replaces exactly these. + + In their own rule, and matched on the attribute rather than on :root, so + that an *element* can carry a palette as well as the document can: the + settings panel draws each palette as a swatch, and the swatch for `classic` + has to be able to say "classic" rather than inherit whatever the page is + currently set to. [data-hnes-palette="classic"] weighs (0,1,0), the same as + the bare :root it replaces here, so the palette blocks further down still + win on source order alone. + + Seeds only. The derived layer below stays on :root, because a custom + property's var()s are substituted at computed-value time — a swatch would + inherit root's already-resolved derivations, not its own. That is why the + swatch is drawn from bg, orange, fg and border and nothing else. + ========================================================================= */ +:root, +[data-hnes-palette="classic"] { /* * Two oranges, deliberately. --hnes-brand paints large surfaces (header, @@ -81,6 +93,10 @@ /* Text selection: a wash of the accent over the page, but not on the accent-into-bg line either theme would predict, so it is stated. */ --hnes-selection: light-dark(#ffd9bf, #5a3410); +} + +:root { + color-scheme: light dark; /* ========================================================================= DERIVED — everything below is palette-independent and never restated by a @@ -247,13 +263,13 @@ } -/* Manual override from the theme toggle; 'auto' removes the attribute entirely. */ +/* Manual override from the settings panel; 'auto' removes the attribute entirely. */ :root[data-hnes-theme="light"] { color-scheme: only light; } :root[data-hnes-theme="dark"] { color-scheme: only dark; } /* --------------------------------------------------------------------------- - Palettes, set by the palette control; 'classic' removes the attribute and + Palettes, set from the settings panel; 'classic' removes the attribute and falls back to the seeds above. Each block is seeds only — the derived layer picks the change up for free, which is the whole reason the split exists. @@ -271,22 +287,26 @@ reason — the header there is the bright accent, not a burnt one, so the ink on it has to invert. - :where() so these weigh (0,1,0) — the same as the bare :root they override, - since :root is itself a pseudo-class — winning on source order alone. Same - reason the density blocks use it, and what keeps the responsive steps at the - foot of the file working without knowing palettes exist. + A bare attribute selector weighs (0,1,0) — the same as the :root carrying the + seeds, since :root is itself a pseudo-class — so these win on source order + alone, which is what keeps the responsive steps at the foot of the file + working without knowing palettes exist. It also matches elements other than + the document, which is what lets the settings panel draw a live swatch of + each palette instead of a hardcoded copy of one. Contrast measured per palette per theme on the eight load-bearing pairs; all clear WCAG AA, most AAA, none below classic. --------------------------------------------------------------------------- */ -/* classic is the unset state — applyMode and boot.js only ever set the - attribute for values past the first — so the presence of the attribute is - exactly "some palette other than classic". Ahead of the blocks below, so a - future palette that wants its two oranges back can just say so. */ -:root:where([data-hnes-palette]) { --hnes-brand: var(--hnes-orange); } +/* classic is the unset state on the document — HNESModes.apply only ever sets + the attribute for values past the first — so on the presence of the + attribute is exactly "some palette other than classic". The swatches do state + it explicitly, hence the :not(): a classic swatch keeps classic's two oranges + rather than collapsing them. Ahead of the blocks below, so a future palette + that wants its two oranges back can just say so. */ +[data-hnes-palette]:not(:where([data-hnes-palette="classic"])) { --hnes-brand: var(--hnes-orange); } -:root:where([data-hnes-palette="newsprint"]) { +[data-hnes-palette="newsprint"] { --hnes-bg: light-dark(#fdfdfc, #0d0d0c); --hnes-surface: light-dark(#ffffff, #151513); --hnes-fg: light-dark(#111110, #f3f2ec); @@ -299,7 +319,7 @@ --hnes-selection: light-dark(#f7dfd6, #4f2d1c); } -:root:where([data-hnes-palette="ember"]) { +[data-hnes-palette="ember"] { --hnes-bg: light-dark(#fbf6f1, #14100c); --hnes-surface: light-dark(#fffcf9, #1e1712); --hnes-fg: light-dark(#241a12, #f0e6dc); @@ -312,7 +332,7 @@ --hnes-selection: light-dark(#efd8cc, #54341f); } -:root:where([data-hnes-palette="slate"]) { +[data-hnes-palette="slate"] { --hnes-bg: light-dark(#f1f3f4, #0f1214); --hnes-surface: light-dark(#ffffff, #181d20); --hnes-fg: light-dark(#14181a, #e3e8ea); @@ -325,7 +345,7 @@ --hnes-selection: light-dark(#e9d6ce, #513123); } -:root:where([data-hnes-palette="letterpress"]) { +[data-hnes-palette="letterpress"] { --hnes-bg: light-dark(#fefaf9, #0f0b09); --hnes-surface: light-dark(#ffffff, #1b1613); --hnes-fg: light-dark(#19120f, #f0ece9); @@ -340,7 +360,7 @@ /* --------------------------------------------------------------------------- - View modes, set by the density toggle; 'comfortable' removes the attribute + View modes, set from the settings panel; 'comfortable' removes the attribute and falls back to the :root defaults. Two different levers, deliberately. `compact` shrinks the scale — smaller @@ -560,9 +580,16 @@ html body #header a:visited { color: var(--hnes-header-ink); } html body #header a:hover { color: var(--hnes-orange-ink); } /* One pill shape for every header link — section tabs, the login link and the - theme toggle. The .nav-links rule below adds only what differs. */ + settings gear. The .nav-links rule below adds only what differs. + + The third-cell selector is a catch-all for that side of the header, where the + links are nested a level deeper than `.pagetop > a` reaches. It has to stop + at the dropdowns that hang off it, though: a menu row is a list item, not a + pill, and at (1,1,4) this rule otherwise out-specifies the .nav-drop-down + rules below and reshapes every row inside the user menu and the settings + panel. */ html body #header .pagetop > a, -html body #header td:nth-child(3) a, +html body #header td:nth-child(3) a:not(.nav-drop-down a), html body .nav-links > span > a, html body .nav-links > span > a:link, html body .nav-links > span > a:visited { @@ -573,7 +600,9 @@ html body .nav-links > span > a:visited { border-radius: var(--hnes-radius-pill); transition: background-color .12s ease, color .12s ease; } -html body #header td:nth-child(3) a:hover { background: var(--hnes-header-hover); } +html body #header td:nth-child(3) a:not(.nav-drop-down a):hover { + background: var(--hnes-header-hover); +} /* news.css sets .pagetop{font-size:10pt} at the same specificity, so an unprefixed rule here silently loses. */ @@ -607,8 +636,11 @@ html body .more-arrow:hover > a { color: var(--hnes-orange-ink) !important; background: var(--hnes-header-hover); } -/* The current section reads as a filled tab rather than just brighter text. */ +/* The current section reads as a filled tab rather than just brighter text, and + an open menu takes the same treatment — including the gear, which is a menu + trigger like the other two even though it wants no caret. */ html body .nav-links > span > a.nav-active-link, +html body .hnes-settings-host > a.active, html body .more-arrow > a.active { color: var(--hnes-brand) !important; background: var(--hnes-orange-ink); @@ -673,28 +705,231 @@ html body .nav-drop-down a:hover { border-top: 1px solid var(--hnes-border); } -/* A toggle is a direct child span of .nav-links, so the pill and font-size:0 - rules above already cover it; only the cursor needs saying. */ -.hnes-nav-toggle > a { cursor: pointer; } +/* --------------------------------------------------------------------------- + Settings panel + + One gear at the right end of the header, one panel behind it. It lives in the + third cell beside the login link rather than among the section tabs, so the + pill rule above already shapes it; the icon is an inline SVG on currentColor, + so it takes the header ink and the hover state for free. + + The panel owns its dropdown, unlike #user-hidden which pins itself to the + page edge — hence the positioned parent here and nowhere else. Right-aligned + so it opens leftward, the direction that cannot push it off the viewport and + reintroduce horizontal scroll. + --------------------------------------------------------------------------- */ + +.hnes-settings-host { position: relative; } + +/* Two classes deep on purpose: the pill rule above reaches this anchor through + `#header td:nth-child(3) a`, which out-specifies anything shorter. The pill + pads for a 13.5px cap height, and a 15px square box wants less. */ +html body #header .hnes-settings-host > a.hnes-gear { + padding: 6px 10px; + line-height: 0; +} +.hnes-settings-host svg { vertical-align: middle; } + +.hnes-settings { + right: 0; + width: 268px; + padding: var(--hnes-gap-2); + /* Stated rather than inherited: the panel hangs off the header's third cell, + which is right-aligned, and HN's own nav spans zero their font-size to + collapse separator pipes. Both would reach in here otherwise. */ + text-align: left; + font-size: var(--hnes-size-sm); + /* A short viewport must scroll the panel rather than the page: it hangs off + the header, so anything taller than the viewport is unreachable. */ + max-height: min(70vh, 520px); + overflow-y: auto; +} + +.hnes-settings-group + .hnes-settings-group { + margin-top: var(--hnes-gap-2); + padding-top: var(--hnes-gap-2); + border-top: 1px solid var(--hnes-border); +} + +.hnes-settings-label { + font-size: var(--hnes-size-xs); + font-weight: 700; + letter-spacing: .07em; + text-transform: uppercase; + color: var(--hnes-fg-muted); + padding: var(--hnes-gap-1) var(--hnes-gap-2); +} + +/* Out-specifies `html body .nav-drop-down a`, which lays the older menus' + rows out as plain blocks and pins their colour with !important. */ +html body .hnes-settings a.hnes-settings-opt { + display: flex; + align-items: center; + gap: var(--hnes-gap-2); + padding: 5px var(--hnes-gap-2); + white-space: normal; +} +/* Only the fill: the base rule's hover recolours to the accent, which on a row + whose job is to be read as a label is one signal too many. */ +html body .hnes-settings a.hnes-settings-opt:hover { + background: var(--hnes-surface-alt); +} + +.hnes-settings-text { display: flex; flex-direction: column; min-width: 0; } +.hnes-settings-name { line-height: 1.35; } +.hnes-settings-hint { + font-size: var(--hnes-size-xs); + line-height: 1.35; + color: var(--hnes-fg-muted); +} /* - * The palette control owns its dropdown, unlike #user-hidden which pins itself - * to the page edge — hence the positioned parent here and nowhere else. + * The check is reserved in both states so picking an option cannot reflow the + * row it is in. Weight plus the accent for the label, not a fill — a filled row + * sitting next to the hover fill reads as two hovers. + */ +html body .hnes-settings a.hnes-settings-opt::after { + content: ''; + display: inline-block; + flex: none; + margin-left: auto; + width: 12px; + line-height: 1; + text-align: center; + color: var(--hnes-orange); +} +html body .hnes-settings a.hnes-settings-on::after { content: '\2713'; } +html body .hnes-settings a.hnes-settings-on .hnes-settings-name { + font-weight: 700; + color: var(--hnes-orange); +} + +/* + * The palette rows *are* the swatches: each carries data-hnes-palette, so it + * paints itself in that palette's own ground, ink, rule and accent, straight + * out of that palette's block above. The row cannot drift from what picking it + * does, because one rule drives both. * - * Right-aligned so the panel opens leftward, which is the direction that cannot - * push it off the viewport and reintroduce horizontal scroll: a nav control is - * never near the left edge, but this one is appended last and can sit close to - * the right one. + * Butted together with no gap and no radius between them, which is the whole + * point. These five are all warm papers in light mode and their grounds sit + * within a few percent of each other; separated by white space and text that + * difference is invisible, and a small chip of it is invisible twice over. At a + * shared edge it is not — simultaneous contrast does the work no chip size can. + * That is also why the row shows ground and ink rather than a miniature of the + * page: amplifying the difference would make the swatch lie about the palette. */ -.hnes-nav-menu { position: relative; } -.hnes-nav-menu > .nav-drop-down { right: 0; } +.hnes-settings-swatches { + border: 1px solid var(--hnes-border); + border-radius: var(--hnes-radius); + overflow: hidden; +} +html body .hnes-settings-swatches a.hnes-settings-opt { + background: var(--hnes-bg); + color: var(--hnes-fg) !important; + border-radius: 0; + padding: 7px var(--hnes-gap-2) 7px 0; +} +/* The palette's own rule colour, so the seam between two rows is drawn by the + palette above it — one more token on show. */ +.hnes-settings-swatches a.hnes-settings-opt + a.hnes-settings-opt { + border-top: 1px solid var(--hnes-border); +} +/* Hover stays inside the palette: its card colour, not the panel's. */ +html body .hnes-settings-swatches a.hnes-settings-opt:hover { + background: var(--hnes-surface); +} +/* The accent, full height, so it reads as the header stripe it stands for. */ +.hnes-swatch-bar { + flex: none; + align-self: stretch; + width: 6px; + background: var(--hnes-orange); +} -/* The .nav-active-link rule above only matches direct children of .nav-links, - so the selected row inside the menu needs its own mark. Weight rather than a - fill: a filled row next to the hover fill reads as two hovers. */ -html body .hnes-nav-menu .nav-drop-down a.nav-active-link { - color: var(--hnes-orange) !important; - font-weight: 700; +/* + * A switch, for the settings that are on or off rather than one of several. The + * whole row is the hit target and the switch is only the readout — a 28px + * control would be the one thing in this panel you have to aim at. That is also + * why it suppresses the check: two affirmatives on one row is one too many, and + * the reserved 12px would push the switch off the right edge. + */ +.hnes-settings-switch { + flex: none; + margin-left: auto; + position: relative; + width: 28px; + height: 16px; + border-radius: var(--hnes-radius-pill); + background: var(--hnes-border); + transition: background .12s ease; +} +.hnes-settings-switch::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--hnes-bg); + transition: transform .12s ease; +} +html body .hnes-settings a.hnes-settings-switchrow::after, +html body .hnes-settings a.hnes-settings-action::after { content: none; } +html body .hnes-settings a.hnes-settings-on .hnes-settings-switch { + background: var(--hnes-orange); +} +html body .hnes-settings a.hnes-settings-on .hnes-settings-switch::after { + transform: translateX(12px); +} +/* The switch says which way it is set, so the label must not say it again. */ +html body .hnes-settings a.hnes-settings-switchrow.hnes-settings-on .hnes-settings-name { + font-weight: 400; + color: inherit; +} + +/* + * The bindings, listed. Two grid columns rather than a row element each, so the + * keys line up on their right edge whatever their width. + */ +.hnes-keys { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 5px var(--hnes-gap-2); + padding: var(--hnes-gap-1) var(--hnes-gap-2) var(--hnes-gap-1); + font-size: var(--hnes-size-xs); + color: var(--hnes-fg-muted); +} +.hnes-keys kbd { + font: inherit; + min-width: 1em; + padding: 1px 5px; + text-align: center; + color: var(--hnes-fg); + background: var(--hnes-bg); + border: 1px solid var(--hnes-border); + /* The thicker bottom edge is the whole keycap illusion at this size. */ + border-bottom-width: 2px; + border-radius: 4px; +} + +/* The one row in the panel that does something rather than sets something, so + it is the one that has to look pressable: every other row is told apart by + its mark, and this one has no state to mark. */ +html body .hnes-settings a.hnes-settings-action { + margin-top: var(--hnes-gap-1); + border: 1px solid var(--hnes-border); + border-radius: var(--hnes-radius); +} + +/* A line of explanation belonging to a group rather than to any one row: what + being in the set means, and how much is stored. */ +.hnes-settings-note { + padding: 0 var(--hnes-gap-2) var(--hnes-gap-1); + font-size: var(--hnes-size-xs); + line-height: 1.35; + color: var(--hnes-fg-muted); } .mourning { border-top: 5px solid var(--hnes-fg-strong); } diff --git a/test/README.md b/test/README.md index ebfa014..46a2a97 100644 --- a/test/README.md +++ b/test/README.md @@ -9,7 +9,7 @@ cd test && npm install # playwright only npm run migration # the one that cannot be redone npm run tokens # colour tokens, contrast, fade ladder npm run degenerate # broken markup must not brick the page -npm run controls # nav controls, persistence, orthogonality +npm run controls # settings panel, persistence, cross-tab, orthogonality npm run pages # every page type, logged out ``` @@ -77,12 +77,38 @@ This is a real regression test, not a smoke test: removing the guard in `doLogin` makes exactly the two `/login` cases fail with the original `TypeError`, and restoring it makes all eight pass. -## controls.mjs — the nav controls end to end - -Builds the controls, opens the palette menu, picks one, and checks the attribute -is written, the label updates, the menu closes and the choice persists across a -reload with the attribute set *before* the reveal. Also checks palette and -density do not disturb each other. +## controls.mjs — the settings panel end to end + +Opens the gear, picks options out of the panel, and checks the attribute is +written, the mark moves, the panel closes on click-away and Escape, and the +choice persists across a reload with the attribute set *before* the reveal. Also +checks palette and view do not disturb each other. 33 checks, exits non-zero on +any failure. + +Several exist because they are the ways this can break silently: + +- **the panel is lazy** — nothing is in the DOM until the gear is clicked, which + is what keeps the settings off the render critical path +- **five swatches, five distinct grounds** — a swatch that inherited the page's + palette instead of carrying its own would still render, just identically five + times over +- **a second tab follows without reloading**, and **an open panel follows + another tab** — the `storage.onChanged` path in `modes.js` and the + `subscribe` hook on top of it have no other coverage +- **a chosen section is a header tab after a reload** — the settings that + change behaviour rather than paint write no attribute to look at, so the + assertion has to be what the next load builds +- **typing is not navigation** — the keyboard guard, which for years was one + flag that only the search box set + +Note the click-away target is hunted with `elementFromPoint` rather than hard +coded. There is no inert pixel down the left of an HNES front page — the comment +count and score are gutter columns and both are links — and clicking one closes +the panel by navigating, which passes a naive check for the wrong reason. + +A check can also report `skip`: Hacker News rate-limits a driven browser +readily, and a 429 is neither a pass nor a failure but a page this run never got +to look at. Console errors are filtered to script errors for the same reason. ## pages.mjs — every page type, logged out diff --git a/test/controls.mjs b/test/controls.mjs index e3260d3..c6f2d9b 100644 --- a/test/controls.mjs +++ b/test/controls.mjs @@ -1,18 +1,18 @@ /* - * Load the unpacked extension into a real Chrome and drive the palette control + * Load the unpacked extension into a real Chrome and drive the settings panel * on a live Hacker News page. Checks the parts the token harness cannot: that - * the nav control is built, that clicking an option writes the attribute and - * persists it, and that the choice survives a reload without a flash of classic. + * the gear is built, that the panel draws every mode with its selection marked, + * that picking an option writes the attribute and persists it, that the choice + * survives a reload without a flash of classic, and that a second tab picks the + * change up without being reloaded. */ import { chromium } from 'playwright'; import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; -import { join } from 'path'; import { fileURLToPath } from 'url'; -import { dirname, join as pjoin } from 'path'; +import { dirname, join } from 'path'; const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); -const SHOTS = pjoin(ROOT, 'test', 'screenshots'); - +const SHOTS = join(ROOT, 'test', 'screenshots'); const EXT = ROOT; const userDataDir = mkdtempSync(join(tmpdir(), 'hnes-')); @@ -22,78 +22,326 @@ const ctx = await chromium.launchPersistentContext(userDataDir, { args: [`--disable-extensions-except=${EXT}`, `--load-extension=${EXT}`], }); +const results = []; +const check = (name, ok, note = '') => results.push({ name, ok: !!ok, note }); +// Hacker News rate-limits a driven browser readily. A 429 is not a pass and not +// a failure — it is a page this run never got to look at, and saying so is the +// same thing test/pages.mjs does with the sweep. +const skip = (name, note) => results.push({ name, ok: true, skipped: true, note }); + const page = await ctx.newPage(); const errors = []; page.on('pageerror', e => errors.push(String(e))); -page.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); +// Script errors only: a failed request is HN's answer to being driven, and the +// checks below already treat a page that did not load as untested. +page.on('console', m => { + if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) { + errors.push('console: ' + m.text()); + } +}); await page.goto('https://news.ycombinator.com/', { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(2500); const shot = p => page.screenshot({ path: `${SHOTS}/${p}`, fullPage: false }); +// Storage is the last group, and the Sections group has a note of its own — +// so this has to name the group rather than take the first note in the panel. +const STORAGE_NOTE = '.hnes-settings > .hnes-settings-group:last-child .hnes-settings-note'; +// Null-safe: a check that navigated away has no panel, and should report that +// rather than throw and take the rest of the run with it. +const panelDisplay = () => page.evaluate(() => { + const panel = document.querySelector('.hnes-settings'); + return panel ? getComputedStyle(panel).display : 'missing'; +}); -// 1. did the rewrite finish, and is the page actually visible? -const state = await page.evaluate(() => ({ +// 1. did the rewrite finish, is the page visible, and is the gear the only +// thing the settings now cost the nav? +const loaded = await page.evaluate(() => ({ pending: document.documentElement.classList.contains('hnes-pending'), visible: getComputedStyle(document.body).visibility, - toggles: [...document.querySelectorAll('.hnes-nav-toggle > a')].map(a => a.textContent), - menuOptions: [...document.querySelectorAll('.hnes-nav-menu .nav-drop-down a')].map(a => a.textContent), + gears: document.querySelectorAll('.hnes-settings-host > a').length, + gearIcon: !!document.querySelector('.hnes-settings-host > a svg'), + // The panel is built on first open, so nothing should exist yet. + panels: document.querySelectorAll('.hnes-settings').length, rows: document.querySelectorAll('tr.athing').length, })); -console.log('after load:', JSON.stringify(state, null, 2)); +check('page revealed', !loaded.pending && loaded.visible === 'visible'); +check('rows rewritten', loaded.rows > 10, `${loaded.rows} rows`); +check('one gear in the nav', loaded.gears === 1 && loaded.gearIcon); +check('panel is lazy', loaded.panels === 0); await shot('01-classic.png'); -// 2. open the palette menu and pick ember -await page.click('.hnes-nav-menu > a'); +// 2. open it: every mode drawn, exactly one option marked per mode, swatches +// rendered from the palettes rather than from the page's current one +await page.click('.hnes-settings-host > a'); await page.waitForTimeout(200); -const menuVisible = await page.isVisible('.hnes-nav-menu .nav-drop-down'); -console.log('menu opens:', menuVisible); -await shot('02-menu-open.png'); +const opened = await page.evaluate(storageNote => { + const panel = document.querySelector('.hnes-settings'); + const groups = [...panel.querySelectorAll('.hnes-settings-group')].map(g => ({ + label: g.querySelector('.hnes-settings-label').textContent, + options: [...g.querySelectorAll('.hnes-settings-opt')].map(a => a.dataset.hnesOpt), + marked: [...g.querySelectorAll('.hnes-settings-on')].map(a => a.dataset.hnesOpt), + })); + // The palette rows are the swatches: each paints itself in its own ground. + const swatchBg = [...panel.querySelectorAll('.hnes-settings-swatches .hnes-settings-opt')] + .map(s => getComputedStyle(s).backgroundColor); + return { + visible: getComputedStyle(panel).display, + groups, + swatchBg, + keys: [...panel.querySelectorAll('.hnes-keys kbd')].map(k => k.textContent).join(''), + note: document.querySelector(storageNote)?.textContent ?? '', + }; +}, STORAGE_NOTE); +check('panel opens', opened.visible !== 'none'); +// Storage is a group without a stored setting behind it, and Reading holds two +// switches under one heading — both are shapes the panel only grew once it held +// more than three lists. +check('every group drawn', + opened.groups.map(g => g.label).join(' ') === + 'Theme View Palette Reading Keyboard Sections Storage', + opened.groups.map(g => `${g.label}(${g.options.length})`).join(' ')); +// A switch that is off has no row to mark, so a count is the assertion: two +// switches on under Reading, four sections chosen, nothing under Storage. +check('right number marked in each group', + opened.groups.map(g => g.marked.length).join(',') === '1,1,1,2,1,4,0', + opened.groups.map(g => `${g.label}:${g.marked.length}`).join(' ')); +check('defaults marked', + opened.groups.flatMap(g => g.marked).join(' ') === + 'hnesTheme:auto hnesDensity:comfortable hnesPalette:classic ' + + 'hnesNewComments:on hnesHckrnews:on hnesKeys:on ' + + 'hnesNav:top hnesNav:new hnesNav:best hnesNav:submit', + opened.groups.flatMap(g => g.marked).join(' ')); +// The bindings were bound in hn.js and written down nowhere. +check('every binding listed', opened.keys === 'jkolpcbh', opened.keys); +check('storage reports a size', /^\d+(\.\d+)? (B|KB|MB) stored$/.test(opened.note), opened.note); +// Five palettes, five distinct grounds — a swatch inheriting the page's palette +// instead of carrying its own would collapse these to one value. +check('swatches show their own palette', + new Set(opened.swatchBg).size === 5, opened.swatchBg.join(' ')); +await shot('02-panel-open.png'); -await page.click('.hnes-nav-menu .nav-drop-down a:has-text("ember")'); +// 3. pick a palette: attribute written, mark moved, panel stays open +const before = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); +await page.click('[data-hnes-opt="hnesPalette:ember"]'); await page.waitForTimeout(300); -const afterPick = await page.evaluate(() => ({ +const picked = await page.evaluate(() => ({ attr: document.documentElement.getAttribute('data-hnes-palette'), - label: document.querySelector('.hnes-nav-menu > a').textContent, - menuOpen: getComputedStyle(document.querySelector('.hnes-nav-menu .nav-drop-down')).display, + marked: [...document.querySelectorAll('[data-hnes-opt^="hnesPalette:"].hnes-settings-on')] + .map(a => a.dataset.hnesOpt), bg: getComputedStyle(document.body).backgroundColor, })); -console.log('after picking ember:', JSON.stringify(afterPick)); +check('picking writes the attribute', picked.attr === 'ember', picked.attr); +check('mark follows the pick', picked.marked.join() === 'hnesPalette:ember', picked.marked.join()); +check('panel stays open to pick again', await panelDisplay() !== 'none'); +// The click-away handler is on the document, so the panel's own furniture has +// to stop the click — otherwise hitting a group heading closes it. +await page.locator('.hnes-settings .hnes-settings-label').first().click(); +await page.waitForTimeout(150); +check('clicking panel furniture keeps it open', await panelDisplay() !== 'none'); +check('page repaints', picked.bg !== before, `${before} -> ${picked.bg}`); await shot('03-ember.png'); -// 3. does it survive a reload, and does boot.js apply it before the reveal? +// 4. click away closes it. The target has to be hunted rather than guessed: +// HNES moves the comment count and score into left gutter columns, so even +// the page margin is a link, and following one would close the panel by +// navigating rather than by the handler under test. +const before4 = page.url(); +const spot = await page.evaluate(() => { + const w = document.documentElement.clientWidth, h = window.innerHeight; + for (let y = 120; y < h - 10; y += 12) { + for (let x = 4; x < w - 4; x += 25) { + const el = document.elementFromPoint(x, y); + if (el && !el.closest('a') && !el.closest('.hnes-settings')) return { x, y, on: el.tagName }; + } + } + return null; +}); +check('found somewhere inert to click', !!spot, spot ? `${spot.x},${spot.y} on ${spot.on}` : 'none'); +await page.mouse.click(spot?.x ?? 4, spot?.y ?? 140); +await page.waitForTimeout(150); +check('click-away closes the panel', + page.url() === before4 && await panelDisplay() === 'none', + page.url() === before4 ? '' : 'navigated instead: ' + page.url()); + +// 5. a second tab hears the change without being reloaded — this is the +// storage.onChanged path in boot.js, and it has no other coverage +const second = await ctx.newPage(); +await second.goto('https://news.ycombinator.com/newest', { waitUntil: 'domcontentloaded' }); +await second.waitForTimeout(2500); +await page.click('.hnes-settings-host > a'); +await page.click('[data-hnes-opt="hnesPalette:slate"]'); +await page.waitForTimeout(400); +const otherAttr = await second.evaluate(() => + document.documentElement.getAttribute('data-hnes-palette')); +check('a second tab follows without reloading', otherAttr === 'slate', otherAttr); + +// And the other direction, with the panel *open* the whole time: the second tab +// writes, and this one's marks have to move without being reopened. This is the +// HNESModes.subscribe path, and nothing else exercises it. +await second.click('.hnes-settings-host > a'); +await second.click('[data-hnes-opt="hnesPalette:newsprint"]'); +await page.waitForTimeout(500); +check('an open panel follows another tab', await page.evaluate(() => + !!document.querySelector('[data-hnes-opt="hnesPalette:newsprint"].hnes-settings-on') && + !document.querySelector('[data-hnes-opt="hnesPalette:slate"].hnes-settings-on'))); +await second.close(); +// Panel is still open from the pick above, so this is a second pick in one +// visit rather than a fresh open — the case the mark refresh has to survive. +await page.click('[data-hnes-opt="hnesPalette:ember"]'); +await page.waitForTimeout(300); +await page.keyboard.press('Escape'); +await page.waitForTimeout(150); +check('Escape closes the panel', await panelDisplay() === 'none'); + +// 6. does it survive a reload, and does boot.js apply it before the reveal? +// +// Watched rather than sampled. The point of applying in boot.js is that the +// attribute is set while the page is still hidden, so there is no frame of +// classic to see — and polling for `.hnes-pending` after the fact only catches +// that if the poll wins a race against the rewrite, which on a warm cache it +// does not. The observer records the palette at the instant the page is +// revealed, which is the property itself rather than a proxy for it. +await page.addInitScript(() => { + window.__hnesAtReveal = null; + // An init script runs before exists, so the watcher may have to wait + // for it. Both halves are needed: which one fires depends on how early Chrome + // gets round to this relative to parsing. + let sawPending = false; + const watch = () => { + const root = document.documentElement; + if (!root) return false; + const obs = new MutationObserver(() => { + if (root.classList.contains('hnes-pending')) { sawPending = true; return; } + // Only after it was hidden — otherwise a class change on before + // boot.js has run would be recorded as a reveal. + if (!sawPending) return; + window.__hnesAtReveal = root.getAttribute('data-hnes-palette') || 'unset'; + obs.disconnect(); + }); + obs.observe(root, { attributes: true, attributeFilter: ['class'] }); + return true; + }; + if (!watch()) { + const pending = new MutationObserver(() => { if (watch()) pending.disconnect(); }); + pending.observe(document, { childList: true }); + } +}); await page.reload({ waitUntil: 'domcontentloaded' }); -const early = await page.evaluate(() => ({ - attr: document.documentElement.getAttribute('data-hnes-palette'), - pending: document.documentElement.classList.contains('hnes-pending'), -})); -await page.waitForTimeout(2000); +await page.waitForTimeout(2500); const late = await page.evaluate(() => ({ attr: document.documentElement.getAttribute('data-hnes-palette'), - label: document.querySelector('.hnes-nav-menu > a')?.textContent, - bg: getComputedStyle(document.body).backgroundColor, + atReveal: window.__hnesAtReveal, })); -console.log('right after reload:', JSON.stringify(early)); -console.log('settled after reload:', JSON.stringify(late)); +check('choice persists', late.attr === 'ember', late.attr); +check('applied before the reveal', late.atReveal === 'ember', + `palette at reveal: ${late.atReveal ?? 'never revealed'}`); await shot('04-ember-reload.png'); -// 4. palette x density are orthogonal: flow must not disturb the palette -await page.click('.hnes-nav-toggle:has-text("view") > a'); -await page.click('.hnes-nav-toggle:has-text("view") > a'); +// 7. palette x view are orthogonal: flow must not disturb the palette +await page.click('.hnes-settings-host > a'); +await page.click('[data-hnes-opt="hnesDensity:flow"]'); await page.waitForTimeout(300); -console.log('palette x view:', JSON.stringify(await page.evaluate(() => ({ +const both = await page.evaluate(() => ({ palette: document.documentElement.getAttribute('data-hnes-palette'), density: document.documentElement.getAttribute('data-hnes-density'), - bg: getComputedStyle(document.body).backgroundColor, -})))); + marked: [...document.querySelectorAll('.hnes-settings-on')].map(a => a.dataset.hnesOpt), +})); +check('palette x view orthogonal', both.palette === 'ember' && both.density === 'flow', + `${both.palette} / ${both.density}`); +check('both marks held at once', + both.marked.slice(0, 3).join(' ') === 'hnesTheme:auto hnesDensity:flow hnesPalette:ember', + both.marked.join(' ')); await shot('05-ember-flow.png'); -// 5. a comment page, where the fade ladder and the spine live -await page.goto('https://news.ycombinator.com/item?id=' + (await page.evaluate(() => - document.querySelector('tr.athing')?.id) || '1'), { waitUntil: 'domcontentloaded' }).catch(() => {}); +// 8. the settings that change behaviour rather than paint. These are the ones +// with no attribute on : hn.js reads them and decides what to build or +// bind, so the assertion is what the next load does, not what the row shows. +// A switch has one row, for values[0]; off is drawn as that row unmarked. +await page.click('[data-hnes-opt="hnesKeys:on"]'); +await page.waitForTimeout(200); +check('a switch flips', await page.evaluate(() => + !document.querySelector('[data-hnes-opt="hnesKeys:on"].hnes-settings-on'))); + +// With shortcuts off, h must not reach the panel either — it is one of them. +await page.keyboard.press('Escape'); +await page.waitForTimeout(150); +await page.keyboard.press('h'); +await page.waitForTimeout(200); +check('h is off with the shortcuts', await panelDisplay() === 'none'); + +await page.click('.hnes-settings-host > a'); +await page.click('[data-hnes-opt="hnesKeys:on"]'); // back on +await page.keyboard.press('Escape'); +await page.waitForTimeout(150); +await page.keyboard.press('h'); +await page.waitForTimeout(200); +check('h opens the panel', await panelDisplay() !== 'none'); + +// The typing guard: HN's own search box is on the same document, and this used +// to be one flag that only the search box set — every comment box was unguarded. +await page.keyboard.press('Escape'); +await page.waitForTimeout(150); +await page.locator('input[name="q"]').first().focus(); +await page.keyboard.press('h'); +await page.waitForTimeout(200); +check('typing is not navigation', await panelDisplay() === 'none'); + +// A section moved out of "more" is a header tab on the next load — this is the +// one setting that rebuilds markup rather than restyling it. +await page.click('.hnes-settings-host > a'); +await page.click('[data-hnes-opt="hnesNav:ask"]'); +await page.waitForTimeout(300); +await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForTimeout(2500); -await shot('06-ember-comments.png'); -console.log('comment page comments:', await page.evaluate(() => document.querySelectorAll('.comtr, tr.athing.comtr').length)); +const nav = await page.evaluate(() => ({ + tabs: [...document.querySelectorAll('.nav-links > span > a')].map(a => a.textContent), + more: [...document.querySelectorAll('#nav-others a')].map(a => a.textContent), +})); +check('a chosen section is a header tab', nav.tabs.includes('ask'), nav.tabs.join(' ')); +check('and has left the more menu', !nav.more.includes('ask'), nav.more.join(' ')); +await shot('07-sections.png'); + +// The one store with no expiry, and the only place that can say how big it is. +await page.click('.hnes-settings-host > a'); +await page.waitForTimeout(200); +await page.click('.hnes-settings-action'); +await page.waitForTimeout(600); +const cleared = await page.evaluate(sel => document.querySelector(sel).textContent, STORAGE_NOTE); +check('clearing reports what it freed', + /^\d+ cleared — \d+(\.\d+)? (B|KB|MB) left$/.test(cleared), cleared); +await page.keyboard.press('Escape'); + +// 9. a comment page, where the fade ladder and the spine live +await page.keyboard.press('Escape'); +const id = await page.evaluate(() => document.querySelector('tr.athing')?.id); +const item = await page.goto('https://news.ycombinator.com/item?id=' + (id || '1'), + { waitUntil: 'domcontentloaded' }).catch(() => null); +await page.waitForTimeout(2500); +if (!item || item.status() !== 200) { + skip('gear on a comment page too', `HTTP ${item ? item.status() : 'no response'}`); +} else { + const comments = await page.evaluate(() => ({ + count: document.querySelectorAll('tr.athing.comtr').length, + gears: document.querySelectorAll('.hnes-settings-host > a').length, + })); + check('gear on a comment page too', comments.gears === 1, `${comments.count} comments`); + await shot('06-ember-comments.png'); +} -console.log('\npage errors:', errors.length ? errors : 'none'); await ctx.close(); + +const pad = (s, n) => String(s).padEnd(n); +console.log(pad('check', 42) + pad('result', 8) + 'note'); +console.log('-'.repeat(84)); +let failed = 0, skipped = 0; +for (const r of results) { + if (!r.ok) failed++; + if (r.skipped) skipped++; + console.log(pad(r.name, 42) + pad(r.skipped ? 'skip' : r.ok ? 'ok' : 'FAIL', 8) + (r.note || '-')); +} +console.log('\npage errors:', errors.length ? errors : 'none'); +console.log(failed || errors.length + ? `\n${failed} of ${results.length} checks failed` + : `\nall ${results.length - skipped} checks pass` + (skipped ? `, ${skipped} skipped` : '')); +process.exit(failed || errors.length ? 1 : 0); diff --git a/test/migration.mjs b/test/migration.mjs index 3facf5d..92253b2 100644 --- a/test/migration.mjs +++ b/test/migration.mjs @@ -22,7 +22,9 @@ const SHOTS = pjoin(ROOT, 'test', 'screenshots'); const SRC = ROOT; -const WORK = pjoin(ROOT, 'test', '.migtest'); +// Outside the repo, not test/.migtest: the copy has to bump the manifest version +// between two launches, and cpSync refuses a destination inside its own source. +const WORK = pjoin(mkdtempSync(join(tmpdir(), 'hnes-work-')), 'HNES'); const PROFILE = mkdtempSync(join(tmpdir(), 'hnes-mig-')); rmSync(WORK, { recursive: true, force: true }); diff --git a/test/pages.mjs b/test/pages.mjs index b03bae0..f020c31 100644 --- a/test/pages.mjs +++ b/test/pages.mjs @@ -63,8 +63,8 @@ for (const [name, url] of PAGES) { pending: document.documentElement.classList.contains('hnes-pending'), visibility: getComputedStyle(document.body).visibility, // Did HNES actually restyle, or is this raw HN? #hnmain is HN's; the - // controls only exist if initModeControls got that far. - controls: document.querySelectorAll('.hnes-nav-toggle').length, + // settings gear only exists if initSettings got that far. + controls: document.querySelectorAll('.hnes-settings-host').length, bodyFont: getComputedStyle(document.body).fontFamily.slice(0, 22), rows: document.querySelectorAll('tr.athing').length, overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth, From 77ae35393e8b7cb72658318774d398934934e458 Mon Sep 17 00:00:00 2001 From: John Bolliger Date: Thu, 13 Aug 2026 23:04:07 -0700 Subject: [PATCH 11/20] Type-check the JavaScript, and fix what it found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsc with allowJs/checkJs/noEmit over background.js, offscreen.js and the three content scripts. No build step and no TypeScript: the manifest points at the same files it always has, nothing is emitted, and it lives under test/ which zip.sh already excludes from both packages. strictNullChecks is the reason to have it. This code walks HN's markup positionally against a server that serves rate-limited and malformed bodies, which is what degenerate.mjs exists to prove survivable. noImplicitAny and noImplicitThis are off on purpose — two thousand lines of jQuery callbacks with genuinely untyped parameters would bury every real finding. globals.d.ts declares HNESModes, which inference cannot see because modes.js assigns it onto globalThis from inside an IIFE. `declare var` puts it on globalThis, so modes.js's own literal is checked against the declaration and the two cannot drift. 113 errors on the first run, and it still exits non-zero — a report, not a gate. The one worth fixing now: rewriteUserNav guarded with pathname != '/upvoted' || pathname != '/favorites' which is true for every possible path, so the two pages it names were the two it let through — and they are exactly the two with no ?id= for the next line to deref. /threads and /upvoted without a query threw a TypeError that aborted the rewrite: no gear, no user menu, and the page revealed only by the stylesheet's two-second failsafe. Both are reachable from HN's own user menu. Nothing caught it because nothing logs in, which is the same gap that hid the horizontal user menu. So: test/session.mjs, 14 checks over the five user pages and the menu, no network. It asserts on .hnes-pending, which is still set if reveal() never ran — the tell for a throw partway through a page that still looks fine because the failsafe showed it anyway. Also found, not yet fixed: ~38 implicit globals, a .size() call removed from jQuery in 3.0 sitting in a branch no caller reaches, location.reload(true), a two-argument call to a one-parameter visit(), a doubly-declared threadList, and ~19 null-safety findings on the positional walks. typecheck 111 errors (documented), migration/tokens/degenerate/session pass. Co-Authored-By: Claude Opus 5 (1M context) --- js/hn.js | 17 +++++- test/README.md | 73 +++++++++++++++++++++++-- test/globals.d.ts | 63 ++++++++++++++++++++++ test/package.json | 7 ++- test/session.mjs | 129 +++++++++++++++++++++++++++++++++++++++++++++ test/tsconfig.json | 41 ++++++++++++++ 6 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 test/globals.d.ts create mode 100644 test/session.mjs create mode 100644 test/tsconfig.json diff --git a/js/hn.js b/js/hn.js index 46eca29..6edc21d 100644 --- a/js/hn.js +++ b/js/hn.js @@ -1964,8 +1964,21 @@ var HN = { hidden_div.append(link); } if (new_active) { - if (window.location.pathname != '/upvoted' || window.location.pathname != '/favorites') { - var user_id = window.location.search.match(/id=(\w+)/)[1]; + /* + * `||` here made the guard always true — no path is both /upvoted and + * /favorites — so the two pages it names were the two it let through, + * and they are exactly the two with no ?id= to match. The deref below + * threw for every logged-in user on either of them. Found by the type + * checker; nothing tests a logged-in session. + * + * The match is checked as well as the path, because HN drops ?id= on + * more pages than these two when you are looking at your own. + */ + var id_match = window.location.pathname != '/upvoted' && + window.location.pathname != '/favorites' && + window.location.search.match(/id=(\w+)/); + if (id_match) { + var user_id = id_match[1]; if (user_id == user_name) user_id = 'Your'; else diff --git a/test/README.md b/test/README.md index 46a2a97..245cdfe 100644 --- a/test/README.md +++ b/test/README.md @@ -2,23 +2,68 @@ There is no unit-test suite — almost everything HNES does is rewriting a page it does not control, so the useful tests drive a real Chrome with the extension -loaded. These four cover what manual checking kept missing. +loaded. These six cover what manual checking kept missing. ```sh -cd test && npm install # playwright only +cd test && npm install # playwright, typescript, two @types packages +npm run typecheck # no build step; reads the shipped source in place npm run migration # the one that cannot be redone npm run tokens # colour tokens, contrast, fade ladder npm run degenerate # broken markup must not brick the page +npm run session # the logged-in pages, which nothing else sees npm run controls # settings panel, persistence, cross-tab, orthogonality npm run pages # every page type, logged out ``` -`migration`, `tokens` and `degenerate` need no network and are deterministic. -`controls` and `pages` hit live Hacker News and can be rate limited — see the -warning under `pages.mjs`. +`typecheck`, `migration`, `tokens`, `degenerate` and `session` need no network +and are deterministic. `controls` and `pages` hit live Hacker News and can be +rate limited — see the warning under `pages.mjs`. Screenshots land in `test/screenshots/`. +## typecheck — tsc over the JavaScript, no TypeScript + +`tsconfig.json` sets `allowJs`, `checkJs` and `noEmit`, so the checker reads +`background.js`, `offscreen.js` and the three content scripts exactly as the +manifest loads them. Nothing is compiled and nothing is emitted; the repo is +still the extension. It lives here because `zip.sh` already excludes `test/` +from both packages. + +`strictNullChecks` is the reason to have it. This code walks HN's markup +positionally — `td:nth-child(3)`, `.subtext a:eq(1)`, `$this.parent().prev()` — +against a server that serves rate-limited and malformed bodies, which is what +`degenerate.mjs` exists to prove survivable. `noImplicitAny` and +`noImplicitThis` are off on purpose: two thousand lines of jQuery callbacks with +genuinely untyped parameters would bury every real finding under one error per +callback. + +`globals.d.ts` declares `HNESModes`, the one thing inference cannot see — +`modes.js` assigns it onto `globalThis` from inside an IIFE. `HN` and +`CommentTracker` are plain top-level `var`s and need no help. Because +`declare var` adds the property to globalThis, `modes.js`'s own assignment is +checked against that declaration, so the two cannot drift silently. + +**It is a report, not a gate.** The first run gave 113 errors and it still exits +non-zero; wiring it into a release check means working that number down first. +What it found: + +- **`hn.js` threw for every logged-in user on `/upvoted` and `/favorites`.** A + guard written `!= '/upvoted' || != '/favorites'` is true for every possible + path, so the two pages it names were the two it let through — and they are + exactly the two with no `?id=` for the next line to match against. Fixed. +- **~38 implicit globals** — `link`, `domain`, `text`, `image`, `fnid`, + `whence`, `hmac`, `below_header`, `help`, `morelink`, `userscoreEl`, `i`, + `comments_link`, `user_drop_toggle`, `toggle_more_link`. Assignments with no + `var`, leaking into the isolated world and shared across every call. +- **`.size()` at `hn.js:1650`**, removed from jQuery in 3.0 and absent from the + vendored 3.2.1. It never fires: the only caller passes `true`, so the branch + holding it is dead. +- Smaller ones — `location.reload(true)` (the argument was dropped from the + spec), `visit(n.children[i], acc)` against a one-parameter `visit`, and + `var threadList` declared twice in `HNComments.apply`. +- **~19 null-safety findings** on the positional walks. That list is the point + of the exercise: it is the only inventory of where HN's markup is assumed. + ## migration.mjs — run this before any release that changes storage The MV2 → MV3 storage migration gets exactly one attempt per user: if it fails, @@ -77,6 +122,24 @@ This is a real regression test, not a smoke test: removing the guard in `doLogin` makes exactly the two `/login` cases fail with the original `TypeError`, and restoring it makes all eight pass. +## session.mjs — the logged-in pages + +Every other harness browses logged out, and two bugs have now hidden in that +gap. The user menu rendered as a row of pills for as long as the header's pill +rule existed. `/threads` and `/upvoted` without a `?id=` threw a TypeError that +aborted the rewrite outright — no gear, no user menu, and the page revealed only +by the stylesheet's two-second failsafe. Both are what a signed-in user sees +every day; neither was visible to a harness that never signs in. + +No network: one logged-in body is served by route interception for every path, +which is enough, because what is under test is what HNES does with `pathname` +and the logout link. Logging in for real would need credentials and would +rate-limit immediately. + +`pending` is the assertion that matters. It is still set if `reveal()` never +ran, which is the tell for a throw partway through the rewrite — a page that +still *looks* fine, because the failsafe animation shows it anyway. + ## controls.mjs — the settings panel end to end Opens the gear, picks options out of the panel, and checks the attribute is diff --git a/test/globals.d.ts b/test/globals.d.ts new file mode 100644 index 0000000..3e52d7c --- /dev/null +++ b/test/globals.d.ts @@ -0,0 +1,63 @@ +/* + * The one global that needs declaring by hand. + * + * Content scripts of one extension share a single isolated world, which is how + * modes.js, boot.js and hn.js see each other with no module system. `HN` and + * `CommentTracker` are plain top-level `var`s, so the checker infers them from + * their own source. `HNESModes` is assigned onto `globalThis` from inside an + * IIFE, which is deliberate — it is the only thing modes.js exports — and that + * is invisible to inference. + * + * Writing it out is not duplication for its own sake: `declare var` adds the + * property to globalThis, so modes.js's own assignment is checked against this, + * and the two cannot drift without the checker saying so. + */ + +interface HNESModeValue { + id: string; + label?: string; + hint?: string; + /** `multi` only: sections carry the page they link to. */ + href?: string; +} + +interface HNESModeSpec { + key: string; + /** Present on a spec that paints: boot.js writes it onto before first + * paint. Absent on one that hn.js reads and acts on. */ + attr?: string; + /** The panel heading. Consecutive specs sharing one share a group. */ + label: string; + ui: 'list' | 'swatch' | 'toggle' | 'multi'; + /** values[0] is the unset state, and for a toggle the shipped default. */ + values: HNESModeValue[]; + /** `toggle` only: the row's own name, since the heading is the group's. */ + name?: string; + hint?: string; + /** `toggle` only: key bindings listed under the switch. */ + help?: { id: string; label: string }[]; + /** `multi` only: the default set, comma-joined. Empty is a real answer. */ + dflt?: string; +} + +type HNESStoredValues = Record; + +interface HNESModesApi { + list: HNESModeSpec[]; + sections: HNESModeValue[]; + keys(): string[]; + spec(key: string): HNESModeSpec | null; + indexOf(spec: HNESModeSpec, value: unknown): number; + current(spec: HNESModeSpec): string; + selected(spec: HNESModeSpec): string[]; + on(key: string): boolean; + apply(root: HTMLElement, spec: HNESModeSpec, value: unknown): void; + applyAll(root: HTMLElement, items: HNESStoredValues): void; + commit(spec: HNESModeSpec, value: string): void; + load(callback?: (values: HNESStoredValues) => void): void; + ready(callback: (values: HNESStoredValues) => void): void; + watch(root: HTMLElement): void; + subscribe(callback: (touched: HNESModeSpec[]) => void): void; +} + +declare var HNESModes: HNESModesApi; diff --git a/test/package.json b/test/package.json index c0325a9..e6ba532 100644 --- a/test/package.json +++ b/test/package.json @@ -4,13 +4,18 @@ "type": "module", "description": "Browser-driven checks for the HNES extension. Not part of the packaged extension.", "scripts": { + "typecheck": "tsc -p tsconfig.json", "migration": "node migration.mjs", "tokens": "node tokens.mjs", "degenerate": "node degenerate.mjs", + "session": "node session.mjs", "controls": "node controls.mjs", "pages": "node pages.mjs" }, "devDependencies": { - "playwright": "^1.62.0" + "@types/chrome": "^0.2.6", + "@types/jquery": "^4.0.1", + "playwright": "^1.62.0", + "typescript": "^7.0.2" } } diff --git a/test/session.mjs b/test/session.mjs new file mode 100644 index 0000000..3fee411 --- /dev/null +++ b/test/session.mjs @@ -0,0 +1,129 @@ +/* + * The logged-in pages, which nothing else covers. + * + * Every other harness browses Hacker News logged out, and two bugs have now + * hidden in that gap: the user menu rendered as a row of pills for as long as + * the header pill rule existed, and `/threads` threw a TypeError that aborted + * the whole rewrite. Neither is exotic — both are what a signed-in user sees + * every day — and neither was visible to a harness that never signs in. + * + * No network. The body below is HN's own markup for a logged-in front page, + * served by route interception for every path, which is enough because what is + * under test is what HNES does with `pathname` and the logout link. Logging in + * for real would need credentials and would rate-limit immediately. + */ +import { chromium } from 'playwright'; +import { mkdtempSync } from 'fs'; +import { tmpdir } from 'os'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); +const SHOTS = join(ROOT, 'test', 'screenshots'); + +// The third header cell is the part that matters: the `logout` link is what +// HN.init keys "is logged in" off, and #user-hidden hangs off the same cell. +const BODY = `
    + +
    + + + + +
    1.A story + (x.test)
    +40 points by bob +2 hours ago | 3 comments +
    `; + +const results = []; +const check = (name, ok, note = '') => results.push({ name, ok: !!ok, note }); + +const ctx = await chromium.launchPersistentContext(mkdtempSync(join(tmpdir(), 'hnes-session-')), { + channel: 'chromium', + args: [`--disable-extensions-except=${ROOT}`, `--load-extension=${ROOT}`], +}); +const page = await ctx.newPage(); +const errors = []; +page.on('pageerror', e => errors.push(String(e))); + +await page.route('**://news.ycombinator.com/**', r => + r.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: BODY })); + +/* + * The label on the active user-page link. `/upvoted` and `/favorites` are + * always your own, so they are named plainly; anywhere else says whose it is. + * A guard written with `||` instead of `&&` is true for every path, which both + * lost that distinction and let the two id-less pages reach a deref that threw. + */ +const PAGES = [ + { path: '/upvoted?id=alice', label: 'upvoted' }, + { path: '/upvoted', label: 'upvoted' }, + { path: '/favorites?id=alice', label: 'favorites' }, + { path: '/threads', label: 'comments' }, // no ?id=: HN drops it on your own + { path: '/submitted?id=alice', label: 'Your submitted' }, + { path: '/submitted?id=bob', label: "bob's submitted" }, +]; + +for (const { path, label } of PAGES) { + errors.length = 0; + // 'commit' rather than 'domcontentloaded': a fulfilled route plus a + // document_start script can settle before the wait is armed. + await page.goto('https://news.ycombinator.com' + path, { waitUntil: 'commit' }); + await page.waitForTimeout(2000); + + const state = await page.evaluate(() => ({ + pending: document.documentElement.classList.contains('hnes-pending'), + gear: document.querySelectorAll('.hnes-settings-host > a').length, + active: document.querySelector('.new-active-link')?.textContent ?? null, + })); + + // `pending` is the tell for a throw: reveal() never ran and only the + // stylesheet's failsafe animation is holding the page up. + check(`${path} completes`, + !state.pending && state.gear === 1 && !errors.length, + errors.length ? errors.join(' ') : state.pending ? 'never revealed' : ''); + check(`${path} names the page`, state.active === label, state.active); +} + +// The user menu, which the header's pill rule used to lay out horizontally. +// Vertical means every row is a block starting at the same left edge. +await page.goto('https://news.ycombinator.com/news', { waitUntil: 'commit' }); +await page.waitForTimeout(2000); +await page.click('#my-more-link > a'); // the username, which is the trigger +await page.waitForTimeout(200); +const menu = await page.evaluate(() => { + const rows = [...document.querySelectorAll('#user-hidden a')]; + return { + count: rows.length, + displays: [...new Set(rows.map(a => getComputedStyle(a).display))], + lefts: [...new Set(rows.map(a => Math.round(a.getBoundingClientRect().left)))], + tops: rows.map(a => Math.round(a.getBoundingClientRect().top)), + }; +}); +check('user menu is a list', menu.count > 1 && menu.displays.join() === 'block', + `${menu.count} rows, display ${menu.displays.join('/')}`); +check('user menu stacks vertically', + menu.lefts.length === 1 && menu.tops.every((t, i) => i === 0 || t > menu.tops[i - 1]), + `lefts ${menu.lefts.join(',')} tops ${menu.tops.join(',')}`); +await page.screenshot({ path: `${SHOTS}/08-user-menu.png` }); + +await ctx.close(); + +const pad = (s, n) => String(s).padEnd(n); +console.log(pad('check', 36) + pad('result', 8) + 'note'); +console.log('-'.repeat(76)); +let failed = 0; +for (const r of results) { + if (!r.ok) failed++; + console.log(pad(r.name, 36) + pad(r.ok ? 'ok' : 'FAIL', 8) + (r.note || '-')); +} +console.log(failed ? `\n${failed} of ${results.length} checks failed` + : `\nall ${results.length} checks pass`); +process.exit(failed ? 1 : 0); diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..25673ba --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,41 @@ +{ + // Type-checks the shipped source without compiling it. There is no build step + // and this does not add one: the manifest points at the same files it always + // has, and `noEmit` means nothing here ever writes one. + // + // It lives under test/ because that is already the directory zip.sh excludes + // from both packages, so the extension is unchanged by its existence. + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + + // The one check worth having here. This code walks HN's markup positionally + // — td:nth-child(3), .subtext a:eq(1), $this.parent().prev() — against a + // server that serves rate-limited and malformed bodies, which is what + // test/degenerate.mjs exists to prove survivable. Everything querySelector + // returns is Element | null and this is what says so. + "strictNullChecks": true, + + // Deliberately off. This is 2000 lines of jQuery callbacks whose parameters + // are genuinely untyped; turning it on buries the findings above under one + // error per callback and teaches nothing. Turn it on per file if a file is + // ever annotated properly. + "noImplicitAny": false, + "noImplicitThis": false, + + // Chrome 123+ is the floor, so the language level is not the constraint. + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "preserve", + "types": ["chrome", "jquery"] + }, + "include": [ + "globals.d.ts", + "../background.js", + "../offscreen.js", + "../js/modes.js", + "../js/boot.js", + "../js/hn.js" + ] +} From bb55c72b7687cd80f615028c1cb44e1bd1e74099 Mon Sep 17 00:00:00 2001 From: John Bolliger Date: Thu, 13 Aug 2026 23:26:46 -0700 Subject: [PATCH 12/20] Fix what the type checker found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 111 errors down to 24. The remainder is the null-safety inventory on the positional DOM walks, where each site needs a decision about what a missing element should mean rather than a mechanical fix. Six real bugs: - getInfo tested `comment_info_el.length == 0` on a DOM element, where `.length` is undefined, so that half of the guard never fired; then it indexed a `.match` result that is null whenever the last subtext link carries no `id=`. Reads the href only when it is an anchor now, and falls back to the address bar. - $.ajax's `accepts` takes a map keyed by dataType. It was passed the bare string "text/html", which jQuery ignores, so the inline reply never sent the Accept header it meant to. - `domain` crossed two click handlers through the global object — one wrote it, the other read it. Both read the page origin now. - `comments_link` was a global written and read inside a per-row .each, so every row shared one slot; it worked only because the two were adjacent. - Heat classes compared a string to a number: "" coerces to 0, so a row whose score had been emptied was rated no-heat, which is a real score of zero. - HNESModes.spec('hnesNav') went into selected() unchecked. Dead code: the .size() branch and its parameter (the only caller passes true), location.reload(true), a second argument to a one-parameter visit, a doubly-declared threadList, and two redundant execs of the same regex. ~38 implicit globals given var. Each was already function-local, so this closes a leak rather than changing behaviour. Note for anyone editing getInfo: it splits on a literal  , not a space. HN emits "3 comments" and normalising it breaks the count. Co-Authored-By: Claude Opus 5 (1M context) --- js/hn.js | 155 ++++++++++++++++++++++++---------------------- js/modes.js | 6 +- offscreen.js | 1 + test/README.md | 47 +++++++++++--- test/globals.d.ts | 6 ++ 5 files changed, 129 insertions(+), 86 deletions(-) diff --git a/js/hn.js b/js/hn.js index 6edc21d..0f689a0 100644 --- a/js/hn.js +++ b/js/hn.js @@ -33,8 +33,8 @@ var InlineReply = { doesn't work that way with collapsible comments*/ $(this).css('display', 'none'); - domain = window.location.origin; - link = domain + '/' + $(this).attr('href'); + var domain = window.location.origin; + var link = domain + '/' + $(this).attr('href'); if ($(this).next().hasClass('reply_form')) { $(this).next().show(); @@ -55,14 +55,17 @@ var InlineReply = { /* Reply button */ $('.rbutton').on('click', function(e) { e.preventDefault(); - link = $(this).attr('data'); - text = $(this).prev().val(); + // Read here rather than carried over from the handler above, which used to + // leave it on the global object for this one to pick up. + var domain = window.location.origin; + var link = $(this).attr('data'); + var text = $(this).prev().val(); //Hide cancel button and change reply text $(this).next().hide(); $(this).attr("disabled","true"); $(this).attr("value","Posting..."); //Add loading spinner - image = $(''); + var image = $(''); image.attr('src',chrome.runtime.getURL("images/spin.gif")); $(this).after(image); //Post @@ -78,12 +81,14 @@ var InlineReply = { postCommentTo: function(link, domain, text, button) { InlineReply.disableButtonAndBox(button); $.ajax({ - accepts: "text/html", + // A map keyed by dataType, not a bare string — jQuery ignored the string, + // so the Accept header this meant to send was never set. + accepts: { '*': 'text/html' }, url: link }).done(function(html) { - fnid = $(html).find('input[name="parent"]').attr('value'); - whence = $(html).find('input[name="goto"]').attr('value'); - hmac = $(html).find('input[name="hmac"]').attr('value'); + var fnid = $(html).find('input[name="parent"]').attr('value'); + var whence = $(html).find('input[name="goto"]').attr('value'); + var hmac = $(html).find('input[name="hmac"]').attr('value'); InlineReply.sendComment(domain, fnid, whence, hmac, text); }).fail(function(xhr, status, error) { InlineReply.enableButtonAndBox(button); @@ -98,7 +103,7 @@ var InlineReply = { 'hmac': hmacarg, 'text': textarg } ).always(function(a) { - window.location.reload(true); + window.location.reload(); // the force-reload argument was dropped from the spec }); }, @@ -172,28 +177,30 @@ var CommentTracker = { var comment_info_as = document.querySelectorAll('.subtext a'); var comment_info_el = comment_info_as[comment_info_as.length - 1]; + // The id is read off the href, so anything that is not a link is the same + // case as no link at all — the old `.length == 0` half of this test asked a + // DOM element for a jQuery property and so was never true. + var href = comment_info_el instanceof HTMLAnchorElement ? comment_info_el.href : ''; + // Falls back to the address bar, which is where the id is on a page whose + // last subtext link is something else. .match returns null in both places + // and the old code indexed the result without checking. + var id_match = href.match(/id=(\d+)/) || window.location.search.match(/id=(\d+)/); + // if there is no 'discuss' or 'n comment(s)' link it's some other kind of page (e.g. profile) - if (!comment_info_el || comment_info_el.length == 0) { + if (!href || !id_match) { return {"id": window.location.pathname + window.location.search, "num": 0, "last_comment_id": CommentTracker.getLastCommentId() } } - var page_id = comment_info_el.href.match(/id=(\d+)/); - if (page_id.length) { - page_id = Number(page_id[1]); - } - else { - page_id = window.location.search.match(/id=(\d+)/); - console.error('NO PAGEID', page_id); - } + var page_id = Number(id_match[1]); - var comment_info_text = comment_info_el.textContent; - var comment_num = comment_info_text.split(" ")[0]; - if (comment_num) { - comment_num = Number(comment_num); - } + var comment_info_text = comment_info_el.textContent || ''; + // The delimiter is a literal  , which is what HN puts between the + // count and the word, as in "3 comments". + var count_text = comment_info_text.split(" ")[0]; + var comment_num = count_text ? Number(count_text) : count_text; var last_id = CommentTracker.getLastCommentId(); @@ -651,7 +658,7 @@ class HNComments { const visit = (n) => { let acc = 0; for (let i = 0; i < n.children.length; i++) { - acc += visit(n.children[i], acc); + acc += visit(n.children[i]); } const res = acc + n.children.length; n.descCount = res; @@ -667,7 +674,6 @@ class HNComments { var commentTree = document.querySelector('#hnmain table.comment-tree'); var itemList = document.querySelector('#hnmain table.itemlist'); var threadList = document.querySelector('#hnmain table.comments-table'); - var threadList; if (!commentTree && !itemList && !threadList) { console.warn('unrecognized markup detected, no commentTree, itemList, or threadList'); return; @@ -802,11 +808,8 @@ var HN = { $('#content').after(morelink); } - let storyIdResults = /id=(\w+)/.exec(window.location.search) - let storyId = false; - if (storyIdResults) { - storyId = /id=(\w+)/.exec(window.location.search)[1] ; - } + let storyIdResults = /id=(\w+)/.exec(window.location.search); + let storyId = storyIdResults ? storyIdResults[1] : false; HN.hnComments = new HNComments(storyId); HN.doCommentsList(pathname, track_comments); } @@ -994,7 +997,9 @@ var HN = { .attr('aria-expanded', 'false') .html(HN.GEAR_SVG), host = $('').addClass('hnes-settings-host').append(link), - panel = null, + // An empty set rather than null: the panel is built on first open, and + // every use before that (.not(), .css()) is a no-op on one. + panel = $(), // Tracked rather than read back off the DOM: jQuery's :visible measures // the element, which forces a synchronous layout of the whole document — // expensive on a long thread, and for a fact we already know. Same @@ -1023,7 +1028,7 @@ var HN = { $('.nav-drop-down').not(panel).hide(); $('.more-arrow > a.active').removeClass('active'); - if (!panel) host.append(panel = HN.buildSettingsPanel()); + if (!panel.length) host.append(panel = HN.buildSettingsPanel()); open = true; HN.markSettings(panel); panel.css('display', 'block'); @@ -1071,7 +1076,7 @@ var HN = { // Consecutive specs sharing a label share one heading, which is what puts // two switches under a single "Reading" instead of a heading each. - var group = null, heading = null; + var group = $(), heading = ''; HNESModes.list.forEach(function(spec) { if (spec.label !== heading) { heading = spec.label; @@ -1244,7 +1249,7 @@ var HN = { }); }); panel.find('.hnes-settings-switchrow').each(function() { - $(this).attr('aria-checked', $(this).hasClass('hnes-settings-on')); + $(this).attr('aria-checked', $(this).hasClass('hnes-settings-on') ? 'true' : 'false'); }); }, @@ -1384,7 +1389,7 @@ var HN = { //enable highlighting of clicked links HN.enableLinkHighlighting(); - HN.replaceVoteButtons(true); + HN.replaceVoteButtons(); }, /*addClassToCommenters: function() { @@ -1400,12 +1405,9 @@ var HN = { //add classes to comment page header (OP post) and the table containing all the comments var comments; - let itemIdResults = /id=(\w+)/.exec(window.location.search) - var itemId = false; - if (itemIdResults) { - itemId = /id=(\w+)/.exec(window.location.search)[1] ; - } - below_header = $('#content table'); + let itemIdResults = /id=(\w+)/.exec(window.location.search); + var itemId = itemIdResults ? itemIdResults[1] : false; + var below_header = $('#content table'); $("

    Loading comments

    ").insertBefore(below_header[1]) @@ -1553,7 +1555,7 @@ var HN = { }, getFormattingHelp: function(links_work) { - help = '

    Blank lines separate paragraphs.

    ' + + var help = '

    Blank lines separate paragraphs.

    ' + '

    Text after a blank line that is indented by two or more spaces is reproduced verbatim (this is intended for code).

    ' + '

    Text surrounded by asterisks is italicized, if the character after the first asterisk isn\'t whitespace.

    '; if (links_work) @@ -1622,7 +1624,7 @@ var HN = { load_div.load(moreurl + " > center > table > tbody > tr:nth-child(3) > td > table > tbody > tr", function(response) { $(".comments-table > tbody").append(load_div.children()); $(".morelink").remove(); - morelink = $('.title a[rel="nofollow"]:contains(More)'); + var morelink = $('.title a[rel="nofollow"]:contains(More)'); if (morelink) { HN.loadMoreLink(morelink); } @@ -1637,22 +1639,13 @@ var HN = { } }, - replaceVoteButtons: function(isPostList) { + // Only ever called for a post list. The comment-page branch that used to sit + // here called jQuery's .size(), removed in 3.0, so it could not have run + // since the 3.2.1 upgrade. + replaceVoteButtons: function() { $('img[src$="grayarrow.gif"]').replaceWith('
    '); $('img[src$="graydown.gif"]').replaceWith('
    '); - - if (isPostList) { - $('div.up-arrow').addClass('postlist-arrow'); - } else { - // any up-arrows that don't have a down arrow next to them, add the last-arrow class - // as well, which will give a bit extra margin before the show/hide link - $('div.up-arrow').each(function() { - var numbuttons = $($(this).parents('center').get(0)).find('a').size(); - if (numbuttons == 1) { - $(this).addClass('last-arrow'); - } - }); - } + $('div.up-arrow').addClass('postlist-arrow'); }, addInfoToUsers: function() { @@ -1750,7 +1743,7 @@ var HN = { }, displayUserScore: function(el, upvotes) { - userscoreEl = el.parentElement.querySelector('.hnes-user-score'); + var userscoreEl = el.parentElement.querySelector('.hnes-user-score'); userscoreEl.textContent = upvotes; userscoreEl.parentElement.classList.remove('noscore'); }, @@ -1786,7 +1779,7 @@ var HN = { }); var commenter = $('.author:contains('+author+')'); - for (i = 0; i < commenter.length; i++) { + for (var i = 0; i < commenter.length; i++) { var tagText = $(commenter[i]).parent().find('.hnes-tagText'), tagEdit = $(commenter[i]).parent().find('.hnes-tagEdit'); @@ -1823,7 +1816,9 @@ var HN = { comments = $('
    -'); } - comments_link = $(at).attr('href'); + // Function-scoped, not shared: this runs per row, and as a global every + // row read whatever the previous one wrote. + var comments_link = $(at).attr('href'); if (comments.text() == "discuss" || /ago$/.test(comments.text())) { comments = $("").html('0') @@ -1948,7 +1943,9 @@ var HN = { ['upvoted', '/upvoted', "Stories you've voted for"], ['favorites', '/favorites', "Stories you've favorited"] ]; - var new_active = false; + // An empty set is the sentinel: .text() and .append() on one are no-ops, + // so nothing downstream needs a null check. + var new_active = $(); for (var i in user_pages) { var link_text = user_pages[i][0]; var link_href = user_pages[i][1]; @@ -1963,7 +1960,7 @@ var HN = { hidden_div.append(link); } - if (new_active) { + if (new_active.length) { /* * `||` here made the guard always true — no path is both /upvoted and * /favorites — so the two pages it names were the two it let through, @@ -2001,7 +1998,7 @@ var HN = { ); user_links.append(hidden_div); - user_drop_toggle = function() { + var user_drop_toggle = function() { user_drop.find('a').toggleClass('active') hidden_div.toggle(); } @@ -2018,14 +2015,17 @@ var HN = { */ rewriteNavigation: function() { HNESModes.ready(function() { - var chosen = HNESModes.selected(HNESModes.spec('hnesNav')), + // A missing spec would mean the descriptor list moved under us; showing + // every section beats showing none. + var nav_spec = HNESModes.spec('hnesNav'), + chosen = nav_spec ? HNESModes.selected(nav_spec) : null, visible_pages = [], hidden_pages = []; // Split in HNESModes.sections order rather than in the order they were // picked, so moving one section across never reorders the others. HNESModes.sections.forEach(function(section) { - (chosen.indexOf(section.id) >= 0 ? visible_pages : hidden_pages).push(section); + (!chosen || chosen.indexOf(section.id) >= 0 ? visible_pages : hidden_pages).push(section); }); HN.paintNavigation(visible_pages, hidden_pages); @@ -2069,7 +2069,7 @@ var HN = { var hidden_div = $('
    ').attr('id', 'nav-others') .addClass('nav-drop-down'); - var new_active = false; + var new_active = $(); hidden_pages.forEach(function(section) { var new_link = $('').attr('href', section.href) .attr('title', section.hint) @@ -2088,12 +2088,12 @@ var HN = { // the kind of dead affordance the panel exists to avoid. if (hidden_pages.length) topsel.append(more_link).append(hidden_div); - if (new_active) + if (new_active.length) topsel.append($('').text('|').append(new_active)); navigation.empty().append(topsel); - toggle_more_link = function() { + var toggle_more_link = function() { more_link.find('a').toggleClass('active'); hidden_div.toggle(); } @@ -2163,9 +2163,12 @@ var HN = { // the submit form unguarded — `j` mid-reply scrolled the page out // from under it. Asking the focused element covers all of them, and // covers boxes HN adds later without being told about them. - var el = e.target; - if (el && (el.isContentEditable || - /^(?:INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return; + // jQuery's types say Document here; the runtime value is the focused + // element, which is what the instanceof below establishes. + var el = /** @type {*} */ (e.target); + if (el instanceof HTMLElement && + (el.isContentEditable || + /^(?:INPUT|TEXTAREA|SELECT)$/.test(el.tagName))) return; if (e.ctrlKey || !HNESModes.on('hnesKeys')) return; if (e.which == j) { @@ -2260,9 +2263,11 @@ var HN = { var MILD = 75; var MEDIUM = 99; $('.score').each(function(i){ - var score = $(this).html(); - - score = score.replace(/[a-z]/g, ''); + // parseInt rather than the string compare this used to do: "" coerced to + // 0 and took the no-heat branch, which is a real score of zero. A row + // with no score at all is skipped instead. + var score = parseInt($(this).html().replace(/[a-z]/g, ''), 10); + if (isNaN(score)) return; if (score < NO_HEAT) { $(this).addClass('no-heat'); diff --git a/js/modes.js b/js/modes.js index 0ea6dae..f53f63c 100644 --- a/js/modes.js +++ b/js/modes.js @@ -70,6 +70,7 @@ var ON_OFF = [{ id: 'on' }, { id: 'off' }]; + /** @type {HNESModeSpec[]} */ var MODES = [ { key: 'hnesTheme', attr: 'data-hnes-theme', label: 'Theme', ui: 'list', @@ -181,7 +182,10 @@ }, apply: function (root, spec, value) { - if (this.indexOf(spec, value) > 0) root.setAttribute(spec.attr, value); + // Only a painting spec has an attribute to write. Callers filter on that + // already; this is what makes the function safe to call without doing so. + if (!spec.attr) return; + if (this.indexOf(spec, value) > 0) root.setAttribute(spec.attr, String(value)); else root.removeAttribute(spec.attr); }, diff --git a/offscreen.js b/offscreen.js index 096ffc5..dcb52db 100644 --- a/offscreen.js +++ b/offscreen.js @@ -12,6 +12,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { const out = {}; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); + if (key === null) continue; // only if the store shrank mid-walk out[key] = localStorage.getItem(key); } sendResponse(out); diff --git a/test/README.md b/test/README.md index 245cdfe..6e53d0f 100644 --- a/test/README.md +++ b/test/README.md @@ -43,26 +43,53 @@ callback. `declare var` adds the property to globalThis, `modes.js`'s own assignment is checked against that declaration, so the two cannot drift silently. -**It is a report, not a gate.** The first run gave 113 errors and it still exits -non-zero; wiring it into a release check means working that number down first. -What it found: +**It is a report, not a gate.** The first run gave 113 errors; fixing everything +below took that to 24, and it still exits non-zero. Wiring it into a release +check means finishing the last group first. + +Bugs it found, all fixed: - **`hn.js` threw for every logged-in user on `/upvoted` and `/favorites`.** A guard written `!= '/upvoted' || != '/favorites'` is true for every possible path, so the two pages it names were the two it let through — and they are - exactly the two with no `?id=` for the next line to match against. Fixed. + exactly the two with no `?id=` for the next line to match against. +- **`CommentTracker.getInfo` had a dead guard and a live crash.** It tested + `comment_info_el.length == 0` on a DOM element, where `.length` is + `undefined`, so that half never fired; then it indexed the result of a + `.match` that returns `null` whenever the last subtext link carries no `id=`. +- **The inline reply never sent the Accept header it meant to.** jQuery's + `accepts` takes a map keyed by dataType; it was passed the bare string + `"text/html"`, which jQuery ignores. +- **`domain` crossed two click handlers through the global object.** The reply + handler wrote it; the post handler read it. Both now read the page origin. +- **`comments_link` was a global read and written inside a per-row `.each`,** + so every row shared one slot and it worked only because the two were adjacent. +- **Heat classes compared a string to a number.** `"" < 50` coerces to `0 < 50`, + so a row whose score had been emptied was silently rated `no-heat` — a real + score of zero. It parses now, and skips a row with no score. + +Cleanup it found: + - **~38 implicit globals** — `link`, `domain`, `text`, `image`, `fnid`, `whence`, `hmac`, `below_header`, `help`, `morelink`, `userscoreEl`, `i`, `comments_link`, `user_drop_toggle`, `toggle_more_link`. Assignments with no `var`, leaking into the isolated world and shared across every call. - **`.size()` at `hn.js:1650`**, removed from jQuery in 3.0 and absent from the - vendored 3.2.1. It never fires: the only caller passes `true`, so the branch - holding it is dead. + vendored 3.2.1. It never fired: the only caller passes `true`, so the whole + branch holding it was dead and is gone, along with the parameter. - Smaller ones — `location.reload(true)` (the argument was dropped from the - spec), `visit(n.children[i], acc)` against a one-parameter `visit`, and - `var threadList` declared twice in `HNComments.apply`. -- **~19 null-safety findings** on the positional walks. That list is the point - of the exercise: it is the only inventory of where HN's markup is assumed. + spec), `visit(n.children[i], acc)` against a one-parameter `visit`, + `var threadList` declared twice in `HNComments.apply`, and two copies of + `/id=(\w+)/.exec(location.search)` where one result was already in hand. +- `false` used as a null sentinel for a jQuery object in four places. An empty + set says the same thing and needs no special case at the point of use. + +What is left is **~24 null-safety findings** on the positional walks — mostly +`querySelector(…).href` with no check. That list is the point of the exercise: +it is the only inventory of where HN's markup is assumed, and each one needs a +decision about what should happen when the element is missing rather than a +mechanical fix. `degenerate.mjs` already holds the property that matters +meanwhile — that a page this broken stays usable. ## migration.mjs — run this before any release that changes storage diff --git a/test/globals.d.ts b/test/globals.d.ts index 3e52d7c..7cfcaa8 100644 --- a/test/globals.d.ts +++ b/test/globals.d.ts @@ -13,6 +13,12 @@ * and the two cannot drift without the checker saying so. */ +// jquery-linkify, vendored under js/. It patches the jQuery prototype at load +// time, which @types/jquery has no way to know about. +interface JQuery { + linkify(options?: Record): JQuery; +} + interface HNESModeValue { id: string; label?: string; From 60dce79195b81afbca741e6916c76a0c1f51a8c8 Mon Sep 17 00:00:00 2001 From: John Bolliger Date: Mon, 17 Aug 2026 21:38:49 -0700 Subject: [PATCH 13/20] Remove the inline reply, which had not run in years MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InlineReply.init() was commented out at its only call site, so the whole object was unreachable — including the two bugs the type checker had just found in it. Repairing dead code is not worth the lines. Its stylesheet rules go with it. a[href^="reply"]:visited stays: that one styles HN's own reply links, which are still there. The test README entry now records both bugs and the fact that the block was dead. Co-Authored-By: Claude Opus 5 (1M context) --- js/hn.js | 120 ------------------------------------------------- style.css | 5 +-- test/README.md | 11 ++--- 3 files changed, 7 insertions(+), 129 deletions(-) diff --git a/js/hn.js b/js/hn.js index 0f689a0..4169ea9 100644 --- a/js/hn.js +++ b/js/hn.js @@ -15,125 +15,6 @@ * Under MIT license, see LICENSE */ -var InlineReply = { - init: function() { - $('a[href^="reply?"]').click(function(e) { - if (HN.isLoggedIn()) { - e.preventDefault(); - } - else { - return; - } - - //make sure there's no stray underlining between Reply and Cancel - $(this).addClass('underlined'); - $(this).parent('u').replaceWith($(this)); - - /*remove the 'reply' link without actually hide()ing it because it - doesn't work that way with collapsible comments*/ - $(this).css('display', 'none'); - - var domain = window.location.origin; - var link = domain + '/' + $(this).attr('href'); - - if ($(this).next().hasClass('reply_form')) { - $(this).next().show(); - } - else { - //add buttons and box - $(this).after( - '
    \ -