Aug 2026 rework - #191
Open
skunkworker wants to merge 20 commits into
Open
Conversation
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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 <html> 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
HN.currentItemId() — two call sites ran the same regex against the same search string and reduced it the same way. The third id= read stays where it is; it guards on the pathname too, so it is a different question. HN.wireDropDown() — the user menu and the "more" menu wired identical open/close handlers. HNESModes.apply() already returns early when a spec has no attribute, and said so in a comment; three callers checked anyway. One guard, in the one place that needs it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The MV3 work already did most of it: Safari aliases chrome.* to browser.*, and the whole API surface this extension touches is supported there except chrome.offscreen — which exists to migrate an MV2 localStorage store that no iOS user can have, since Safari could not run extensions at all until iOS 15. One guard skips it. What is left is a native container app, which is new, and touch, which is the real work: 29 hover rules, tap targets under 44pt, and a settings panel never tried at 320px. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It had grown to 1519px of content in a 536px box — about a third of itself on screen, with everything after Palette below the fold on a full-height 1080px desktop. What made that bad rather than long is that nothing said so: macOS fades out overlay scrollbars, so a panel with four hidden groups looked exactly like a panel with none. Look / Reading / Sections / Storage, grouped so the tallest pane is 488px and none of them scrolls. The tab list lives in modes.js next to MODES, because a spec's label and the tab holding it are one fact — splitting that across two files is what let boot.js and hn.js drift before modes.js existed. A group no tab claims still lands in the pane that opens, so forgetting to file a new setting looks wrong rather than losing it. The strip is a real tablist: roving tabindex, arrows, wrap, aria-selected. The panel keeps its max-height for short viewports and now sets scrollbar-color, which is what makes Chromium draw a scrollbar that stays. controls.mjs is 39 checks, six of them new; the load-bearing one is "no pane needs scrolling", which is what a setting filed under the wrong tab would break and nothing else would notice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Chrome warns "'background.scripts' requires manifest version of 2 or lower" on every load. MDN's cross-browser form is to carry both keys and let each browser take its own, which is what shipped — but Chrome ignores `scripts` in MV3 rather than accepting it, and says so, and Firefox has no service worker at all (bug 1573659), so each browser complains about the other's key. The tree is Chrome-shaped now, because Chrome is what all seven suites drive. zip.sh writes the Firefox background key into that package on the way past, and drops `offscreen` and `minimum_chrome_version` with it — Firefox knows neither, and the point is a package that loads clean in either store. Firefox unpacked is now the built zip, which about:debugging takes as-is. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Half the settings paint: they write an attribute onto <html> and the stylesheet does the rest, live and across tabs. The other half do not. The Reading switches, Keyboard and all fourteen Sections were read at document_end and turned into markup and bindings that already exist, so clicking one saves instantly and moves nothing on the page. Adding `ask` to the header and watching the header not change is indistinguishable from the click having failed. A bar now appears under the panes — Reload to apply, with Saved first, because the reload applies a stored choice rather than committing an unstored one. Keyed on `!spec.attr` rather than a list of keys, so a new behaviour spec gets the bar without anyone remembering. Raised cross-tab as well, off the specs HNESModes.subscribe reports, since another tab's change leaves this page just as stale. Visibility is derived in markSettings on every open, the same way the marks are recomputed rather than tracked. Costs 50px, which takes the panel's cap from 560 to 615: the Look pane set that cap and the bar has to fit under it. Sticking the bar to the bottom of the scroll box would avoid that and would also make a scrolling panel supported again, which is what the tabs were for. controls: 43 checks. Two of the three bar checks assert it is *down* — a bar that is always up says nothing — and the sections step now reloads by clicking the bar, since that is the only way a user applies it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The reload bar was inside the scroll box, so on any viewport short enough for 70vh to bind it scrolled off the bottom with everything else — a button asking you to reload that you have to scroll to the bottom to find. The tab strip had the same bug: scroll the panel and you lose the way out of the pane you are in. Three rows now, and only the middle one scrolls. .hnes-settings is a flex column with overflow:hidden, .hnes-settings-panes carries the overflow-y and the scrollbar-color, and the strip and the bar are flex:none at either end. min-height:0 on the panes is the load-bearing part — a flex item defaults to min-height:auto and refuses to shrink below its content, which pushes the bar back off the bottom instead of scrolling. Both scroll-fit checks had to move to .hnes-settings-panes: the panel is overflow:hidden, so measuring it would read zero forever and say nothing. The new check resizes to 520, scrolls the panes to the bottom, and asserts the strip and the bar are both still inside the panel's box. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It still advertised inline commenting, which was dead code before it was removed in 60dce79, and listed "Options page" under TODO while the settings panel it describes has shipped. The keyboard list was missing h. Adds the settings panel, themes and palettes, the section picker, and the part a reader of this file has no other way to find: Firefox is loaded from zip.sh's package now, because the two browsers cannot share a background key. Every other feature claim was checked against the source rather than carried over. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.