Skip to content

Instantly share code, notes, and snippets.

@swhitt
Last active March 26, 2026 21:49
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.3.8
// @author Steve Whittaker
// @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
// @run-at document-end
// @noframes
// ==/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(() => {
try {
callback();
} catch (error) {
console.error("[HWT Observer] Error in mutation callback:", error);
}
pending = false;
});
});
observer.observe(target, options);
return observer;
}
const TEXT_INPUT_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]);
function isTextInputFocused() {
const active = document.activeElement;
if (!active) return false;
if (TEXT_INPUT_TAGS.has(active.tagName)) return true;
if (active.getAttribute("contenteditable") === "true") return true;
return false;
}
function normalizeKey(key) {
return key.toLowerCase().trim();
}
function eventMatchesKey(event, key) {
const parts = normalizeKey(key).split("+");
const mainKey = parts.pop() ?? "";
const modifiers = new Set(parts);
const eventKey = event.key.toLowerCase();
if (eventKey !== mainKey && event.code.toLowerCase() !== mainKey) {
return false;
}
if (modifiers.has("ctrl") !== event.ctrlKey) return false;
if (modifiers.has("alt") !== event.altKey) return false;
if (modifiers.has("shift") !== event.shiftKey) return false;
if (modifiers.has("meta") !== event.metaKey) return false;
return true;
}
class KeyboardManager {
bindings = new Map();
enabled = true;
currentScope = "global";
initialized = false;
boundHandler = null;
init() {
if (this.initialized) return;
this.initialized = true;
this.boundHandler = (event) => this.handleKeydown(event);
document.addEventListener("keydown", this.boundHandler);
}
destroy() {
if (this.boundHandler) {
document.removeEventListener("keydown", this.boundHandler);
this.boundHandler = null;
}
this.bindings.clear();
this.initialized = false;
}
setScope(scope) {
this.currentScope = scope;
}
register(key, handler, options) {
const normalized = normalizeKey(key);
const scope = options.scope ?? "global";
const id = `${scope}:${normalized}`;
const binding = {
key: normalized,
handler,
description: options.description,
scope,
priority: options.priority ?? 0
};
const existing = this.bindings.get(id);
if (existing) {
console.warn(
`[HWT Keys] Overwriting binding for ${key} in scope ${scope}`
);
}
this.bindings.set(id, binding);
return () => {
this.bindings.delete(id);
};
}
unregisterScope(scope) {
for (const [id, binding] of this.bindings) {
if (binding.scope === scope) {
this.bindings.delete(id);
}
}
}
setEnabled(enabled) {
this.enabled = enabled;
}
getBindings() {
return Array.from(this.bindings.values()).filter(
(b) => b.scope === "global" || b.scope === this.currentScope
);
}
handleKeydown(event) {
if (!this.enabled) return;
if (isTextInputFocused()) return;
const matches = [];
for (const binding2 of this.bindings.values()) {
if (binding2.scope !== "global" && binding2.scope !== this.currentScope) {
continue;
}
if (eventMatchesKey(event, binding2.key)) {
matches.push(binding2);
}
}
if (matches.length === 0) return;
matches.sort((a, b) => b.priority - a.priority);
const binding = matches[0];
if (!binding) return;
try {
const result = binding.handler(event);
if (result === false) {
event.preventDefault();
event.stopPropagation();
}
} catch (error) {
console.error(`[HWT Keys] Error in handler for "${binding.key}":`, error);
}
}
}
let instance$1 = null;
function getKeyboardManager() {
instance$1 ??= new KeyboardManager();
return instance$1;
}
const DEFAULT_CONFIG = {
features: {
collapse: true,
hwebLinks: true,
opBadge: true,
deepLink: true,
keyboardNav: false,
newCommentHighlight: false,
hideReadStories: false,
darkModeSync: false,
readingProgress: false,
commentBookmarks: false,
scoreThreshold: false,
collapseByDepth: false,
comfortMode: false,
timeGrouping: false,
inlinePreview: false
},
thresholds: {
minScore: 0,
minComments: 0,
gutterClickPx: 15,
autoCollapseDepth: 5,
highScoreThreshold: 100,
lowScoreThreshold: -5
},
display: {
maxContentWidth: 900,
fontSize: 16,
commentLineHeight: 1.6,
newCommentColor: "#ffffcc"
},
sites: {
hackerweb: {
enabled: true,
features: {}
},
hn: {
enabled: true,
features: {}
}
}
};
const CONFIG_VERSION = 2;
const LOG_PREFIX$2 = "[HWT Migrate]";
const LEGACY_COLLAPSE_KEY = "hwc-collapsed";
const NEW_COLLAPSE_KEY = "hwt:state:collapse";
function migrateLegacyCollapseState() {
try {
const legacy = localStorage.getItem(LEGACY_COLLAPSE_KEY);
if (!legacy) return;
const existing = localStorage.getItem(NEW_COLLAPSE_KEY);
if (existing) {
localStorage.removeItem(LEGACY_COLLAPSE_KEY);
console.debug(LOG_PREFIX$2, "Cleaned up legacy collapse data");
return;
}
const parsed = JSON.parse(legacy);
if (!Array.isArray(parsed)) {
console.warn(LOG_PREFIX$2, "Legacy collapse data has invalid format");
localStorage.removeItem(LEGACY_COLLAPSE_KEY);
return;
}
const validIds = parsed.filter(
(id) => typeof id === "string" && /^\d+$/.test(id)
);
localStorage.setItem(NEW_COLLAPSE_KEY, JSON.stringify(validIds));
localStorage.removeItem(LEGACY_COLLAPSE_KEY);
console.debug(
LOG_PREFIX$2,
`Migrated ${validIds.length} collapsed comments to new format`
);
} catch (error) {
console.warn(LOG_PREFIX$2, "Failed to migrate legacy collapse data:", error);
}
}
function migrateConfig(stored) {
let { version: version2 } = stored;
const { config } = stored;
while (version2 < CONFIG_VERSION) {
console.debug(LOG_PREFIX$2, `Migrating config from v${version2}`);
switch (version2) {
case 0:
break;
case 1: {
const display = config.display;
if (display) {
if (typeof display["maxContentWidth"] === "string") {
display["maxContentWidth"] = parseInt(display["maxContentWidth"], 10) || 900;
}
if (typeof display["commentLineHeight"] === "string") {
display["commentLineHeight"] = parseFloat(display["commentLineHeight"]) || 1.6;
}
}
break;
}
default:
console.warn(
LOG_PREFIX$2,
`Unknown config version ${version2}, resetting to defaults`
);
return { version: CONFIG_VERSION, config: {} };
}
version2++;
}
return { version: version2, config };
}
function runMigrations() {
migrateLegacyCollapseState();
}
const STORAGE_KEY = "hwt:config";
const LOG_PREFIX$1 = "[HWT Config]";
function deepMerge(target, source) {
const result = { ...target };
for (const key in source) {
const sourceValue = source[key];
const targetValue = target[key];
if (sourceValue !== void 0 && typeof sourceValue === "object" && sourceValue !== null && !Array.isArray(sourceValue) && typeof targetValue === "object" && targetValue !== null && !Array.isArray(targetValue)) {
result[key] = deepMerge(
targetValue,
sourceValue
);
} else if (sourceValue !== void 0) {
result[key] = sourceValue;
}
}
return result;
}
function deepClone(obj) {
return JSON.parse(JSON.stringify(obj));
}
class ConfigStore {
config;
overrides;
listeners = new Map();
nestedListeners = new Set();
constructor() {
const stored = this.load();
this.overrides = stored?.config ?? {};
this.config = deepMerge(deepClone(DEFAULT_CONFIG), this.overrides);
}
load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (parsed.version < CONFIG_VERSION) {
const migrated = migrateConfig(parsed);
this.save(migrated.config);
return migrated;
}
return parsed;
} catch (error) {
console.warn(LOG_PREFIX$1, "Failed to load config:", error);
return null;
}
}
save(overrides) {
try {
const stored = {
version: CONFIG_VERSION,
config: overrides
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
} catch (error) {
console.warn(LOG_PREFIX$1, "Failed to save config:", error);
}
}
getAll() {
return deepClone(this.config);
}
getSection(section) {
return deepClone(this.config[section]);
}
get(section, key) {
return this.config[section][key];
}
set(section, key, value) {
const oldValue = this.config[section][key];
if (oldValue === value) return;
if (!this.overrides[section]) {
this.overrides[section] = {};
}
this.overrides[section][key] = value;
this.config[section][key] = value;
this.save(this.overrides);
this.notifyListeners(section, key, value, oldValue);
}
reset(section, key) {
const oldValue = this.config[section][key];
const defaultValue = DEFAULT_CONFIG[section][key];
if (oldValue === defaultValue) return;
if (this.overrides[section]) {
const sectionOverrides = this.overrides[section];
Reflect.deleteProperty(sectionOverrides, key);
if (Object.keys(sectionOverrides).length === 0) {
Reflect.deleteProperty(this.overrides, section);
}
}
const sectionConfig = this.config[section];
sectionConfig[key] = defaultValue;
this.save(this.overrides);
this.notifyListeners(section, key, defaultValue, oldValue);
}
resetAll() {
const oldConfig = this.config;
this.overrides = {};
this.config = deepClone(DEFAULT_CONFIG);
this.save({});
this.notifyResetListeners(oldConfig);
}
notifyResetListeners(oldConfig) {
for (const listener of this.nestedListeners) {
const section = listener.section;
const key = listener.key;
const oldSection = oldConfig[section];
const newSection = this.config[section];
const oldValue = oldSection[key];
const newValue = newSection[key];
if (oldValue !== newValue) {
listener.callback(newValue, oldValue);
}
}
for (const [section, sectionListeners] of this.listeners) {
const sectionValue = this.getSection(section);
for (const callback of sectionListeners) {
callback(sectionValue, sectionValue);
}
}
}
subscribe(section, key, callback) {
const listener = {
section,
key,
callback
};
this.nestedListeners.add(listener);
return () => {
this.nestedListeners.delete(listener);
};
}
subscribeSection(section, callback) {
const key = section;
const listeners = this.listeners.get(key) ?? new Set();
listeners.add(callback);
this.listeners.set(key, listeners);
return () => {
listeners.delete(callback);
};
}
notifyListeners(section, key, newValue, oldValue) {
for (const listener of this.nestedListeners) {
if (listener.section === section && listener.key === key) {
try {
listener.callback(newValue, oldValue);
} catch (error) {
console.error(LOG_PREFIX$1, "Error in config listener:", error);
}
}
}
const sectionListeners = this.listeners.get(section);
if (sectionListeners) {
const sectionValue = this.getSection(section);
for (const callback of sectionListeners) {
try {
callback(sectionValue, sectionValue);
} catch (error) {
console.error(LOG_PREFIX$1, "Error in section listener:", error);
}
}
}
}
export() {
return JSON.stringify(this.overrides, null, 2);
}
isValidConfig(obj) {
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
return false;
}
const validSections = new Set(Object.keys(DEFAULT_CONFIG));
for (const key of Object.keys(obj)) {
if (!validSections.has(key)) return false;
const section = obj[key];
if (typeof section !== "object" || section === null) return false;
}
return true;
}
import(json) {
try {
const parsed = JSON.parse(json);
if (!this.isValidConfig(parsed)) {
console.error(LOG_PREFIX$1, "Invalid config structure");
return false;
}
const oldConfig = this.config;
this.overrides = parsed;
this.config = deepMerge(deepClone(DEFAULT_CONFIG), this.overrides);
this.save(this.overrides);
this.notifyResetListeners(oldConfig);
return true;
} catch (error) {
console.error(LOG_PREFIX$1, "Failed to import config:", error);
return false;
}
}
}
let instance = null;
function getConfigStore() {
instance ??= new ConfigStore();
return instance;
}
const STATE_PREFIX = "hwt:state:";
const LOG_PREFIX = "[HWT State]";
class FeatureState {
key;
cache = null;
defaultValue;
validator;
constructor(feature, defaultValue, validator) {
this.key = STATE_PREFIX + feature;
this.defaultValue = defaultValue;
this.validator = validator;
}
formatError(error) {
return error instanceof Error ? error.message : String(error);
}
load() {
if (this.cache !== null) return this.cache;
try {
const raw = localStorage.getItem(this.key);
if (!raw) {
this.cache = this.defaultValue;
return this.cache;
}
const parsed = JSON.parse(raw);
if (this.validator) {
if (this.validator(parsed)) {
this.cache = parsed;
} else {
console.warn(
LOG_PREFIX,
`Invalid state for ${this.key}, using default`
);
this.cache = this.defaultValue;
}
} else {
this.cache = parsed;
}
return this.cache;
} catch (error) {
console.warn(
LOG_PREFIX,
`Failed to load state for ${this.key}:`,
this.formatError(error)
);
this.cache = this.defaultValue;
return this.cache;
}
}
save(value) {
this.cache = value;
try {
localStorage.setItem(this.key, JSON.stringify(value));
} catch (error) {
console.warn(
LOG_PREFIX,
`Failed to save state for ${this.key}:`,
this.formatError(error)
);
}
}
update(updater) {
const current = this.load();
const updated = updater(current);
this.save(updated);
}
clear() {
this.cache = null;
try {
localStorage.removeItem(this.key);
} catch (error) {
console.warn(
LOG_PREFIX,
`Failed to clear state for ${this.key}:`,
this.formatError(error)
);
}
}
resetCache() {
this.cache = null;
}
}
class SetState {
state;
setCache = null;
constructor(feature) {
this.state = new FeatureState(
feature,
[],
(v) => Array.isArray(v) && v.every((i) => typeof i === "string")
);
}
getSet() {
if (this.setCache) return this.setCache;
this.setCache = new Set(this.state.load());
return this.setCache;
}
has(item) {
return this.getSet().has(item);
}
add(item) {
const set = this.getSet();
if (set.has(item)) return;
set.add(item);
this.state.save([...set]);
}
delete(item) {
const set = this.getSet();
if (!set.has(item)) return;
set.delete(item);
this.state.save([...set]);
}
toggle(item) {
const set = this.getSet();
if (set.has(item)) {
set.delete(item);
this.state.save([...set]);
return false;
} else {
set.add(item);
this.state.save([...set]);
return true;
}
}
getAll() {
return [...this.getSet()];
}
clear() {
this.setCache = null;
this.state.clear();
}
resetCache() {
this.setCache = null;
this.state.resetCache();
}
}
class MapState {
state;
mapCache = null;
constructor(feature, valueValidator) {
this.state = new FeatureState(
feature,
{},
(v) => {
if (typeof v !== "object" || v === null || Array.isArray(v))
return false;
if (!valueValidator) return true;
return Object.values(v).every((val) => valueValidator(val));
}
);
}
getMap() {
if (this.mapCache) return this.mapCache;
const obj = this.state.load();
this.mapCache = new Map(Object.entries(obj));
return this.mapCache;
}
get(key) {
return this.getMap().get(key);
}
set(key, value) {
const map = this.getMap();
map.set(key, value);
this.state.save(Object.fromEntries(map));
}
delete(key) {
const map = this.getMap();
if (!map.has(key)) return;
map.delete(key);
this.state.save(Object.fromEntries(map));
}
has(key) {
return this.getMap().has(key);
}
entries() {
return [...this.getMap().entries()];
}
clear() {
this.mapCache = null;
this.state.clear();
}
resetCache() {
this.mapCache = null;
this.state.resetCache();
}
}
function isFeatureEnabled(feature, site) {
const store = getConfigStore();
if (site) {
const siteConfig = store.get("sites", site);
if (!siteConfig.enabled) return false;
if (feature in siteConfig.features) {
return siteConfig.features[feature] ?? false;
}
}
return store.get("features", feature);
}
function getThreshold(key) {
return getConfigStore().get("thresholds", key);
}
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 = `
/* Display settings driven by CSS custom properties */
.view {
max-width: var(--hwt-max-width) !important;
}
section li {
font-size: var(--hwt-font-size) !important;
line-height: var(--hwt-line-height) !important;
}
/* More breathing room between comments */
section li {
margin-bottom: 12px !important;
}
/* Username and timestamp on same row */
section li > p.metadata {
display: flex !important;
align-items: baseline !important;
gap: 8px !important;
}
section li > p.metadata time {
margin-left: auto !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 syncDisplaySettings() {
const store = getConfigStore();
const root = document.documentElement;
root.style.setProperty(
"--hwt-max-width",
`${store.get("display", "maxContentWidth")}px`
);
root.style.setProperty(
"--hwt-font-size",
`${store.get("display", "fontSize")}px`
);
root.style.setProperty(
"--hwt-line-height",
String(store.get("display", "commentLineHeight"))
);
}
function injectStyles$f() {
inject$1(STYLES$1);
syncDisplaySettings();
const store = getConfigStore();
store.subscribe("display", "maxContentWidth", syncDisplaySettings);
store.subscribe("display", "fontSize", syncDisplaySettings);
store.subscribe("display", "commentLineHeight", syncDisplaySettings);
}
function qs(sel, root = document) {
return root.querySelector(sel);
}
function qsa(sel, root = document) {
return root.querySelectorAll(sel);
}
function getEventTargetElement(event) {
return event.target instanceof Element ? event.target : null;
}
function setDataBool(el, key, value) {
el.dataset[key] = String(value);
}
function getDataBool(el, key) {
return el?.dataset[key] === "true";
}
function asCommentId(id) {
return id && /^\d+$/.test(id) ? id : null;
}
const collapseState = new SetState("collapse");
function getCollapsedState(commentId) {
return collapseState.has(commentId);
}
function setCollapsedState(commentId, collapsed) {
if (collapsed) {
collapseState.add(commentId);
} else {
collapseState.delete(commentId);
}
}
const SEL$a = {
comment: "section li",
replies: ":scope > ul",
child: ":scope > li",
toggle: ":scope > button.hwc-toggle",
originalToggle: ":scope > button.comments-toggle:not(.hwc-toggle)",
anyToggle: "button.hwc-toggle"
};
const MAX_RECURSION_DEPTH = 100;
let highlightedElements = [];
function getReplies(li) {
return qs(SEL$a.replies, li);
}
function countReplies(ul, depth = 0) {
if (!ul || depth > MAX_RECURSION_DEPTH) return 0;
let count = 0;
for (const child of qsa(SEL$a.child, ul)) {
count += 1 + countReplies(getReplies(child), depth + 1);
}
return count;
}
function findRoot(li) {
let current = li;
while (current.parentElement?.closest("li") instanceof HTMLLIElement) {
current = current.parentElement.closest("li");
}
return current;
}
function getCommentId$1(li) {
const href = li.querySelector('p.metadata time a[href*="item?id="]')?.getAttribute("href");
const match = href?.match(/item\?id=(\d+)/);
return asCommentId(match?.[1]) ?? asCommentId(li.dataset["id"]) ?? asCommentId(li.id);
}
function setCollapsed(li, collapsed) {
const ul = getReplies(li);
const btn = qs(SEL$a.toggle, 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" : "Collapse"} ${count} replies`
);
const id = getCommentId$1(li);
if (id) setCollapsedState(id, collapsed);
}
function collapseThread(root) {
for (const btn of qsa(SEL$a.anyToggle, root)) {
if (getDataBool(btn, "collapsed")) continue;
const li = btn.closest("li");
if (li instanceof HTMLLIElement) setCollapsed(li, true);
}
}
function createToggle$1(ul) {
if (!ul.children.length) return null;
const count = countReplies(ul);
const collapsed = getComputedStyle(ul).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.title = "Click to toggle, Shift+click to collapse thread";
btn.setAttribute("aria-expanded", String(!collapsed));
btn.setAttribute(
"aria-label",
`${collapsed ? "Expand" : "Collapse"} ${count} replies`
);
setDataBool(btn, "collapsed", collapsed);
return btn;
}
function injectButtons() {
for (const li of qsa(SEL$a.comment)) {
const ul = getReplies(li);
if (!ul?.children.length) continue;
if (qs(SEL$a.toggle, li)) continue;
qs(SEL$a.originalToggle, li)?.remove();
const btn = createToggle$1(ul);
if (btn) li.insertBefore(btn, ul);
const id = getCommentId$1(li);
if (id && getCollapsedState(id)) setCollapsed(li, true);
}
}
function highlightAncestors(start) {
for (const el of highlightedElements) {
el.classList.remove("hwc-hl");
}
highlightedElements = [];
for (let li = start; li instanceof HTMLLIElement; li = li.parentElement?.closest("li") ?? null) {
li.classList.add("hwc-hl");
highlightedElements.push(li);
}
}
function onToggleClick(e, btn) {
e.stopPropagation();
e.preventDefault();
const li = btn.closest("li");
if (!(li instanceof HTMLLIElement)) return;
if (e.shiftKey) {
collapseThread(findRoot(li));
} else {
setCollapsed(li, !getDataBool(btn, "collapsed"));
}
}
function onGutterClick(e, li) {
const clickX = e.clientX - li.getBoundingClientRect().left;
if (clickX > getThreshold("gutterClickPx")) return;
const btn = qs(SEL$a.toggle, li);
if (!btn) return;
e.preventDefault();
setCollapsed(li, !getDataBool(btn, "collapsed"));
}
function setupEventListeners() {
document.addEventListener("mouseover", (e) => {
const target = getEventTargetElement(e);
const li = target?.closest(SEL$a.comment);
if (li instanceof HTMLLIElement) highlightAncestors(li);
});
document.addEventListener("click", (e) => {
const target = getEventTargetElement(e);
if (!target) return;
const btn = target.closest("button.hwc-toggle");
if (btn instanceof HTMLButtonElement) {
onToggleClick(e, btn);
return;
}
const li = target.closest(SEL$a.comment);
if (li instanceof HTMLLIElement) onGutterClick(e, li);
});
}
let ready$1 = false;
function initCollapse() {
if (!isFeatureEnabled("collapse", "hackerweb")) return;
if (!ready$1) {
injectStyles$f();
setupEventListeners();
ready$1 = true;
}
injectButtons();
}
const CSS$d = `
/* OP Badge - highlights original poster in comment threads */
.hwt-op-badge {
display: inline-block;
background: #ff6600;
color: white;
font-size: 10px;
font-weight: bold;
padding: 1px 4px;
border-radius: 2px;
margin-left: 4px;
vertical-align: middle;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Subtle highlight for OP's comments */
li[data-is-op="true"] > p:first-of-type {
border-left: 2px solid #ff6600;
padding-left: 8px;
margin-left: -10px;
}
`;
const SEL$9 = {
storyHeader: "header .story",
authorLink: 'a[href^="/user/"]',
comments: "section li",
commentAuthor: 'p.metadata a[href^="/user/"]'
};
const OP_BADGE_CLASS = "hwt-op-badge";
const OP_DATA_ATTR = "data-is-op";
function getStoryAuthor() {
const storyHeader = qs(SEL$9.storyHeader);
if (!storyHeader) return null;
const authorLink = qs(SEL$9.authorLink, storyHeader);
if (!authorLink) return null;
const href = authorLink.getAttribute("href");
const match = href?.match(/^\/user\/(.+)$/);
return match?.[1] ?? null;
}
function hasBadge(comment) {
return !!qs(`.${OP_BADGE_CLASS}`, comment);
}
function addBadge(authorLink) {
const badge = document.createElement("span");
badge.className = OP_BADGE_CLASS;
badge.textContent = "OP";
badge.title = "Original Poster";
authorLink.insertAdjacentElement("afterend", badge);
}
function injectOpBadges() {
const storyAuthor = getStoryAuthor();
if (!storyAuthor) return;
for (const comment of qsa(SEL$9.comments)) {
if (hasBadge(comment)) continue;
const authorLink = qs(SEL$9.commentAuthor, comment);
if (!authorLink) continue;
const href = authorLink.getAttribute("href");
const match = href?.match(/^\/user\/(.+)$/);
const commentAuthor = match?.[1];
if (commentAuthor === storyAuthor) {
addBadge(authorLink);
comment.setAttribute(OP_DATA_ATTR, "true");
}
}
}
const injectStyles$e = createStyleInjector("hwt-op-badge-styles");
function initOpBadge() {
if (!isFeatureEnabled("opBadge", "hackerweb")) return;
injectStyles$e(CSS$d);
injectOpBadges();
}
const CSS$c = `
/* Deep Link - copy link to comment on click */
.hwt-deep-link {
cursor: pointer;
text-decoration: none;
}
.hwt-deep-link:hover {
text-decoration: underline;
}
/* Toast notification for copy confirmation */
.hwt-toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: #333;
color: white;
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
z-index: 10000;
animation: hwt-toast-fade 2s ease-in-out forwards;
}
@keyframes hwt-toast-fade {
0% { opacity: 0; transform: translateX(-50%) translateY(10px); }
10% { opacity: 1; transform: translateX(-50%) translateY(0); }
90% { opacity: 1; }
100% { opacity: 0; }
}
`;
const SEL$8 = {
comments: "section li",
timeLink: 'p.metadata time a[href*="item?id="]'
};
const DEEP_LINK_CLASS = "hwt-deep-link";
const TOAST_CLASS = "hwt-toast";
function getItemId(link) {
const href = link.getAttribute("href");
const match = href?.match(/item\?id=(\d+)/);
return match?.[1] ?? null;
}
function showToast(message) {
qs(`.${TOAST_CLASS}`)?.remove();
const toast = document.createElement("div");
toast.className = TOAST_CLASS;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 2e3);
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
async function handleTimeLinkClick(e, link) {
const itemId = getItemId(link);
if (!itemId) return;
const hnUrl = `https://news.ycombinator.com/item?id=${itemId}`;
e.preventDefault();
e.stopPropagation();
const success = await copyToClipboard(hnUrl);
showToast(success ? "Link copied!" : "Failed to copy link");
}
function injectDeepLinks() {
for (const comment of qsa(SEL$8.comments)) {
const timeLink = qs(SEL$8.timeLink, comment);
if (!timeLink) continue;
if (timeLink.classList.contains(DEEP_LINK_CLASS)) continue;
timeLink.classList.add(DEEP_LINK_CLASS);
timeLink.title = "Click to copy link";
}
}
function setupDeepLinkHandler() {
document.addEventListener("click", (e) => {
const target = getEventTargetElement(e);
if (!target) return;
const link = target.closest(`a.${DEEP_LINK_CLASS}`);
if (link instanceof HTMLAnchorElement) {
void handleTimeLinkClick(e, link);
}
});
}
const injectStyles$d = createStyleInjector("hwt-deep-link-styles");
let handlerInitialized$1 = false;
function initDeepLink() {
if (!isFeatureEnabled("deepLink", "hackerweb")) return;
injectStyles$d(CSS$c);
injectDeepLinks();
if (!handlerInitialized$1) {
setupDeepLinkHandler();
handlerInitialized$1 = true;
}
}
const CSS$b = `
/* New Comments - highlight comments since last visit */
/* New comment indicator */
li[data-is-new="true"] {
position: relative;
}
li[data-is-new="true"]::before {
content: "";
position: absolute;
left: -2px;
top: 0;
bottom: 0;
width: 3px;
background: #ff6600;
border-radius: 1px;
}
/* New comment background highlight (subtle) */
li[data-is-new="true"] > p:first-of-type {
background: rgba(255, 102, 0, 0.05);
margin-left: -8px;
padding-left: 8px;
margin-right: -8px;
padding-right: 8px;
border-radius: 2px;
}
/* "X new comments" indicator in header */
.hwt-new-count {
display: inline-block;
background: #ff6600;
color: white;
font-size: 11px;
font-weight: bold;
padding: 2px 6px;
border-radius: 10px;
margin-left: 8px;
vertical-align: middle;
}
`;
const SEL$7 = {
comments: "section li",
timeElement: "p.metadata time",
header: "header .story",
storyLink: 'header .story a[href*="item?id="]'
};
const NEW_ATTR = "data-is-new";
const NEW_COUNT_CLASS = "hwt-new-count";
const lastVisitState = new MapState(
"lastVisit",
(v) => typeof v === "number"
);
function getStoryId$1() {
const storyLink = qs(SEL$7.storyLink);
if (!storyLink) return null;
const href = storyLink.getAttribute("href");
const match = href?.match(/item\?id=(\d+)/);
return match?.[1] ?? null;
}
function parseRelativeTime(timeText) {
const now = Date.now();
const text = timeText.toLowerCase().trim();
const match = /^(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago$/.exec(text);
if (!match?.[1] || !match[2]) return null;
const value = parseInt(match[1], 10);
const unit = match[2];
const multipliers = {
second: 1e3,
minute: 60 * 1e3,
hour: 60 * 60 * 1e3,
day: 24 * 60 * 60 * 1e3,
week: 7 * 24 * 60 * 60 * 1e3,
month: 30 * 24 * 60 * 60 * 1e3,
year: 365 * 24 * 60 * 60 * 1e3
};
const multiplier = multipliers[unit];
if (!multiplier) return null;
return now - value * multiplier;
}
function getCommentTime(li) {
const timeEl = qs(SEL$7.timeElement, li);
if (!timeEl) return null;
const timeText = timeEl.textContent;
if (!timeText) return null;
return parseRelativeTime(timeText);
}
function markNewComments() {
const storyId = getStoryId$1();
if (!storyId) return 0;
const lastVisit = lastVisitState.get(storyId);
const now = Date.now();
lastVisitState.set(storyId, now);
if (!lastVisit) return 0;
let newCount = 0;
for (const li of qsa(SEL$7.comments)) {
if (li.hasAttribute(NEW_ATTR)) continue;
const commentTime = getCommentTime(li);
if (!commentTime) continue;
const isNew = commentTime > lastVisit;
li.setAttribute(NEW_ATTR, String(isNew));
if (isNew) newCount++;
}
return newCount;
}
function injectNewCountBadge(count) {
if (count === 0) return;
const header = qs(SEL$7.header);
if (!header) return;
qs(`.${NEW_COUNT_CLASS}`, header)?.remove();
const badge = document.createElement("span");
badge.className = NEW_COUNT_CLASS;
badge.textContent = `${count} new`;
badge.title = `${count} new comment${count === 1 ? "" : "s"} since your last visit`;
header.appendChild(badge);
}
const injectStyles$c = createStyleInjector("hwt-new-comments-styles");
function initNewComments() {
if (!isFeatureEnabled("newCommentHighlight", "hackerweb")) return;
injectStyles$c(CSS$b);
const newCount = markNewComments();
injectNewCountBadge(newCount);
}
const CSS$a = `
/* Keyboard Navigation - highlight focused comment */
/* Focus indicator for keyboard navigation */
li.hwt-kb-focus {
outline: 2px solid #ff6600;
outline-offset: 2px;
border-radius: 4px;
}
/* Ensure focused comment is visible */
li.hwt-kb-focus > p:first-of-type {
background: rgba(255, 102, 0, 0.1);
}
/* Keyboard help overlay */
.hwt-kb-help {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 16px 20px;
border-radius: 8px;
font-family: monospace;
font-size: 13px;
z-index: 10000;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.hwt-kb-help h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: #ff6600;
}
.hwt-kb-help table {
border-collapse: collapse;
}
.hwt-kb-help td {
padding: 2px 0;
}
.hwt-kb-help kbd {
display: inline-block;
background: #333;
border: 1px solid #555;
border-radius: 3px;
padding: 2px 6px;
margin-right: 8px;
min-width: 20px;
text-align: center;
}
`;
function scrollToElement(element, offset = 0, behavior = "smooth") {
const rect = element.getBoundingClientRect();
const top = rect.top + window.scrollY - offset;
window.scrollTo({
top,
behavior
});
}
function isElementInViewport(element) {
const rect = element.getBoundingClientRect();
return rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth);
}
const SEL$6 = {
comments: "section li",
toggle: ":scope > button.hwc-toggle"
};
const FOCUS_CLASS$1 = "hwt-kb-focus";
const HELP_CLASS$1 = "hwt-kb-help";
const GG_SEQUENCE_TIMEOUT_MS = 500;
let focusedIndex$1 = -1;
let helpVisible$1 = false;
function getComments() {
return Array.from(qsa(SEL$6.comments));
}
function getFocused$1() {
return qs(`.${FOCUS_CLASS$1}`);
}
function setFocus$1(index) {
const comments = getComments();
if (comments.length === 0) return;
index = Math.max(0, Math.min(index, comments.length - 1));
getFocused$1()?.classList.remove(FOCUS_CLASS$1);
const comment = comments[index];
if (!comment) return;
comment.classList.add(FOCUS_CLASS$1);
focusedIndex$1 = index;
if (!isElementInViewport(comment)) {
scrollToElement(comment, 100, "smooth");
}
}
function focusNext$1() {
const comments = getComments();
if (focusedIndex$1 < 0) {
setFocus$1(0);
} else {
setFocus$1(Math.min(focusedIndex$1 + 1, comments.length - 1));
}
}
function focusPrev$1() {
if (focusedIndex$1 < 0) {
setFocus$1(0);
} else {
setFocus$1(Math.max(focusedIndex$1 - 1, 0));
}
}
function focusParent() {
const focused = getFocused$1();
if (!focused) return;
const parent = focused.parentElement?.closest("li");
if (parent instanceof HTMLLIElement) {
const comments = getComments();
const parentIndex = comments.indexOf(parent);
if (parentIndex >= 0) {
setFocus$1(parentIndex);
}
}
}
function focusChild() {
const focused = getFocused$1();
if (!focused) return;
const child = qs("ul > li", focused);
if (child) {
const comments = getComments();
const childIndex = comments.indexOf(child);
if (childIndex >= 0) {
setFocus$1(childIndex);
}
}
}
function toggleFocused() {
const focused = getFocused$1();
if (!focused) return;
const toggle = qs(SEL$6.toggle, focused);
toggle?.click();
}
function focusFirst() {
setFocus$1(0);
}
function focusLast() {
const comments = getComments();
setFocus$1(comments.length - 1);
}
function toggleHelp$1() {
helpVisible$1 = !helpVisible$1;
let help = qs(`.${HELP_CLASS$1}`);
if (helpVisible$1) {
if (!help) {
help = document.createElement("div");
help.className = HELP_CLASS$1;
help.innerHTML = `
<h4>Keyboard Shortcuts</h4>
<table>
<tr><td><kbd>j</kbd></td><td>Next comment</td></tr>
<tr><td><kbd>k</kbd></td><td>Previous comment</td></tr>
<tr><td><kbd>l</kbd></td><td>First child</td></tr>
<tr><td><kbd>h</kbd></td><td>Parent comment</td></tr>
<tr><td><kbd>o</kbd></td><td>Toggle collapse</td></tr>
<tr><td><kbd>g g</kbd></td><td>First comment</td></tr>
<tr><td><kbd>G</kbd></td><td>Last comment</td></tr>
<tr><td><kbd>?</kbd></td><td>Toggle help</td></tr>
</table>
`;
document.body.appendChild(help);
}
} else {
help?.remove();
}
}
function registerKeyboardShortcuts$2() {
const km = getKeyboardManager();
km.init();
const unsubscribers = [
km.register("j", focusNext$1, {
description: "Next comment",
scope: "hackerweb"
}),
km.register("k", focusPrev$1, {
description: "Previous comment",
scope: "hackerweb"
}),
km.register("h", focusParent, {
description: "Parent comment",
scope: "hackerweb"
}),
km.register("l", focusChild, {
description: "First child comment",
scope: "hackerweb"
}),
km.register("o", toggleFocused, {
description: "Toggle collapse",
scope: "hackerweb"
}),
km.register("shift+g", focusLast, {
description: "Last comment",
scope: "hackerweb"
}),
km.register("?", toggleHelp$1, {
description: "Toggle help",
scope: "hackerweb"
})
];
let gPressed = false;
let gTimeout = null;
const unregisterG = km.register(
"g",
() => {
if (gPressed) {
focusFirst();
gPressed = false;
if (gTimeout) clearTimeout(gTimeout);
} else {
gPressed = true;
gTimeout = setTimeout(() => {
gPressed = false;
}, GG_SEQUENCE_TIMEOUT_MS);
}
},
{
description: "First comment (press twice)",
scope: "hackerweb"
}
);
unsubscribers.push(unregisterG);
return () => {
if (gTimeout) clearTimeout(gTimeout);
unsubscribers.forEach((unsub) => unsub());
qs(`.${HELP_CLASS$1}`)?.remove();
getFocused$1()?.classList.remove(FOCUS_CLASS$1);
};
}
const injectStyles$b = createStyleInjector("hwt-keyboard-nav-styles");
let cleanup$3 = null;
function initKeyboardNav$1() {
if (!isFeatureEnabled("keyboardNav", "hackerweb")) {
if (cleanup$3) {
cleanup$3();
cleanup$3 = null;
}
return;
}
if (cleanup$3) return;
injectStyles$b(CSS$a);
cleanup$3 = registerKeyboardShortcuts$2();
}
const CSS$9 = `
/* Collapse by Depth - auto-collapsed indicator */
/* Indicator for auto-collapsed comments */
li[data-auto-collapsed="true"] > button.hwc-toggle::after {
content: " (auto)";
font-size: 10px;
color: #828282;
font-style: italic;
}
`;
const SEL$5 = {
comments: "section li",
toggle: ":scope > button.hwc-toggle",
replies: ":scope > ul"
};
const AUTO_COLLAPSED_ATTR = "data-auto-collapsed";
function getCommentDepth(li) {
let depth = 0;
let parent = li.parentElement?.closest("li");
while (parent) {
depth++;
parent = parent.parentElement?.closest("li");
}
return depth;
}
function collapseByDepth() {
const threshold = getThreshold("autoCollapseDepth");
for (const li of qsa(SEL$5.comments)) {
if (li.hasAttribute(AUTO_COLLAPSED_ATTR)) continue;
const depth = getCommentDepth(li);
if (depth >= threshold) {
const toggle = qs(SEL$5.toggle, li);
const replies = qs(SEL$5.replies, li);
if (toggle && replies) {
const isCollapsed = toggle.dataset["collapsed"] === "true";
if (!isCollapsed) {
toggle.click();
li.setAttribute(AUTO_COLLAPSED_ATTR, "true");
}
}
}
li.setAttribute(AUTO_COLLAPSED_ATTR, depth >= threshold ? "true" : "false");
}
}
const injectStyles$a = createStyleInjector("hwt-collapse-depth-styles");
function initCollapseDepth() {
if (!isFeatureEnabled("collapseByDepth", "hackerweb")) return;
injectStyles$a(CSS$9);
collapseByDepth();
}
function prefersDarkMode() {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
function syncThemeClass(darkClass, lightClass) {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const apply = (isDark) => {
if (isDark) {
document.documentElement.classList.add(darkClass);
} else {
document.documentElement.classList.remove(darkClass);
}
};
apply(prefersDarkMode());
const handler = (e) => apply(e.matches);
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}
const CSS_HACKERWEB = `
/* Dark Mode - HackerWeb */
html.hwt-dark {
filter: invert(1) hue-rotate(180deg);
}
/* Don't invert images and videos */
html.hwt-dark img,
html.hwt-dark video,
html.hwt-dark iframe {
filter: invert(1) hue-rotate(180deg);
}
`;
const CSS_HN = `
/* Dark Mode - Hacker News */
/* Base */
html.hwt-dark {
background-color: #1b1b1d;
color-scheme: dark;
}
html.hwt-dark body {
background-color: #1b1b1d;
}
/* Main content area */
html.hwt-dark #hnmain {
background-color: #27272a;
}
/* Header - keep iconic orange */
html.hwt-dark #hnmain > tbody > tr:first-child td,
html.hwt-dark table[bgcolor="#ff6600"] {
background-color: #ff6600 !important;
}
html.hwt-dark #hnmain > tbody > tr:first-child a,
html.hwt-dark td.pagetop a,
html.hwt-dark .pagetop,
html.hwt-dark span.pagetop b a {
color: #000;
}
/* Titles */
html.hwt-dark .athing .titleline a {
color: #e4e4e7;
}
html.hwt-dark .athing .titleline a:visited {
color: #6e6e72;
}
/* Hostname / domain */
html.hwt-dark .titleline .sitebit a,
html.hwt-dark .titleline .sitestr,
html.hwt-dark .sitebit.comhead a {
color: #8b8b90;
}
html.hwt-dark .titleline .sitebit {
color: #8b8b90;
}
/* Meta / subtext */
html.hwt-dark .subtext,
html.hwt-dark .subtext a {
color: #747478;
}
html.hwt-dark .subtext a:hover {
color: #e4e4e7;
}
html.hwt-dark .score {
color: #ff8040;
}
html.hwt-dark .hnuser {
color: #9090a0;
}
/* Rank numbers */
html.hwt-dark .rank {
color: #606065;
}
/* Comments */
html.hwt-dark .comhead,
html.hwt-dark .comhead a {
color: #747478;
}
html.hwt-dark .comhead a:hover {
color: #e4e4e7;
}
html.hwt-dark .comment,
html.hwt-dark .commtext {
color: #c0c0c5;
}
html.hwt-dark .comment a {
color: #6caed8;
}
html.hwt-dark .comment a:visited {
color: #5a8aaa;
}
html.hwt-dark .reply a {
color: #747478;
}
html.hwt-dark .reply a:hover {
color: #e4e4e7;
}
/* Comment indent lines */
html.hwt-dark .ind img {
opacity: 0.15;
}
/* Vote arrows */
html.hwt-dark .votearrow {
filter: brightness(0.85);
}
/* Inputs */
html.hwt-dark input[type="text"],
html.hwt-dark input[type="password"],
html.hwt-dark textarea {
background-color: #35353a;
color: #e4e4e7;
border: 1px solid #48484d;
border-radius: 3px;
}
html.hwt-dark input[type="submit"] {
background-color: #35353a;
color: #e4e4e7;
border: 1px solid #48484d;
cursor: pointer;
}
/* Footer */
html.hwt-dark .yclinks,
html.hwt-dark .yclinks a {
color: #606065;
}
html.hwt-dark .yclinks a:hover {
color: #e4e4e7;
}
/* Visited stories */
html.hwt-dark tr.athing[data-visited="true"] .titleline a {
color: #505055;
}
/* Score threshold - titles stay uniform */
html.hwt-dark tr.athing[data-score-tier="high"] .titleline a {
color: #e4e4e7;
font-weight: inherit;
}
html.hwt-dark tr.athing[data-score-tier="high"] + tr .subtext .score {
color: #ff8040;
}
html.hwt-dark tr.athing[data-score-tier="low"] .titleline a {
color: inherit;
font-weight: inherit;
}
/* Domain badges - uniform in dark mode */
html.hwt-dark .titleline .sitestr {
background: rgba(255,255,255,0.06);
color: #8b8b90;
}
html.hwt-dark .titleline[data-site] .sitestr {
background: rgba(255,255,255,0.06);
color: #8b8b90;
}
/* Time grouping */
html.hwt-dark .hwt-time-group {
background: #30303a;
color: #747478;
border-left-color: #ff6600;
}
/* Keyboard nav */
html.hwt-dark tr.athing.hwt-kb-focus {
background: rgba(255, 102, 0, 0.12);
}
html.hwt-dark tr.athing.hwt-kb-focus .titleline a {
color: #ff9050;
}
html.hwt-dark tr.athing.hwt-kb-focus + tr {
background: rgba(255, 102, 0, 0.06);
}
/* Bookmarks */
html.hwt-dark .hwt-bookmarks-panel {
background: #27272a;
border-color: #3a3a3f;
}
html.hwt-dark .hwt-bookmark-item {
border-color: #3a3a3f;
}
html.hwt-dark .hwt-bookmark-item:hover {
background: #30303a;
}
html.hwt-dark .hwt-bookmark-item-text {
color: #e4e4e7;
}
/* Hide-read toggle */
html.hwt-dark .hwt-hide-read-toggle {
color: #747478;
}
html.hwt-dark .hwt-hide-read-toggle.active {
color: #ff6600;
}
/* Reading progress bar */
html.hwt-dark .hwt-progress-bar-fill {
background: linear-gradient(90deg, #ff6600, #ff8533);
}
`;
const DARK_CLASS = "hwt-dark";
const injectHackerwebStyles = createStyleInjector("hwt-dark-mode-hackerweb");
const injectHnStyles = createStyleInjector("hwt-dark-mode-hn");
let cleanup$2 = null;
function enableDarkMode(site) {
if (cleanup$2) return;
if (site === "hackerweb") {
injectHackerwebStyles(CSS_HACKERWEB);
} else {
injectHnStyles(CSS_HN);
}
cleanup$2 = syncThemeClass(DARK_CLASS);
}
function disableDarkMode() {
if (cleanup$2) {
cleanup$2();
cleanup$2 = null;
}
document.documentElement.classList.remove(DARK_CLASS);
}
function initDarkMode(site) {
if (isFeatureEnabled("darkModeSync", site)) {
enableDarkMode(site);
}
getConfigStore().subscribe("features", "darkModeSync", (enabled) => {
if (enabled) {
enableDarkMode(site);
} else {
disableDarkMode();
}
});
}
const CSS$8 = `
/* Reading Progress Bar */
.hwt-progress-bar {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: transparent;
z-index: 99999;
pointer-events: none;
}
.hwt-progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #ff6600, #ff9933);
width: 0%;
transition: width 0.1s ease-out;
}
`;
const PROGRESS_BAR_CLASS = "hwt-progress-bar";
const PROGRESS_FILL_CLASS = "hwt-progress-bar-fill";
let progressBar = null;
let progressFill = null;
let rafId = null;
function calculateProgress() {
const scrollTop = window.scrollY;
const docHeight = document.documentElement.scrollHeight;
const winHeight = window.innerHeight;
const scrollable = docHeight - winHeight;
if (scrollable <= 0) return 100;
const progress = scrollTop / scrollable * 100;
return Math.min(100, Math.max(0, progress));
}
function updateProgressBar() {
if (!progressFill) return;
const progress = calculateProgress();
progressFill.style.width = `${progress}%`;
}
function onScroll() {
if (rafId) return;
rafId = requestAnimationFrame(() => {
updateProgressBar();
rafId = null;
});
}
function createProgressBar() {
if (qs(`.${PROGRESS_BAR_CLASS}`)) return;
progressBar = document.createElement("div");
progressBar.className = PROGRESS_BAR_CLASS;
progressFill = document.createElement("div");
progressFill.className = PROGRESS_FILL_CLASS;
progressBar.appendChild(progressFill);
document.body.appendChild(progressBar);
updateProgressBar();
}
function setupScrollListener() {
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", updateProgressBar, { passive: true });
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", updateProgressBar);
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
};
}
function removeProgressBar() {
progressBar?.remove();
progressBar = null;
progressFill = null;
}
const injectStyles$9 = createStyleInjector("hwt-reading-progress-styles");
let cleanup$1 = null;
function initReadingProgress(site) {
if (!isFeatureEnabled("readingProgress", site)) {
if (cleanup$1) {
cleanup$1();
cleanup$1 = null;
removeProgressBar();
}
return;
}
if (cleanup$1) return;
injectStyles$9(CSS$8);
createProgressBar();
cleanup$1 = setupScrollListener();
}
const CSS$7 = `
/* Comment Bookmarks */
/* Bookmark button on each comment */
.hwt-bookmark-btn {
display: inline-block;
cursor: pointer;
font-size: 1em;
color: #828282;
margin-left: 8px;
opacity: 0.5;
transition: opacity 0.2s;
}
.hwt-bookmark-btn:hover {
opacity: 1;
}
.hwt-bookmark-btn.bookmarked {
color: #ff6600;
opacity: 1;
}
/* Bookmarked comment highlight */
li[data-bookmarked="true"],
tr.athing[data-bookmarked="true"] {
background: rgba(255, 102, 0, 0.05);
}
/* ================================================================
Bookmarks Panel
================================================================ */
.hwt-bookmarks-panel {
position: fixed;
right: 20px;
bottom: 80px;
width: 340px;
max-height: 480px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.12), 0 2px 10px rgba(0, 0, 0, 0.08);
z-index: 10000;
display: none;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
}
.hwt-bookmarks-panel.visible {
display: flex;
flex-direction: column;
}
/* Header */
.hwt-bookmarks-panel-header {
padding: 14px 16px;
border-bottom: 1px solid #eeecea;
font-weight: 600;
font-size: 14px;
color: #333;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.hwt-bookmarks-panel-close {
cursor: pointer;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: #828282;
font-size: 18px;
transition: background 0.15s;
}
.hwt-bookmarks-panel-close:hover {
background: rgba(0, 0, 0, 0.06);
}
/* Body */
.hwt-bookmarks-panel-body {
overflow-y: auto;
flex: 1;
min-height: 0;
}
/* Empty state */
.hwt-bookmarks-empty {
padding: 32px 16px;
text-align: center;
color: #828282;
font-size: 13px;
}
/* Bookmark item */
.hwt-bookmark-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px 10px 16px;
border-bottom: 1px solid #eeecea;
transition: background 0.15s;
}
.hwt-bookmark-item:hover {
background: rgba(0, 0, 0, 0.03);
}
.hwt-bookmark-item:last-child {
border-bottom: none;
}
.hwt-bookmark-item-content {
flex: 1;
min-width: 0;
text-decoration: none;
color: inherit;
}
/* Post title */
.hwt-bookmark-item-title {
font-size: 12px;
font-weight: 500;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Author · time */
.hwt-bookmark-item-meta {
font-size: 11px;
color: #828282;
margin-top: 2px;
}
/* Comment text preview */
.hwt-bookmark-item-text {
font-size: 12px;
color: #666;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-style: italic;
}
.hwt-bookmark-item-content:hover .hwt-bookmark-item-title {
color: #ff6600;
}
/* Remove button */
.hwt-bookmark-item-remove {
background: none;
border: none;
cursor: pointer;
color: #ccc;
font-size: 16px;
line-height: 1;
padding: 2px 4px;
border-radius: 4px;
flex-shrink: 0;
margin-top: 1px;
transition: color 0.15s;
}
.hwt-bookmark-item-remove:hover {
color: #c00;
}
/* Pagination */
.hwt-bookmarks-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 8px 16px;
border-top: 1px solid #eeecea;
}
.hwt-bookmarks-nav-btn {
background: none;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
color: #333;
font-size: 18px;
line-height: 1;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, color 0.15s;
}
.hwt-bookmarks-nav-btn:hover:not(:disabled) {
border-color: #ff6600;
color: #ff6600;
}
.hwt-bookmarks-nav-btn:disabled {
opacity: 0.3;
cursor: default;
}
.hwt-bookmarks-nav-indicator {
font-size: 12px;
color: #828282;
}
/* Toggle FAB */
.hwt-bookmarks-toggle {
position: fixed;
right: 20px;
bottom: 20px;
width: 48px;
height: 48px;
background: #ff6600;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.hwt-bookmarks-toggle:hover {
background: #ff7700;
}
.hwt-bookmarks-toggle-count {
position: absolute;
top: -4px;
right: -4px;
background: #c00;
color: white;
font-size: 10px;
font-weight: bold;
padding: 2px 6px;
border-radius: 10px;
}
/* ================================================================
Dark mode
================================================================ */
.hwt-dark .hwt-bookmarks-panel {
background: #1a1a1a;
}
.hwt-dark .hwt-bookmarks-panel-header {
color: #e0e0e0;
border-color: #333;
}
.hwt-dark .hwt-bookmarks-panel-close:hover {
background: rgba(255, 255, 255, 0.1);
}
.hwt-dark .hwt-bookmark-item {
border-color: #333;
}
.hwt-dark .hwt-bookmark-item:hover {
background: rgba(255, 255, 255, 0.05);
}
.hwt-dark .hwt-bookmark-item-title {
color: #e0e0e0;
}
.hwt-dark .hwt-bookmark-item-text {
color: #999;
}
.hwt-dark .hwt-bookmark-item-meta {
color: #888;
}
.hwt-dark .hwt-bookmark-item-remove {
color: #555;
}
.hwt-dark .hwt-bookmark-item-remove:hover {
color: #c00;
}
.hwt-dark .hwt-bookmarks-empty {
color: #888;
}
.hwt-dark .hwt-bookmarks-nav {
border-color: #333;
}
.hwt-dark .hwt-bookmarks-nav-btn {
border-color: #444;
color: #e0e0e0;
}
.hwt-dark .hwt-bookmarks-nav-btn:hover:not(:disabled) {
border-color: #ff6600;
color: #ff6600;
}
`;
const BOOKMARK_BTN_CLASS = "hwt-bookmark-btn";
const PANEL_CLASS = "hwt-bookmarks-panel";
const TOGGLE_CLASS$1 = "hwt-bookmarks-toggle";
const BOOKMARKED_ATTR = "data-bookmarked";
const bookmarkIds = new SetState("bookmarks");
const bookmarkData = new MapState(
"bookmarkData",
(v) => typeof v === "object" && v !== null && typeof v.id === "string" && typeof v.text === "string"
);
let panelVisible = false;
const PAGE_SIZE = 5;
let currentPage = 0;
const SEL_HACKERWEB = {
comments: "section li",
metadata: "p.metadata",
timeLink: 'p.metadata time a[href*="item?id="]',
content: ":scope > p:not(.metadata)",
author: "p.metadata .user",
pageTitle: "#view-comments header h1"
};
const SEL_HN = {
comments: ".comtr",
metadata: ".comhead",
timeLink: '.comhead a[href*="item?id="]',
content: ".commtext",
author: ".hnuser",
pageTitle: ".titleline > a"
};
function getCommentId(comment, site) {
const sel = site === "hackerweb" ? SEL_HACKERWEB : SEL_HN;
const timeLink = qs(sel.timeLink, comment);
if (!timeLink) return null;
const href = timeLink.getAttribute("href");
const match = href?.match(/item\?id=(\d+)/);
return match?.[1] ?? null;
}
function getCommentText(comment, site) {
const sel = site === "hackerweb" ? SEL_HACKERWEB : SEL_HN;
const content = qs(sel.content, comment);
const text = content?.textContent ?? "";
const trimmed = text.slice(0, 80).trim();
return trimmed + (text.trim().length > 80 ? "…" : "");
}
function getCommentAuthor(comment, site) {
const sel = site === "hackerweb" ? SEL_HACKERWEB : SEL_HN;
const author = qs(sel.author, comment);
return author?.textContent.trim() ?? "";
}
function getPageTitle(site) {
const sel = site === "hackerweb" ? SEL_HACKERWEB : SEL_HN;
return qs(sel.pageTitle)?.textContent.trim() ?? "";
}
function timeAgo(timestamp) {
const seconds = Math.floor((Date.now() - timestamp) / 1e3);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
function refreshPanel() {
if (!panelVisible) return;
const panel = qs(`.${PANEL_CLASS}`);
if (panel) updatePanelContent(panel);
}
function createBookmarkButton(isBookmarked) {
const btn = document.createElement("span");
btn.className = BOOKMARK_BTN_CLASS;
if (isBookmarked) btn.classList.add("bookmarked");
btn.textContent = isBookmarked ? "★" : "☆";
btn.title = isBookmarked ? "Remove bookmark" : "Bookmark this comment";
return btn;
}
function toggleBookmark(comment, id, site) {
const isBookmarked = bookmarkIds.toggle(id);
const btn = qs(`.${BOOKMARK_BTN_CLASS}`, comment);
if (btn) {
btn.classList.toggle("bookmarked", isBookmarked);
btn.textContent = isBookmarked ? "★" : "☆";
btn.title = isBookmarked ? "Remove bookmark" : "Bookmark this comment";
}
comment.setAttribute(BOOKMARKED_ATTR, String(isBookmarked));
if (isBookmarked) {
bookmarkData.set(id, {
id,
url: `https://news.ycombinator.com/item?id=${id}`,
title: getPageTitle(site),
text: getCommentText(comment, site),
author: getCommentAuthor(comment, site),
timestamp: Date.now()
});
} else {
bookmarkData.delete(id);
}
currentPage = 0;
updateToggleCount();
refreshPanel();
}
function addBookmarkButtons(site) {
const sel = site === "hackerweb" ? SEL_HACKERWEB : SEL_HN;
for (const comment of qsa(sel.comments)) {
const metadata = qs(sel.metadata, comment);
if (!metadata) continue;
if (qs(`.${BOOKMARK_BTN_CLASS}`, metadata)) continue;
const id = getCommentId(comment, site);
if (!id) continue;
const isBookmarked = bookmarkIds.has(id);
const btn = createBookmarkButton(isBookmarked);
if (isBookmarked) {
comment.setAttribute(BOOKMARKED_ATTR, "true");
if (!bookmarkData.has(id)) {
bookmarkData.set(id, {
id,
url: `https://news.ycombinator.com/item?id=${id}`,
title: getPageTitle(site),
text: getCommentText(comment, site),
author: getCommentAuthor(comment, site),
timestamp: Date.now()
});
}
}
const userEl = qs(sel.author, metadata);
if (userEl) {
userEl.after(btn);
} else {
metadata.appendChild(btn);
}
}
}
function setupBookmarkHandler(site) {
const sel = site === "hackerweb" ? SEL_HACKERWEB : SEL_HN;
document.addEventListener("click", (e) => {
const target = getEventTargetElement(e);
if (!target) return;
if (target.classList.contains(BOOKMARK_BTN_CLASS)) {
e.preventDefault();
e.stopPropagation();
const comment = target.closest(sel.comments);
if (!comment) return;
const id = getCommentId(comment, site);
if (id) toggleBookmark(comment, id, site);
}
if (target.classList.contains(TOGGLE_CLASS$1)) {
e.preventDefault();
togglePanel$1();
}
if (target.classList.contains("hwt-bookmarks-panel-close")) {
e.preventDefault();
togglePanel$1(false);
}
if (target.classList.contains("hwt-bookmarks-nav-btn")) {
e.preventDefault();
const dir = target.getAttribute("data-dir");
if (dir === "prev") currentPage--;
if (dir === "next") currentPage++;
refreshPanel();
}
if (target.classList.contains("hwt-bookmark-item-remove")) {
e.preventDefault();
e.stopPropagation();
const id = target.getAttribute("data-id");
if (!id) return;
bookmarkIds.delete(id);
bookmarkData.delete(id);
updateToggleCount();
refreshPanel();
for (const comment of qsa(sel.comments)) {
if (getCommentId(comment, site) === id) {
const btn = qs(`.${BOOKMARK_BTN_CLASS}`, comment);
if (btn) {
btn.classList.remove("bookmarked");
btn.textContent = "☆";
btn.title = "Bookmark this comment";
}
comment.removeAttribute(BOOKMARKED_ATTR);
break;
}
}
}
});
}
function updateToggleCount() {
const toggle = qs(`.${TOGGLE_CLASS$1}`);
if (!toggle) return;
const count = bookmarkIds.getAll().length;
let badge = qs(".hwt-bookmarks-toggle-count", toggle);
if (count > 0) {
if (!badge) {
badge = document.createElement("span");
badge.className = "hwt-bookmarks-toggle-count";
toggle.appendChild(badge);
}
badge.textContent = String(count);
} else {
badge?.remove();
}
}
function togglePanel$1(show) {
panelVisible = show ?? !panelVisible;
let panel = qs(`.${PANEL_CLASS}`);
if (panelVisible) {
if (!panel) {
panel = createPanel$1();
document.body.appendChild(panel);
}
updatePanelContent(panel);
panel.classList.add("visible");
} else {
panel?.classList.remove("visible");
}
}
function createPanel$1() {
const panel = document.createElement("div");
panel.className = PANEL_CLASS;
const header = document.createElement("div");
header.className = "hwt-bookmarks-panel-header";
const title = document.createElement("span");
title.textContent = "Bookmarks";
header.appendChild(title);
const closeBtn = document.createElement("span");
closeBtn.className = "hwt-bookmarks-panel-close";
closeBtn.textContent = "×";
header.appendChild(closeBtn);
const body = document.createElement("div");
body.className = "hwt-bookmarks-panel-body";
panel.appendChild(header);
panel.appendChild(body);
return panel;
}
function getSortedBookmarks() {
return bookmarkIds.getAll().map((id) => ({ id, data: bookmarkData.get(id) })).sort((a, b) => (b.data?.timestamp ?? 0) - (a.data?.timestamp ?? 0));
}
function updatePanelContent(panel) {
const body = qs(".hwt-bookmarks-panel-body", panel);
if (!body) return;
const sorted = getSortedBookmarks();
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
if (currentPage >= totalPages) currentPage = totalPages - 1;
if (currentPage < 0) currentPage = 0;
body.innerHTML = "";
if (sorted.length === 0) {
const empty = document.createElement("div");
empty.className = "hwt-bookmarks-empty";
empty.textContent = "No bookmarks yet. Click ☆ on a comment to save it.";
body.appendChild(empty);
return;
}
const start = currentPage * PAGE_SIZE;
const page = sorted.slice(start, start + PAGE_SIZE);
for (const { id, data } of page) {
const item = document.createElement("div");
item.className = "hwt-bookmark-item";
const link = document.createElement("a");
link.className = "hwt-bookmark-item-content";
link.href = `https://news.ycombinator.com/item?id=${id}`;
link.target = "_blank";
const titleDiv = document.createElement("div");
titleDiv.className = "hwt-bookmark-item-title";
titleDiv.textContent = data?.title ?? `Comment #${id}`;
link.appendChild(titleDiv);
const metaDiv = document.createElement("div");
metaDiv.className = "hwt-bookmark-item-meta";
const parts = [];
if (data?.author) parts.push(data.author);
if (data?.timestamp) parts.push(timeAgo(data.timestamp));
metaDiv.textContent = parts.join(" · ");
link.appendChild(metaDiv);
if (data?.text) {
const textDiv = document.createElement("div");
textDiv.className = "hwt-bookmark-item-text";
textDiv.textContent = data.text;
link.appendChild(textDiv);
}
item.appendChild(link);
const removeBtn = document.createElement("button");
removeBtn.className = "hwt-bookmark-item-remove";
removeBtn.setAttribute("data-id", id);
removeBtn.textContent = "×";
removeBtn.title = "Remove bookmark";
item.appendChild(removeBtn);
body.appendChild(item);
}
if (totalPages > 1) {
const nav = document.createElement("div");
nav.className = "hwt-bookmarks-nav";
const prevBtn = document.createElement("button");
prevBtn.className = "hwt-bookmarks-nav-btn";
prevBtn.textContent = "‹";
prevBtn.disabled = currentPage === 0;
prevBtn.setAttribute("data-dir", "prev");
nav.appendChild(prevBtn);
const indicator = document.createElement("span");
indicator.className = "hwt-bookmarks-nav-indicator";
indicator.textContent = `${currentPage + 1} / ${totalPages}`;
nav.appendChild(indicator);
const nextBtn = document.createElement("button");
nextBtn.className = "hwt-bookmarks-nav-btn";
nextBtn.textContent = "›";
nextBtn.disabled = currentPage === totalPages - 1;
nextBtn.setAttribute("data-dir", "next");
nav.appendChild(nextBtn);
body.appendChild(nav);
}
}
function createToggleButton() {
if (qs(`.${TOGGLE_CLASS$1}`)) return;
const toggle = document.createElement("button");
toggle.className = TOGGLE_CLASS$1;
toggle.innerHTML = "☆";
toggle.title = "View bookmarks";
document.body.appendChild(toggle);
updateToggleCount();
}
const injectStyles$8 = createStyleInjector("hwt-comment-bookmarks-styles");
let handlerInitialized = false;
function initCommentBookmarks(site) {
if (!isFeatureEnabled("commentBookmarks", site)) return;
injectStyles$8(CSS$7);
addBookmarkButtons(site);
createToggleButton();
if (!handlerInitialized) {
setupBookmarkHandler(site);
handlerInitialized = true;
}
}
const SITE$1 = "hackerweb";
function initFeatures() {
initCollapse();
initOpBadge();
initDeepLink();
initNewComments();
initCollapseDepth();
initCommentBookmarks(SITE$1);
}
function init$1() {
getKeyboardManager().setScope(SITE$1);
initDarkMode(SITE$1);
initReadingProgress(SITE$1);
initKeyboardNav$1();
initFeatures();
createDebouncedObserver(initFeatures);
window.addEventListener("hashchange", initFeatures);
}
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 link = document.createElement("a");
link.href = "https://hckrnews.com/";
link.textContent = "hckrnews";
link.className = LINK_CLASS$1;
pagetop.append(" | ", link);
}
let done = false;
function initHeaderLink() {
if (done) return;
done = 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$7() {
inject(STYLES);
}
const HACKERWEB_URL = "https://hackerweb.app/#/item/";
const LINK_CLASS = "hn-links-hweb";
function createLink(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 getSubtext(row) {
return row.nextElementSibling?.querySelector(".subtext") ?? null;
}
function injectLink(subtext, itemId) {
if (subtext.querySelector(`.${LINK_CLASS}`)) return;
subtext.appendChild(createLink(itemId));
}
function injectStoryLinks() {
for (const row of document.querySelectorAll(
"tr.athing[id]"
)) {
const subtext = getSubtext(row);
if (row.id && subtext) injectLink(subtext, row.id);
}
}
function injectCommentPageLink() {
const itemId = new URLSearchParams(location.search).get("id");
const row = document.querySelector("tr.athing[id]");
const subtext = row && getSubtext(row);
if (itemId && subtext) injectLink(subtext, itemId);
}
let ready = false;
function initItemLinks() {
if (!ready) {
injectStyles$7();
ready = true;
}
injectStoryLinks();
injectCommentPageLink();
}
const CSS$6 = `
/* Score Threshold - highlight high/low score stories */
/* High score - only highlight the score number */
tr.athing[data-score-tier="high"] + tr .subtext .score {
color: #ff6600;
font-weight: bold;
}
/* Low score / negative stories - subtle dim */
tr.athing[data-score-tier="low"] {
opacity: 0.55;
}
`;
const SEL$4 = {
storyRow: "tr.athing",
score: ".score"
};
const SCORE_ATTR = "data-score-tier";
function parseScore(scoreEl) {
if (!scoreEl) return null;
const text = scoreEl.textContent;
if (!text) return null;
const match = /^(\d+)\s+points?$/.exec(text);
if (!match?.[1]) return null;
return parseInt(match[1], 10);
}
function getScoreTier(score) {
const highThreshold = getThreshold("highScoreThreshold");
const lowThreshold = getThreshold("lowScoreThreshold");
if (score >= highThreshold) return "high";
if (score <= lowThreshold) return "low";
return "normal";
}
function applyScoreThresholds() {
for (const storyRow of qsa(SEL$4.storyRow)) {
if (storyRow.hasAttribute(SCORE_ATTR)) continue;
const subtextRow = storyRow.nextElementSibling;
if (!subtextRow) continue;
const scoreEl = qs(SEL$4.score, subtextRow);
const score = parseScore(scoreEl);
if (score === null) {
storyRow.setAttribute(SCORE_ATTR, "normal");
continue;
}
const tier = getScoreTier(score);
storyRow.setAttribute(SCORE_ATTR, tier);
}
}
const injectStyles$6 = createStyleInjector("hwt-score-threshold-styles");
function initScoreThreshold() {
if (!isFeatureEnabled("scoreThreshold", "hn")) return;
injectStyles$6(CSS$6);
applyScoreThresholds();
}
const CSS$5 = `
/* Hide Read Stories */
/* Visited/read story indicator */
tr.athing[data-visited="true"] .titleline a {
color: #828282;
}
tr.athing[data-visited="true"] .titleline a:visited {
color: #828282;
}
/* Hide read stories when toggle is active */
body.hwt-hide-read tr.athing[data-visited="true"],
body.hwt-hide-read tr.athing[data-visited="true"] + tr,
body.hwt-hide-read tr.athing[data-visited="true"] + tr + tr.spacer {
display: none;
}
/* Toggle button */
.hwt-hide-read-toggle {
font-size: 10px;
color: #828282;
cursor: pointer;
margin-left: 10px;
}
.hwt-hide-read-toggle:hover {
text-decoration: underline;
}
.hwt-hide-read-toggle.active {
color: #ff6600;
font-weight: bold;
}
`;
const visitState = new MapState(
"visited",
(v) => typeof v === "object" && v !== null && typeof v.firstVisit === "number" && typeof v.lastVisit === "number" && typeof v.visitCount === "number"
);
function recordVisit(itemId) {
const now = Date.now();
const existing = visitState.get(itemId);
const info = existing ? {
firstVisit: existing.firstVisit,
lastVisit: now,
visitCount: existing.visitCount + 1
} : {
firstVisit: now,
lastVisit: now,
visitCount: 1
};
visitState.set(itemId, info);
return info;
}
function hasVisited(itemId) {
return visitState.has(itemId);
}
const SEL$3 = {
storyRow: "tr.athing",
titleLink: ".titleline > a",
commentsLink: 'a[href^="item?id="]',
pagetop: "#hnmain td.pagetop"
};
const VISITED_ATTR = "data-visited";
const TOGGLE_CLASS = "hwt-hide-read-toggle";
const HIDE_CLASS = "hwt-hide-read";
let hideEnabled = false;
function getStoryId(row) {
return row.id || null;
}
function markVisitedStories() {
for (const row of qsa(SEL$3.storyRow)) {
const id = getStoryId(row);
if (!id) continue;
if (hasVisited(id)) {
row.setAttribute(VISITED_ATTR, "true");
}
}
}
function handleStoryClick(id) {
recordVisit(id);
const row = document.getElementById(id);
if (row) {
row.setAttribute(VISITED_ATTR, "true");
}
}
function setupClickTracking() {
document.addEventListener("click", (e) => {
const target = getEventTargetElement(e);
if (!target) return;
const titleLink = target.closest(SEL$3.titleLink);
if (titleLink) {
const row = titleLink.closest("tr.athing");
const id = row ? getStoryId(row) : null;
if (id) handleStoryClick(id);
return;
}
const commentsLink = target.closest(SEL$3.commentsLink);
if (commentsLink) {
const subtextRow = commentsLink.closest("tr");
const storyRow = subtextRow?.previousElementSibling;
if (storyRow instanceof HTMLTableRowElement) {
const id = getStoryId(storyRow);
if (id) handleStoryClick(id);
}
}
});
}
function toggleHideRead() {
hideEnabled = !hideEnabled;
document.body.classList.toggle(HIDE_CLASS, hideEnabled);
const toggle = qs(`.${TOGGLE_CLASS}`);
if (toggle) {
toggle.classList.toggle("active", hideEnabled);
toggle.textContent = hideEnabled ? "show read" : "hide read";
}
}
function injectToggleButton() {
const pagetop = qs(SEL$3.pagetop);
if (!pagetop) return;
if (qs(`.${TOGGLE_CLASS}`, pagetop)) return;
const toggle = document.createElement("span");
toggle.className = TOGGLE_CLASS;
toggle.textContent = "hide read";
toggle.title = "Toggle hiding of read stories";
toggle.addEventListener("click", toggleHideRead);
pagetop.appendChild(toggle);
}
const injectStyles$5 = createStyleInjector("hwt-hide-read-styles");
let trackingInitialized = false;
function initHideRead() {
if (!isFeatureEnabled("hideReadStories", "hn")) return;
injectStyles$5(CSS$5);
markVisitedStories();
injectToggleButton();
if (!trackingInitialized) {
setupClickTracking();
trackingInitialized = true;
}
}
const CSS$4 = `
/* Keyboard Navigation - highlight focused story */
/* Focus indicator for keyboard navigation */
tr.athing.hwt-kb-focus {
background: rgba(255, 102, 0, 0.1);
}
tr.athing.hwt-kb-focus .titleline a {
color: #ff6600;
}
/* Also highlight the subtext row */
tr.athing.hwt-kb-focus + tr {
background: rgba(255, 102, 0, 0.05);
}
/* Keyboard help overlay */
.hwt-kb-help {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 16px 20px;
border-radius: 8px;
font-family: monospace;
font-size: 13px;
z-index: 10000;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.hwt-kb-help h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: #ff6600;
}
.hwt-kb-help table {
border-collapse: collapse;
}
.hwt-kb-help td {
padding: 2px 0;
}
.hwt-kb-help kbd {
display: inline-block;
background: #333;
border: 1px solid #555;
border-radius: 3px;
padding: 2px 6px;
margin-right: 8px;
min-width: 20px;
text-align: center;
}
`;
const SEL$2 = {
storyRows: "tr.athing",
titleLink: ".titleline > a",
commentsLink: 'a[href^="item?id="]'
};
const FOCUS_CLASS = "hwt-kb-focus";
const HELP_CLASS = "hwt-kb-help";
let focusedIndex = -1;
let helpVisible = false;
function getStories() {
return Array.from(qsa(SEL$2.storyRows));
}
function getFocused() {
return qs(`.${FOCUS_CLASS}`);
}
function setFocus(index) {
const stories = getStories();
if (stories.length === 0) return;
index = Math.max(0, Math.min(index, stories.length - 1));
getFocused()?.classList.remove(FOCUS_CLASS);
const story = stories[index];
if (!story) return;
story.classList.add(FOCUS_CLASS);
focusedIndex = index;
if (!isElementInViewport(story)) {
scrollToElement(story, 100, "smooth");
}
}
function focusNext() {
const stories = getStories();
if (focusedIndex < 0) {
setFocus(0);
} else {
setFocus(Math.min(focusedIndex + 1, stories.length - 1));
}
}
function focusPrev() {
if (focusedIndex < 0) {
setFocus(0);
} else {
setFocus(Math.max(focusedIndex - 1, 0));
}
}
function openStory() {
const focused = getFocused();
if (!focused) return;
const link = qs(SEL$2.titleLink, focused);
if (link) {
const id = focused.id;
if (id) recordVisit(id);
window.open(link.href, "_blank");
}
}
function openComments() {
const focused = getFocused();
if (!focused) return;
const subtextRow = focused.nextElementSibling;
if (!subtextRow) return;
const links = qsa(SEL$2.commentsLink, subtextRow);
const commentsLink = links[links.length - 1];
if (commentsLink) {
const id = focused.id;
if (id) recordVisit(id);
window.location.href = commentsLink.href;
}
}
function toggleHelp() {
helpVisible = !helpVisible;
let help = qs(`.${HELP_CLASS}`);
if (helpVisible) {
if (!help) {
help = document.createElement("div");
help.className = HELP_CLASS;
help.innerHTML = `
<h4>Keyboard Shortcuts</h4>
<table>
<tr><td><kbd>j</kbd></td><td>Next story</td></tr>
<tr><td><kbd>k</kbd></td><td>Previous story</td></tr>
<tr><td><kbd>o</kbd></td><td>Open story link</td></tr>
<tr><td><kbd>c</kbd></td><td>Open comments</td></tr>
<tr><td><kbd>?</kbd></td><td>Toggle help</td></tr>
</table>
`;
document.body.appendChild(help);
}
} else {
help?.remove();
}
}
function registerKeyboardShortcuts$1() {
const km = getKeyboardManager();
km.init();
const unsubscribers = [
km.register("j", focusNext, {
description: "Next story",
scope: "hn"
}),
km.register("k", focusPrev, {
description: "Previous story",
scope: "hn"
}),
km.register("o", openStory, {
description: "Open story link",
scope: "hn"
}),
km.register("c", openComments, {
description: "Open comments",
scope: "hn"
}),
km.register("?", toggleHelp, {
description: "Toggle help",
scope: "hn"
})
];
return () => {
unsubscribers.forEach((unsub) => unsub());
qs(`.${HELP_CLASS}`)?.remove();
getFocused()?.classList.remove(FOCUS_CLASS);
};
}
const injectStyles$4 = createStyleInjector("hwt-hn-keyboard-nav-styles");
let cleanup = null;
function initKeyboardNav() {
if (!isFeatureEnabled("keyboardNav", "hn")) {
if (cleanup) {
cleanup();
cleanup = null;
}
return;
}
if (cleanup) return;
injectStyles$4(CSS$4);
cleanup = registerKeyboardShortcuts$1();
}
const CSS$3 = `
/* Time Grouping - group stories by age */
/* Group header */
.hwt-time-group {
display: block;
background: #f6f6ef;
padding: 8px 12px;
margin: 8px 0;
font-size: 12px;
font-weight: bold;
color: #828282;
border-left: 3px solid #ff6600;
}
/* First group has no top margin */
.hwt-time-group:first-of-type {
margin-top: 0;
}
/* Make group headers sticky */
.hwt-time-group {
position: sticky;
top: 0;
z-index: 10;
}
`;
const SEL$1 = {
storyRow: "tr.athing",
age: ".age",
itemList: "#hnmain table:first-of-type > tbody"
};
const GROUP_CLASS = "hwt-time-group";
const GROUPS = {
now: { label: "Just now", maxMinutes: 30 },
hour: { label: "Past hour", maxMinutes: 60 },
today: { label: "Today", maxMinutes: 24 * 60 },
yesterday: { label: "Yesterday", maxMinutes: 48 * 60 },
week: { label: "This week", maxMinutes: 7 * 24 * 60 },
older: { label: "Older", maxMinutes: Infinity }
};
const GROUP_ORDER = [
"now",
"hour",
"today",
"yesterday",
"week",
"older"
];
function parseAge(ageText) {
const text = ageText.toLowerCase().trim();
const match = /^(\d+)\s+(minute|hour|day|week|month|year)s?\s+ago$/.exec(
text
);
if (!match?.[1] || !match[2]) return null;
const value = parseInt(match[1], 10);
const unit = match[2];
const multipliers = {
minute: 1,
hour: 60,
day: 24 * 60,
week: 7 * 24 * 60,
month: 30 * 24 * 60,
year: 365 * 24 * 60
};
const multiplier = multipliers[unit];
return multiplier ? value * multiplier : null;
}
function getTimeGroup(ageMinutes) {
for (const group of GROUP_ORDER) {
if (ageMinutes <= GROUPS[group].maxMinutes) {
return group;
}
}
return "older";
}
function createGroupHeader(group) {
const tr = document.createElement("tr");
tr.className = GROUP_CLASS;
tr.setAttribute("data-group", group);
const td = document.createElement("td");
td.colSpan = 3;
td.textContent = GROUPS[group].label;
tr.appendChild(td);
return tr;
}
function addTimeGrouping() {
const tbody = qs(SEL$1.itemList);
if (!tbody) return;
if (qs(`.${GROUP_CLASS}`, tbody)) return;
const stories = [];
for (const row of qsa(SEL$1.storyRow)) {
const subtextRow = row.nextElementSibling;
if (!subtextRow) continue;
const ageEl = qs(SEL$1.age, subtextRow);
if (!ageEl) continue;
const ageText = ageEl.getAttribute("title") ?? ageEl.textContent;
if (!ageText) continue;
const ageMinutes = parseAge(ageText);
if (ageMinutes !== null) {
stories.push({ row, ageMinutes });
}
}
let currentGroup = null;
for (const { row, ageMinutes } of stories) {
const group = getTimeGroup(ageMinutes);
if (group !== currentGroup) {
const header = createGroupHeader(group);
row.parentNode?.insertBefore(header, row);
currentGroup = group;
}
}
}
const injectStyles$3 = createStyleInjector("hwt-time-grouping-styles");
function initTimeGrouping() {
if (!isFeatureEnabled("timeGrouping", "hn")) return;
injectStyles$3(CSS$3);
addTimeGrouping();
}
const CSS$2 = `
/* Inline Preview - favicon and domain display */
/* Favicon next to title */
.hwt-favicon {
width: 16px;
height: 16px;
vertical-align: middle;
margin-right: 6px;
border-radius: 2px;
background: #f6f6ef;
}
/* Hide broken favicon images */
.hwt-favicon-error {
display: none;
}
/* Domain badge - uniform subtle style for all sites */
.titleline .sitestr {
background: rgba(0, 0, 0, 0.05);
padding: 1px 5px;
border-radius: 3px;
font-size: 9px;
}
`;
const SEL = {
titleline: ".titleline",
titleLink: ".titleline > a"
};
const FAVICON_CLASS = "hwt-favicon";
const FAVICON_ERROR_CLASS = "hwt-favicon-error";
const PROCESSED_ATTR = "data-hwt-preview";
function getDomain(url) {
try {
const parsed = new URL(url);
return parsed.hostname;
} catch {
return null;
}
}
function getFaviconUrl(domain) {
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
}
function createFavicon(domain) {
const img = document.createElement("img");
img.className = FAVICON_CLASS;
img.src = getFaviconUrl(domain);
img.alt = "";
img.loading = "lazy";
img.onerror = () => {
img.classList.add(FAVICON_ERROR_CLASS);
};
return img;
}
function addInlinePreviews() {
for (const titleline of qsa(SEL.titleline)) {
if (titleline.hasAttribute(PROCESSED_ATTR)) continue;
titleline.setAttribute(PROCESSED_ATTR, "true");
const titleLink = qs(SEL.titleLink, titleline);
if (!titleLink) continue;
const url = titleLink.href;
const domain = getDomain(url);
if (!domain) continue;
titleline.setAttribute("data-site", domain);
const favicon = createFavicon(domain);
titleLink.insertAdjacentElement("beforebegin", favicon);
}
}
const injectStyles$2 = createStyleInjector("hwt-inline-preview-styles");
function initInlinePreview() {
if (!isFeatureEnabled("inlinePreview", "hn")) return;
injectStyles$2(CSS$2);
addInlinePreviews();
}
const CSS$1 = `
/* Comfort Mode - centered, readable layout */
html.hwt-comfort #hnmain {
max-width: 920px;
margin: 0 auto;
font-size: 15px;
border-radius: 8px;
overflow: hidden;
margin-top: 12px;
margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* Dark mode shadow */
html.hwt-dark.hwt-comfort #hnmain {
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
}
/* More vertical space between stories */
html.hwt-comfort .itemlist .spacer td {
height: 8px;
}
/* Title readability */
html.hwt-comfort .athing .titleline {
font-size: 15px;
line-height: 1.4;
}
/* Subtext */
html.hwt-comfort .subtext {
font-size: 11px;
line-height: 1.5;
}
/* Story row padding */
html.hwt-comfort .athing td {
padding-top: 4px;
padding-bottom: 2px;
}
/* Rank numbers */
html.hwt-comfort .rank {
font-size: 14px;
}
/* Comment readability */
html.hwt-comfort .comment {
font-size: 14px;
line-height: 1.6;
}
`;
const COMFORT_CLASS = "hwt-comfort";
function applyComfortMode() {
document.documentElement.classList.add(COMFORT_CLASS);
}
function removeComfortMode() {
document.documentElement.classList.remove(COMFORT_CLASS);
}
const injectStyles$1 = createStyleInjector("hwt-comfort-mode");
function initComfortMode() {
injectStyles$1(CSS$1);
if (isFeatureEnabled("comfortMode", "hn")) {
applyComfortMode();
}
getConfigStore().subscribe("features", "comfortMode", (enabled) => {
if (enabled) {
applyComfortMode();
} else {
removeComfortMode();
}
});
}
const SITE = "hn";
function initDynamicFeatures() {
initItemLinks();
initScoreThreshold();
initHideRead();
initTimeGrouping();
initInlinePreview();
initCommentBookmarks(SITE);
}
function init() {
getKeyboardManager().setScope(SITE);
initHeaderLink();
initDarkMode(SITE);
initComfortMode();
initReadingProgress(SITE);
initKeyboardNav();
initDynamicFeatures();
createDebouncedObserver(initDynamicFeatures);
}
const CSS = `
/* Settings Panel - Gear Button */
.hwt-settings-gear {
position: fixed;
bottom: 80px;
right: 20px;
width: 44px;
height: 44px;
border-radius: 50%;
background: #ff6600;
border: none;
cursor: pointer;
z-index: 9998;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.hwt-settings-gear:hover {
transform: scale(1.1) rotate(15deg);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.hwt-settings-gear:active {
transform: scale(0.95);
}
.hwt-settings-gear svg {
width: 24px;
height: 24px;
fill: white;
}
/* Overlay */
.hwt-settings-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 9999;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.hwt-settings-overlay.hwt-visible {
opacity: 1;
visibility: visible;
}
/* Panel */
.hwt-settings-panel {
position: fixed;
top: 0;
right: 0;
width: 360px;
max-width: 90vw;
height: 100vh;
background: #e5e3dc;
z-index: 10000;
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.15);
transform: translateX(100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
color: #3a3a3a;
}
.hwt-settings-panel.hwt-visible {
transform: translateX(0);
}
/* Header */
.hwt-settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #c5c2b8;
background: #dad8d0;
flex-shrink: 0;
}
.hwt-settings-title {
font-size: 18px;
font-weight: 600;
color: #2a2a2a;
margin: 0;
}
.hwt-settings-close {
width: 32px;
height: 32px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s ease;
}
.hwt-settings-close:hover {
background: #ccc9c0;
}
.hwt-settings-close svg {
width: 20px;
height: 20px;
stroke: #555;
}
/* Content area */
.hwt-settings-content {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
/* Sections */
.hwt-settings-section {
margin-bottom: 24px;
}
.hwt-settings-section:last-child {
margin-bottom: 0;
}
.hwt-settings-section-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #777;
margin: 0 0 12px 0;
}
/* Group (card-like container) */
.hwt-settings-group {
background: #dddbd3;
border-radius: 10px;
padding: 4px 0;
margin-bottom: 12px;
}
.hwt-settings-group:last-child {
margin-bottom: 0;
}
.hwt-settings-group-title {
font-size: 12px;
font-weight: 600;
color: #666;
padding: 8px 16px 4px;
margin: 0;
}
/* Row (each setting item) */
.hwt-settings-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
min-height: 44px;
transition: background 0.15s ease;
}
.hwt-settings-row:hover {
background: rgba(0, 0, 0, 0.04);
}
.hwt-settings-row-info {
flex: 1;
min-width: 0;
padding-right: 12px;
}
.hwt-settings-row-label {
font-size: 14px;
font-weight: 500;
color: #2a2a2a;
margin: 0 0 2px 0;
}
.hwt-settings-row-description {
font-size: 12px;
color: #777;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Toggle Switch (iOS-style) */
.hwt-toggle {
position: relative;
width: 44px;
height: 24px;
flex-shrink: 0;
}
.hwt-toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.hwt-toggle-track {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: #b8b5ab;
border-radius: 24px;
transition: background 0.25s ease;
}
.hwt-toggle input:checked + .hwt-toggle-track {
background: #ff6600;
}
.hwt-toggle-knob {
position: absolute;
height: 20px;
width: 20px;
left: 2px;
bottom: 2px;
background: white;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.hwt-toggle input:checked + .hwt-toggle-track .hwt-toggle-knob {
transform: translateX(20px);
}
/* Focus state for keyboard accessibility */
.hwt-toggle input:focus-visible + .hwt-toggle-track {
outline: 2px solid #4a9eff;
outline-offset: 2px;
}
/* Pulse animation on change */
.hwt-toggle.hwt-pulse .hwt-toggle-track {
animation: hwt-pulse 0.4s ease;
}
@keyframes hwt-pulse {
0% { box-shadow: 0 0 0 0 rgba(255, 102, 0, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(255, 102, 0, 0); }
100% { box-shadow: 0 0 0 0 rgba(255, 102, 0, 0); }
}
/* Number Input */
.hwt-number-input {
width: 70px;
height: 32px;
border: 1px solid #c5c2b8;
border-radius: 6px;
padding: 0 8px;
font-size: 14px;
text-align: center;
color: #333;
background: #eeecea;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.hwt-number-input:focus {
outline: none;
border-color: #ff6600;
box-shadow: 0 0 0 2px rgba(255, 102, 0, 0.15);
}
.hwt-number-input::-webkit-inner-spin-button,
.hwt-number-input::-webkit-outer-spin-button {
opacity: 1;
}
/* Text Input */
.hwt-text-input {
width: 100px;
height: 32px;
border: 1px solid #c5c2b8;
border-radius: 6px;
padding: 0 8px;
font-size: 14px;
color: #333;
background: #eeecea;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.hwt-text-input:focus {
outline: none;
border-color: #ff6600;
box-shadow: 0 0 0 2px rgba(255, 102, 0, 0.15);
}
/* Color Input */
.hwt-color-input {
width: 44px;
height: 32px;
border: 1px solid #c5c2b8;
border-radius: 6px;
padding: 2px;
cursor: pointer;
background: #eeecea;
}
.hwt-color-input::-webkit-color-swatch-wrapper {
padding: 0;
}
.hwt-color-input::-webkit-color-swatch {
border: none;
border-radius: 4px;
}
/* Footer */
.hwt-settings-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-top: 1px solid #c5c2b8;
background: #dad8d0;
flex-shrink: 0;
}
.hwt-reset-btn {
padding: 8px 16px;
border: 1px solid #c5c2b8;
border-radius: 6px;
background: #eeecea;
font-size: 13px;
color: #555;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
}
.hwt-reset-btn:hover {
border-color: #ff6600;
color: #ff6600;
}
.hwt-version {
font-size: 12px;
color: #888;
}
/* Welcome State */
.hwt-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
}
.hwt-welcome-icon {
width: 64px;
height: 64px;
background: #ff6600;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
}
.hwt-welcome-icon svg {
width: 36px;
height: 36px;
fill: white;
}
.hwt-welcome-title {
font-size: 20px;
font-weight: 600;
color: #2a2a2a;
margin: 0 0 8px 0;
}
.hwt-welcome-desc {
font-size: 14px;
color: #666;
margin: 0 0 24px 0;
max-width: 280px;
line-height: 1.5;
}
.hwt-welcome-shortcuts {
background: #dddbd3;
border-radius: 10px;
padding: 16px;
width: 100%;
max-width: 260px;
margin-bottom: 24px;
}
.hwt-welcome-shortcut {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
}
.hwt-welcome-shortcut:not(:last-child) {
border-bottom: 1px solid #c5c2b8;
}
.hwt-welcome-shortcut-key {
font-family: ui-monospace, monospace;
font-size: 12px;
background: #ccc9c0;
padding: 4px 8px;
border-radius: 4px;
color: #2a2a2a;
}
.hwt-welcome-shortcut-desc {
font-size: 13px;
color: #666;
}
.hwt-explore-btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
background: #ff6600;
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, transform 0.15s ease;
}
.hwt-explore-btn:hover {
background: #e55a00;
}
.hwt-explore-btn:active {
transform: scale(0.98);
}
/* Dark Mode Overrides */
.hwt-dark .hwt-settings-panel {
background: #1a1a1a;
color: #e0e0e0;
}
.hwt-dark .hwt-settings-header {
background: #222;
border-color: #333;
}
.hwt-dark .hwt-settings-title {
color: #e0e0e0;
}
.hwt-dark .hwt-settings-close:hover {
background: #333;
}
.hwt-dark .hwt-settings-close svg {
stroke: #aaa;
}
.hwt-dark .hwt-settings-section-title {
color: #888;
}
.hwt-dark .hwt-settings-group {
background: #252525;
}
.hwt-dark .hwt-settings-group-title {
color: #888;
}
.hwt-dark .hwt-settings-row:hover {
background: rgba(255, 255, 255, 0.05);
}
.hwt-dark .hwt-settings-row-label {
color: #e0e0e0;
}
.hwt-dark .hwt-settings-row-description {
color: #888;
}
.hwt-dark .hwt-toggle-track {
background: #444;
}
.hwt-dark .hwt-number-input,
.hwt-dark .hwt-text-input {
background: #2a2a2a;
border-color: #444;
color: #e0e0e0;
}
.hwt-dark .hwt-color-input {
background: #2a2a2a;
border-color: #444;
}
.hwt-dark .hwt-settings-footer {
background: #222;
border-color: #333;
}
.hwt-dark .hwt-reset-btn {
background: #2a2a2a;
border-color: #444;
color: #aaa;
}
.hwt-dark .hwt-reset-btn:hover {
border-color: #ff6600;
color: #ff6600;
}
.hwt-dark .hwt-welcome-shortcuts {
background: #252525;
}
.hwt-dark .hwt-welcome-shortcut:not(:last-child) {
border-color: #333;
}
.hwt-dark .hwt-welcome-shortcut-key {
background: #333;
color: #e0e0e0;
}
.hwt-dark .hwt-welcome-title {
color: #e0e0e0;
}
.hwt-dark .hwt-welcome-desc,
.hwt-dark .hwt-welcome-shortcut-desc {
color: #888;
}
/* Reduced Motion */
@media (prefers-reduced-motion: reduce) {
.hwt-settings-gear,
.hwt-settings-overlay,
.hwt-settings-panel,
.hwt-toggle-track,
.hwt-toggle-knob,
.hwt-number-input,
.hwt-text-input,
.hwt-reset-btn,
.hwt-explore-btn {
transition: none;
}
.hwt-toggle.hwt-pulse .hwt-toggle-track {
animation: none;
}
}
/* Site toggle styling */
.hwt-site-toggle {
padding: 12px 16px;
}
.hwt-site-toggle .hwt-settings-row-label {
font-size: 15px;
}
`;
const version = "0.0.3";
const build = 8;
const fullVersion = `${version}.${build}`;
const FEATURE_GROUPS = {
core: {
label: "Core",
features: ["collapse", "keyboardNav", "opBadge", "deepLink"]
},
reading: {
label: "Reading",
features: ["newCommentHighlight", "readingProgress", "commentBookmarks"]
},
visual: {
label: "Visual",
features: ["darkModeSync", "comfortMode", "collapseByDepth"]
},
hnSpecific: {
label: "HN Features",
features: [
"hideReadStories",
"scoreThreshold",
"timeGrouping",
"inlinePreview",
"hwebLinks"
]
}
};
const FEATURE_LABELS = {
collapse: {
label: "Collapse threads",
description: "Click to collapse/expand comment threads"
},
keyboardNav: {
label: "Keyboard nav",
description: "j/k to navigate, o to open links"
},
opBadge: {
label: "OP badge",
description: "Highlight comments by the original poster"
},
deepLink: {
label: "Deep linking",
description: "URL updates when viewing comments"
},
newCommentHighlight: {
label: "New comment highlight",
description: "Highlight unread comments since last visit"
},
readingProgress: {
label: "Reading progress",
description: "Show progress bar while scrolling"
},
commentBookmarks: {
label: "Comment bookmarks",
description: "Save comments for later reading"
},
darkModeSync: {
label: "Dark mode sync",
description: "Match system dark/light preference"
},
comfortMode: {
label: "Comfort mode",
description: "Centered layout with larger fonts"
},
collapseByDepth: {
label: "Auto-collapse by depth",
description: "Collapse deeply nested comments"
},
hideReadStories: {
label: "Hide read stories",
description: "Hide stories you've already viewed"
},
scoreThreshold: {
label: "Score threshold",
description: "Dim low-scoring comments"
},
timeGrouping: {
label: "Time grouping",
description: "Group stories by time period"
},
inlinePreview: {
label: "Inline preview",
description: "Preview links without leaving the page"
},
hwebLinks: {
label: "HackerWeb links",
description: "Add links to view on HackerWeb"
}
};
const THRESHOLD_LABELS = {
autoCollapseDepth: {
label: "Auto-collapse depth",
min: 1,
max: 20
},
gutterClickPx: {
label: "Gutter click width (px)",
min: 5,
max: 50
},
highScoreThreshold: {
label: "High score threshold",
min: 10,
max: 500,
step: 10
},
lowScoreThreshold: {
label: "Low score threshold",
min: -100,
max: 0
},
minScore: {
label: "Minimum score",
min: -100,
max: 100
},
minComments: {
label: "Minimum comments",
min: 0,
max: 100
}
};
const DISPLAY_LABELS = {
maxContentWidth: {
label: "Max content width (px)",
type: "number",
min: 400,
max: 2e3,
step: 50
},
fontSize: {
label: "Font size (px)",
type: "number",
min: 10,
max: 24
},
commentLineHeight: {
label: "Line height",
type: "number",
min: 1,
max: 3,
step: 0.1
},
newCommentColor: {
label: "New comment color",
type: "color"
}
};
let panelElement = null;
let overlayElement = null;
let isOpen = false;
const PULSE_ANIMATION_MS = 400;
const GEAR_ICON = `<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/>
</svg>`;
const CLOSE_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12"/>
</svg>`;
function isFirstTimeUser() {
return localStorage.getItem(STORAGE_KEY) === null;
}
function isDarkMode() {
return document.documentElement.classList.contains("hwt-dark");
}
function observeDarkMode(panel) {
const sync = () => {
panel.classList.toggle("hwt-dark", isDarkMode());
};
const observer = new MutationObserver(sync);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"]
});
}
function createSection(title, buildContent) {
const section = document.createElement("div");
section.className = "hwt-settings-section";
const titleEl = document.createElement("h3");
titleEl.className = "hwt-settings-section-title";
titleEl.textContent = title;
section.appendChild(titleEl);
for (const element of buildContent()) {
section.appendChild(element);
}
return section;
}
function createGroup(title, rows) {
const group = document.createElement("div");
group.className = "hwt-settings-group";
if (title) {
const groupTitle = document.createElement("h4");
groupTitle.className = "hwt-settings-group-title";
groupTitle.textContent = title;
group.appendChild(groupTitle);
}
for (const row of rows) {
group.appendChild(row);
}
return group;
}
function rebuildPanelContent(container) {
container.innerHTML = "";
container.appendChild(createSitesSection());
container.appendChild(createFeaturesSection());
container.appendChild(createThresholdsSection());
container.appendChild(createDisplaySection());
}
function createGearButton() {
const button = document.createElement("button");
button.className = "hwt-settings-gear";
button.innerHTML = GEAR_ICON;
button.setAttribute("aria-label", "Open settings");
button.addEventListener("click", () => togglePanel());
document.body.appendChild(button);
return button;
}
function createOverlay() {
const overlay = document.createElement("div");
overlay.className = "hwt-settings-overlay";
overlay.addEventListener("click", () => togglePanel(false));
document.body.appendChild(overlay);
overlayElement = overlay;
return overlay;
}
function createToggle(checked, onChange) {
const label = document.createElement("label");
label.className = "hwt-toggle";
const input = document.createElement("input");
input.type = "checkbox";
input.checked = checked;
const track = document.createElement("span");
track.className = "hwt-toggle-track";
const knob = document.createElement("span");
knob.className = "hwt-toggle-knob";
track.appendChild(knob);
input.addEventListener("change", () => {
onChange(input.checked);
label.classList.add("hwt-pulse");
setTimeout(() => label.classList.remove("hwt-pulse"), PULSE_ANIMATION_MS);
});
label.appendChild(input);
label.appendChild(track);
return label;
}
function createToggleRow(label, description, checked, onChange) {
const row = document.createElement("div");
row.className = "hwt-settings-row";
const info = document.createElement("div");
info.className = "hwt-settings-row-info";
const labelEl = document.createElement("p");
labelEl.className = "hwt-settings-row-label";
labelEl.textContent = label;
const descEl = document.createElement("p");
descEl.className = "hwt-settings-row-description";
descEl.textContent = description;
info.appendChild(labelEl);
info.appendChild(descEl);
row.appendChild(info);
row.appendChild(createToggle(checked, onChange));
return row;
}
function createNumberInput(value, min, max, step, onChange) {
const input = document.createElement("input");
input.type = "number";
input.className = "hwt-number-input";
input.value = String(value);
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.addEventListener("change", () => {
const newValue = Math.min(max, Math.max(min, Number(input.value)));
input.value = String(newValue);
onChange(newValue);
});
return input;
}
function createNumberRow(label, value, min, max, step, onChange) {
const row = document.createElement("div");
row.className = "hwt-settings-row";
const info = document.createElement("div");
info.className = "hwt-settings-row-info";
const labelEl = document.createElement("p");
labelEl.className = "hwt-settings-row-label";
labelEl.textContent = label;
info.appendChild(labelEl);
row.appendChild(info);
row.appendChild(createNumberInput(value, min, max, step, onChange));
return row;
}
function createTextInput(value, onChange) {
const input = document.createElement("input");
input.type = "text";
input.className = "hwt-text-input";
input.value = value;
input.addEventListener("change", () => {
onChange(input.value);
});
return input;
}
function createColorInput(value, onChange) {
const input = document.createElement("input");
input.type = "color";
input.className = "hwt-color-input";
input.value = value;
input.addEventListener("change", () => {
onChange(input.value);
});
return input;
}
function createDisplayRow(label, value, type, onChange) {
const row = document.createElement("div");
row.className = "hwt-settings-row";
const info = document.createElement("div");
info.className = "hwt-settings-row-info";
const labelEl = document.createElement("p");
labelEl.className = "hwt-settings-row-label";
labelEl.textContent = label;
info.appendChild(labelEl);
row.appendChild(info);
const input = type === "color" ? createColorInput(value, onChange) : createTextInput(value, onChange);
row.appendChild(input);
return row;
}
function createSitesSection() {
const configStore2 = getConfigStore();
return createSection("Sites", () => {
const hwebRow = createToggleRow(
"HackerWeb",
"hackerweb.app",
configStore2.get("sites", "hackerweb").enabled,
(checked) => {
const current = configStore2.get("sites", "hackerweb");
configStore2.set("sites", "hackerweb", { ...current, enabled: checked });
}
);
hwebRow.classList.add("hwt-site-toggle");
const hnRow = createToggleRow(
"Hacker News",
"news.ycombinator.com",
configStore2.get("sites", "hn").enabled,
(checked) => {
const current = configStore2.get("sites", "hn");
configStore2.set("sites", "hn", { ...current, enabled: checked });
}
);
hnRow.classList.add("hwt-site-toggle");
return [createGroup(null, [hwebRow, hnRow])];
});
}
function createFeaturesSection() {
const configStore2 = getConfigStore();
return createSection("Features", () => {
const groups = [];
for (const groupInfo of Object.values(FEATURE_GROUPS)) {
const rows = [];
for (const featureKey of groupInfo.features) {
const featureInfo = FEATURE_LABELS[featureKey];
rows.push(
createToggleRow(
featureInfo.label,
featureInfo.description,
configStore2.get("features", featureKey),
(checked) => {
configStore2.set("features", featureKey, checked);
}
)
);
}
groups.push(createGroup(groupInfo.label, rows));
}
return groups;
});
}
function createThresholdsSection() {
const configStore2 = getConfigStore();
return createSection("Thresholds", () => {
const rows = [];
for (const [key, info] of Object.entries(THRESHOLD_LABELS)) {
const thresholdKey = key;
rows.push(
createNumberRow(
info.label,
configStore2.get("thresholds", thresholdKey),
info.min,
info.max,
info.step ?? 1,
(value) => {
configStore2.set("thresholds", thresholdKey, value);
}
)
);
}
return [createGroup(null, rows)];
});
}
function createDisplaySection() {
const configStore2 = getConfigStore();
return createSection("Display", () => {
const rows = [];
for (const [key, info] of Object.entries(DISPLAY_LABELS)) {
if (info.type === "number") {
rows.push(
createNumberRow(
info.label,
configStore2.get("display", key),
info.min,
info.max,
info.step ?? 1,
(value) => {
configStore2.set("display", key, value);
}
)
);
} else {
rows.push(
createDisplayRow(
info.label,
configStore2.get("display", key),
info.type,
(value) => {
configStore2.set("display", key, value);
}
)
);
}
}
return [createGroup(null, rows)];
});
}
function createWelcome(onExplore) {
const welcome = document.createElement("div");
welcome.className = "hwt-welcome";
const icon = document.createElement("div");
icon.className = "hwt-welcome-icon";
icon.innerHTML = GEAR_ICON;
welcome.appendChild(icon);
const title = document.createElement("h2");
title.className = "hwt-welcome-title";
title.textContent = "Welcome to HackerWeb Tools";
welcome.appendChild(title);
const desc = document.createElement("p");
desc.className = "hwt-welcome-desc";
desc.textContent = "Enhance your Hacker News experience with collapsible threads, keyboard navigation, and more.";
welcome.appendChild(desc);
const shortcuts = document.createElement("div");
shortcuts.className = "hwt-welcome-shortcuts";
const shortcutItems = [
{ key: "j / k", desc: "Navigate comments" },
{ key: "o", desc: "Open link" },
{ key: ",", desc: "Settings" }
];
for (const item of shortcutItems) {
const shortcut = document.createElement("div");
shortcut.className = "hwt-welcome-shortcut";
const key = document.createElement("span");
key.className = "hwt-welcome-shortcut-key";
key.textContent = item.key;
const shortcutDesc = document.createElement("span");
shortcutDesc.className = "hwt-welcome-shortcut-desc";
shortcutDesc.textContent = item.desc;
shortcut.appendChild(shortcutDesc);
shortcut.appendChild(key);
shortcuts.appendChild(shortcut);
}
welcome.appendChild(shortcuts);
const exploreBtn = document.createElement("button");
exploreBtn.className = "hwt-explore-btn";
exploreBtn.textContent = "Explore Settings";
exploreBtn.addEventListener("click", onExplore);
welcome.appendChild(exploreBtn);
return welcome;
}
function createPanel() {
if (panelElement) return panelElement;
const panel = document.createElement("div");
panel.className = "hwt-settings-panel";
if (isDarkMode()) {
panel.classList.add("hwt-dark");
}
observeDarkMode(panel);
const header = document.createElement("div");
header.className = "hwt-settings-header";
const title = document.createElement("h2");
title.className = "hwt-settings-title";
title.textContent = "HackerWeb Tools";
header.appendChild(title);
const closeBtn = document.createElement("button");
closeBtn.className = "hwt-settings-close";
closeBtn.innerHTML = CLOSE_ICON;
closeBtn.setAttribute("aria-label", "Close settings");
closeBtn.addEventListener("click", () => togglePanel(false));
header.appendChild(closeBtn);
panel.appendChild(header);
const content = document.createElement("div");
content.className = "hwt-settings-content";
if (isFirstTimeUser()) {
const welcomeEl = createWelcome(() => {
rebuildPanelContent(content);
});
content.appendChild(welcomeEl);
} else {
rebuildPanelContent(content);
}
panel.appendChild(content);
const footer = document.createElement("div");
footer.className = "hwt-settings-footer";
const resetBtn = document.createElement("button");
resetBtn.className = "hwt-reset-btn";
resetBtn.textContent = "Reset All";
resetBtn.addEventListener("click", () => {
if (confirm("Reset all settings to defaults?")) {
getConfigStore().resetAll();
rebuildPanelContent(content);
}
});
footer.appendChild(resetBtn);
const version2 = document.createElement("span");
version2.className = "hwt-version";
version2.textContent = `v${fullVersion}`;
footer.appendChild(version2);
panel.appendChild(footer);
document.body.appendChild(panel);
panelElement = panel;
return panel;
}
function togglePanel(show) {
const shouldShow = show ?? !isOpen;
if (shouldShow === isOpen) return;
isOpen = shouldShow;
if (panelElement) {
panelElement.classList.toggle("hwt-visible", isOpen);
}
if (overlayElement) {
overlayElement.classList.toggle("hwt-visible", isOpen);
}
document.body.style.overflow = isOpen ? "hidden" : "";
if (isOpen && panelElement) {
const closeBtn = panelElement.querySelector(
".hwt-settings-close"
);
closeBtn?.focus();
}
}
function registerKeyboardShortcuts() {
const handleKeydown = (e) => {
if (e.key === "Escape" && isOpen) {
e.preventDefault();
togglePanel(false);
return;
}
const target = e.target;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) {
return;
}
if (e.key === ",") {
e.preventDefault();
togglePanel();
}
};
document.addEventListener("keydown", handleKeydown);
return () => {
document.removeEventListener("keydown", handleKeydown);
};
}
const injectStyles = createStyleInjector("hwt-settings-panel-styles");
let initialized = false;
function initSettingsPanel() {
if (initialized) return;
initialized = true;
injectStyles(CSS);
createGearButton();
createOverlay();
createPanel();
registerKeyboardShortcuts();
}
runMigrations();
const configStore = getConfigStore();
const debugAPI = {
version: fullVersion,
loaded: ( new Date()).toISOString(),
get config() {
return configStore.getAll();
},
defaults: DEFAULT_CONFIG,
set: (section, key, value) => configStore.set(section, key, value),
reset: (section, key) => configStore.reset(section, key),
resetAll: () => configStore.resetAll(),
export: () => configStore.export(),
import: (json) => configStore.import(json),
isEnabled: (feature) => isFeatureEnabled(feature)
};
Object.assign(window, { __HWT__: debugAPI });
console.debug(`[HackerWeb Tools] v${fullVersion}`);
function main() {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", route, { once: true });
} else {
route();
}
}
function route() {
const host = location.hostname;
if (host === "hackerweb.app" || host === "news.ycombinator.com") {
initSettingsPanel();
}
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