Skip to content

Instantly share code, notes, and snippets.

@swhitt
Last active February 1, 2026 06:17
Show Gist options
  • Select an option

  • Save swhitt/0fcf80442f2c0b55c01a90fa3a512df6 to your computer and use it in GitHub Desktop.

Select an option

Save swhitt/0fcf80442f2c0b55c01a90fa3a512df6 to your computer and use it in GitHub Desktop.
HackerWeb Tools - Enhancements for Hacker News and HackerWeb
// ==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