Last active
February 1, 2026 06:17
-
-
Save swhitt/0fcf80442f2c0b55c01a90fa3a512df6 to your computer and use it in GitHub Desktop.
HackerWeb Tools - Enhancements for Hacker News and HackerWeb
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
| // ==UserScript== | |
| // @name HackerWeb Tools | |
| // @namespace https://github.com/swhitt | |
| // @version 0.0.1-7 | |
| // @author Steve Whittaker <[email protected]> | |
| // @description Enhancements for Hacker News and HackerWeb: collapsible comments, quick navigation links | |
| // @license MIT | |
| // @icon https://news.ycombinator.com/favicon.ico | |
| // @downloadURL https://gist.githubusercontent.com/swhitt/0fcf80442f2c0b55c01a90fa3a512df6/raw/hackerweb-tools.user.js | |
| // @updateURL https://gist.githubusercontent.com/swhitt/0fcf80442f2c0b55c01a90fa3a512df6/raw/hackerweb-tools.user.js | |
| // @match https://hackerweb.app/* | |
| // @match https://news.ycombinator.com/* | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| function createDebouncedObserver(callback, target = document.body, options = { childList: true, subtree: true }) { | |
| let pending = false; | |
| const observer = new MutationObserver(() => { | |
| if (pending) return; | |
| pending = true; | |
| requestAnimationFrame(() => { | |
| callback(); | |
| pending = false; | |
| }); | |
| }); | |
| observer.observe(target, options); | |
| return observer; | |
| } | |
| function createStyleInjector(styleId) { | |
| let injected = false; | |
| return (css) => { | |
| if (injected) return; | |
| injected = true; | |
| if (typeof GM_addStyle === "function") { | |
| GM_addStyle(css); | |
| return; | |
| } | |
| const style = document.createElement("style"); | |
| style.id = styleId; | |
| style.textContent = css; | |
| document.head.appendChild(style); | |
| }; | |
| } | |
| const STYLES$1 = ` | |
| /* Wider main column */ | |
| body > section { | |
| max-width: 900px !important; | |
| } | |
| /* Better readability */ | |
| .comment-content, | |
| section li > p { | |
| line-height: 1.6 !important; | |
| } | |
| /* More breathing room between comments */ | |
| section li { | |
| margin-bottom: 12px !important; | |
| } | |
| /* Toggle button - base styles (override HackerWeb defaults) */ | |
| .hwc-toggle.comments-toggle { | |
| display: inline-flex !important; | |
| align-items: center !important; | |
| gap: 0.25em !important; | |
| font-size: 0.85em !important; | |
| font-weight: 500 !important; | |
| font-variant-numeric: tabular-nums !important; | |
| margin: 4px 0 !important; | |
| padding: 2px 6px !important; | |
| white-space: nowrap !important; | |
| color: #828282 !important; | |
| background: none !important; | |
| background-color: rgba(255, 255, 255, 0.05) !important; | |
| border: 1px solid rgba(255, 255, 255, 0.1) !important; | |
| border-radius: 3px !important; | |
| cursor: pointer !important; | |
| transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease !important; | |
| } | |
| /* Hover state */ | |
| .hwc-toggle.comments-toggle:hover { | |
| color: #e07020 !important; | |
| background-color: rgba(255, 140, 50, 0.10) !important; | |
| border-color: rgba(255, 140, 50, 0.25) !important; | |
| } | |
| /* Active/pressed state */ | |
| .hwc-toggle.comments-toggle:active { | |
| color: #ff6600 !important; | |
| background-color: rgba(255, 102, 0, 0.2) !important; | |
| } | |
| /* Focus state for keyboard users */ | |
| .hwc-toggle.comments-toggle:focus-visible { | |
| outline: 2px solid #ff6600 !important; | |
| outline-offset: 2px !important; | |
| } | |
| /* Collapsed state - slightly muted */ | |
| .hwc-toggle.hwc-collapsed { | |
| color: #999 !important; | |
| } | |
| .hwc-toggle.hwc-collapsed:hover { | |
| color: #ff6600 !important; | |
| } | |
| /* Arrow indicator with rotation */ | |
| .hwc-toggle .hwc-arrow { | |
| display: inline-block !important; | |
| transition: transform 0.15s ease-out !important; | |
| } | |
| .hwc-toggle:not(.hwc-collapsed) .hwc-arrow { | |
| transform: rotate(90deg) !important; | |
| } | |
| /* Ancestor highlight on hover */ | |
| li.hwc-hl { | |
| background-color: rgba(255,255,255,0.04) !important; | |
| } | |
| `; | |
| const inject$1 = createStyleInjector("hwc-styles"); | |
| function injectStyles$1() { | |
| inject$1(STYLES$1); | |
| } | |
| function qs(sel, root = document) { | |
| return root.querySelector(sel); | |
| } | |
| function qsa(sel, root = document) { | |
| return root.querySelectorAll(sel); | |
| } | |
| function getEventTargetElement(event) { | |
| const target = event.target; | |
| return target instanceof Element ? target : null; | |
| } | |
| const STORAGE_KEY = "hwc-collapsed"; | |
| const LOG_PREFIX = "[HackerWeb Tools]"; | |
| function asCommentId(id) { | |
| if (!id || !/^\d+$/.test(id)) return null; | |
| return id; | |
| } | |
| function isStringArray(value) { | |
| return Array.isArray(value) && value.every((item) => typeof item === "string"); | |
| } | |
| let cache = null; | |
| function loadState() { | |
| if (cache) return cache; | |
| cache = new Set(); | |
| try { | |
| const stored = localStorage.getItem(STORAGE_KEY); | |
| if (stored) { | |
| const parsed = JSON.parse(stored); | |
| if (isStringArray(parsed)) { | |
| for (const id of parsed) { | |
| const validId = asCommentId(id); | |
| if (validId) cache.add(validId); | |
| } | |
| } else { | |
| console.warn( | |
| LOG_PREFIX, | |
| "localStorage data has unexpected format, starting fresh. Expected string array, got:", | |
| typeof parsed | |
| ); | |
| } | |
| } | |
| } catch (error) { | |
| console.warn( | |
| LOG_PREFIX, | |
| "Failed to load collapse state from localStorage:", | |
| error instanceof Error ? error.message : String(error) | |
| ); | |
| } | |
| return cache; | |
| } | |
| function saveState(state) { | |
| try { | |
| localStorage.setItem(STORAGE_KEY, JSON.stringify([...state])); | |
| } catch (error) { | |
| console.warn( | |
| LOG_PREFIX, | |
| "Failed to save collapse state to localStorage:", | |
| error instanceof Error ? error.message : String(error) | |
| ); | |
| } | |
| } | |
| function getCollapsedState(commentId) { | |
| return loadState().has(commentId); | |
| } | |
| function setCollapsedState(commentId, collapsed) { | |
| const state = loadState(); | |
| if (collapsed) { | |
| state.add(commentId); | |
| } else { | |
| state.delete(commentId); | |
| } | |
| saveState(state); | |
| } | |
| function setDataBool(el, key, value) { | |
| el.dataset[key] = value ? "true" : "false"; | |
| } | |
| function getDataBool(el, key) { | |
| return el?.dataset[key] === "true"; | |
| } | |
| const SELECTORS = { | |
| commentLi: "section li", | |
| repliesUl: ":scope > ul", | |
| childCommentLi: ":scope > li", | |
| ourToggle: ":scope > button.hwc-toggle", | |
| originalToggle: ":scope > button.comments-toggle:not(.hwc-toggle)", | |
| anyOurToggle: "button.hwc-toggle" | |
| }; | |
| const LEFT_GUTTER_THRESHOLD_PX = 15; | |
| function getRepliesUl(li) { | |
| return qs(SELECTORS.repliesUl, li); | |
| } | |
| function countDescendantReplies(ul) { | |
| if (!ul) return 0; | |
| let count = 0; | |
| for (const childLi of qsa(SELECTORS.childCommentLi, ul)) { | |
| count += 1; | |
| count += countDescendantReplies(getRepliesUl(childLi)); | |
| } | |
| return count; | |
| } | |
| function findThreadRoot(li) { | |
| let current = li; | |
| let parentLi = current.parentElement?.closest("li"); | |
| while (parentLi instanceof HTMLLIElement) { | |
| current = parentLi; | |
| parentLi = current.parentElement?.closest("li"); | |
| } | |
| return current; | |
| } | |
| function getCommentId(li) { | |
| const timeLink = li.querySelector('p.metadata time a[href*="item?id="]'); | |
| if (timeLink) { | |
| const href = timeLink.getAttribute("href"); | |
| const match = href?.match(/item\?id=(\d+)/); | |
| if (match?.[1]) return asCommentId(match[1]); | |
| } | |
| return asCommentId(li.dataset["id"]) ?? asCommentId(li.id); | |
| } | |
| function setCollapsed(li, collapsed) { | |
| const ul = getRepliesUl(li); | |
| const btn = qs(SELECTORS.ourToggle, li); | |
| if (!ul || !btn) return; | |
| ul.style.display = collapsed ? "none" : ""; | |
| setDataBool(btn, "collapsed", collapsed); | |
| btn.classList.toggle("hwc-collapsed", collapsed); | |
| const count = btn.dataset["count"] ?? "0"; | |
| btn.setAttribute("aria-expanded", String(!collapsed)); | |
| btn.setAttribute( | |
| "aria-label", | |
| collapsed ? `Expand ${count} replies` : `Collapse ${count} replies` | |
| ); | |
| const commentId = getCommentId(li); | |
| if (commentId) { | |
| setCollapsedState(commentId, collapsed); | |
| } | |
| } | |
| function collapseWholeThread(rootLi) { | |
| for (const btn of qsa(SELECTORS.anyOurToggle, rootLi)) { | |
| if (getDataBool(btn, "collapsed")) continue; | |
| const li = btn.closest("li"); | |
| if (li instanceof HTMLLIElement) setCollapsed(li, true); | |
| } | |
| } | |
| function createToggleButton(repliesUl) { | |
| if (!repliesUl.children.length) return null; | |
| const count = countDescendantReplies(repliesUl); | |
| const collapsed = getComputedStyle(repliesUl).display === "none"; | |
| const btn = document.createElement("button"); | |
| btn.type = "button"; | |
| btn.className = `comments-toggle hwc-toggle${collapsed ? " hwc-collapsed" : ""}`; | |
| btn.innerHTML = `<span class="hwc-arrow">▶</span> ${count}`; | |
| btn.dataset["count"] = String(count); | |
| btn.setAttribute("aria-expanded", String(!collapsed)); | |
| btn.setAttribute( | |
| "aria-label", | |
| collapsed ? `Expand ${count} replies` : `Collapse ${count} replies` | |
| ); | |
| btn.title = "Click to toggle, Shift+click to collapse entire thread"; | |
| setDataBool(btn, "collapsed", collapsed); | |
| return btn; | |
| } | |
| function injectButtons() { | |
| for (const li of qsa(SELECTORS.commentLi)) { | |
| const repliesUl = getRepliesUl(li); | |
| if (!repliesUl?.children.length) continue; | |
| if (qs(SELECTORS.ourToggle, li)) continue; | |
| qs(SELECTORS.originalToggle, li)?.remove(); | |
| const btn = createToggleButton(repliesUl); | |
| if (btn) li.insertBefore(btn, repliesUl); | |
| const commentId = getCommentId(li); | |
| if (commentId && getCollapsedState(commentId)) { | |
| setCollapsed(li, true); | |
| } | |
| } | |
| } | |
| function clearHighlights() { | |
| for (const el of qsa(".hwc-hl")) { | |
| el.classList.remove("hwc-hl"); | |
| } | |
| } | |
| function highlightAncestors(startLi) { | |
| clearHighlights(); | |
| let li = startLi; | |
| while (li instanceof HTMLLIElement) { | |
| li.classList.add("hwc-hl"); | |
| li = li.parentElement?.closest("li") ?? null; | |
| } | |
| } | |
| function handleToggleClick(event, btn) { | |
| event.stopPropagation(); | |
| event.preventDefault(); | |
| const li = btn.closest("li"); | |
| if (!(li instanceof HTMLLIElement)) return; | |
| if (event.shiftKey) { | |
| collapseWholeThread(findThreadRoot(li)); | |
| return; | |
| } | |
| setCollapsed(li, !getDataBool(btn, "collapsed")); | |
| } | |
| function handleLeftGutterClick(event, li) { | |
| const rect = li.getBoundingClientRect(); | |
| const clickX = event.clientX - rect.left; | |
| if (clickX > LEFT_GUTTER_THRESHOLD_PX) return; | |
| const btn = qs(SELECTORS.ourToggle, li); | |
| if (!btn) return; | |
| event.preventDefault(); | |
| setCollapsed(li, !getDataBool(btn, "collapsed")); | |
| } | |
| function setupEventListeners() { | |
| document.addEventListener("mouseover", (event) => { | |
| const target = getEventTargetElement(event); | |
| if (!target) return; | |
| const li = target.closest(SELECTORS.commentLi); | |
| if (li instanceof HTMLLIElement) highlightAncestors(li); | |
| }); | |
| document.addEventListener("click", (event) => { | |
| const target = getEventTargetElement(event); | |
| if (!target) return; | |
| const btn = target.closest("button.hwc-toggle"); | |
| if (btn instanceof HTMLButtonElement) { | |
| handleToggleClick(event, btn); | |
| return; | |
| } | |
| const li = target.closest(SELECTORS.commentLi); | |
| if (li instanceof HTMLLIElement) handleLeftGutterClick(event, li); | |
| }); | |
| } | |
| let initialized$1 = false; | |
| function initCollapse() { | |
| if (!initialized$1) { | |
| injectStyles$1(); | |
| setupEventListeners(); | |
| initialized$1 = true; | |
| } | |
| injectButtons(); | |
| } | |
| function init$1() { | |
| initCollapse(); | |
| createDebouncedObserver(() => initCollapse()); | |
| window.addEventListener("hashchange", () => initCollapse()); | |
| } | |
| const HCKRNEWS_URL = "https://hckrnews.com/"; | |
| const LINK_CLASS$1 = "hn-links-header"; | |
| function injectHeaderLink() { | |
| if (document.querySelector(`.${LINK_CLASS$1}`)) return; | |
| const pagetop = document.querySelector(".pagetop"); | |
| if (!pagetop) return; | |
| const separator = document.createTextNode(" | "); | |
| const link = document.createElement("a"); | |
| link.href = HCKRNEWS_URL; | |
| link.textContent = "hckrnews"; | |
| link.className = LINK_CLASS$1; | |
| pagetop.appendChild(separator); | |
| pagetop.appendChild(link); | |
| } | |
| let initialized = false; | |
| function initHeaderLink() { | |
| if (initialized) return; | |
| initialized = true; | |
| injectHeaderLink(); | |
| } | |
| const STYLES = ` | |
| /* HackerWeb link styling */ | |
| .hn-links-hweb { | |
| margin-left: 4px; | |
| font-size: 0.85em; | |
| color: #828282; | |
| } | |
| .hn-links-hweb:visited { | |
| color: #828282; | |
| } | |
| `; | |
| const inject = createStyleInjector("hn-links-styles"); | |
| function injectStyles() { | |
| inject(STYLES); | |
| } | |
| const HACKERWEB_URL = "https://hackerweb.app/#/item/"; | |
| const LINK_CLASS = "hn-links-hweb"; | |
| function createHackerWebLink(itemId) { | |
| const link = document.createElement("a"); | |
| link.href = `${HACKERWEB_URL}${itemId}`; | |
| link.textContent = "[hweb]"; | |
| link.className = LINK_CLASS; | |
| link.title = "View on HackerWeb"; | |
| return link; | |
| } | |
| function injectStoryLinks() { | |
| const storyRows = document.querySelectorAll("tr.athing[id]"); | |
| for (const row of storyRows) { | |
| const itemId = row.id; | |
| if (!itemId) continue; | |
| const subtextRow = row.nextElementSibling; | |
| if (!subtextRow) continue; | |
| const subtext = subtextRow.querySelector(".subtext"); | |
| if (!subtext) continue; | |
| if (subtext.querySelector(`.${LINK_CLASS}`)) continue; | |
| const link = createHackerWebLink(itemId); | |
| subtext.appendChild(link); | |
| } | |
| } | |
| function injectCommentPageLink() { | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const itemId = urlParams.get("id"); | |
| if (!itemId) return; | |
| const titleRow = document.querySelector("tr.athing[id]"); | |
| if (!titleRow) return; | |
| const subtextRow = titleRow.nextElementSibling; | |
| if (!subtextRow) return; | |
| const subtext = subtextRow.querySelector(".subtext"); | |
| if (!subtext) return; | |
| if (subtext.querySelector(`.${LINK_CLASS}`)) return; | |
| const link = createHackerWebLink(itemId); | |
| subtext.appendChild(link); | |
| } | |
| let stylesInitialized = false; | |
| function initItemLinks() { | |
| if (!stylesInitialized) { | |
| injectStyles(); | |
| stylesInitialized = true; | |
| } | |
| injectStoryLinks(); | |
| injectCommentPageLink(); | |
| } | |
| function init() { | |
| initHeaderLink(); | |
| initItemLinks(); | |
| createDebouncedObserver(() => initItemLinks()); | |
| } | |
| const version = "0.0.1"; | |
| const build = 7; | |
| const fullVersion = `${version}-${build}`; | |
| Object.assign(window, { | |
| __HWT__: { version: fullVersion, loaded: ( new Date()).toISOString() } | |
| }); | |
| console.debug(`[HackerWeb Tools] v${fullVersion}`); | |
| function main() { | |
| if (document.readyState === "loading") { | |
| document.addEventListener("DOMContentLoaded", route); | |
| } else { | |
| route(); | |
| } | |
| } | |
| function route() { | |
| const host = location.hostname; | |
| if (host === "hackerweb.app") { | |
| init$1(); | |
| } else if (host === "news.ycombinator.com") { | |
| init(); | |
| } | |
| } | |
| main(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment