Last active
March 26, 2026 21:49
-
-
Save swhitt/0fcf80442f2c0b55c01a90fa3a512df6 to your computer and use it in GitHub Desktop.
HackerWeb Tools - Enhancements for Hacker News and HackerWeb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name HackerWeb Tools | |
| // @namespace https://github.com/swhitt | |
| // @version 0.0.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