Repository: refined-github/refined-github
Pull request #7890 (2024-10-12 07:27:15)

`unfinished-comments` - Support new text fields
- for #7886 

## Test URLs

- https://togithub.com/eslint/eslint/issues/19013
- https://github.com/eslint/eslint/issues/new?template=change.yml

## Demo

![dirty](https://github.com/user-attachments/assets/d48df0e7-0d5f-46bd-84ac-ce4cd33e741c)




Patch:
diff --git a/.github/ISSUE_TEMPLATE/3_discussion.md b/.github/ISSUE_TEMPLATE/3_discussion.md
index 13377ad5..c74c0923 100644
--- a/.github/ISSUE_TEMPLATE/3_discussion.md
+++ b/.github/ISSUE_TEMPLATE/3_discussion.md
@@ -3,3 +3,5 @@ name: 💬 Ask a question or open a discussion
 about: ———
 labels: under discussion
 ---
+
+<!-- If you wonder why something isn't working, "report a bug" instead of using this template. -->
diff --git a/.github/release.yml b/.github/release.yml
index 6748f881..05bb72b7 100644
--- a/.github/release.yml
+++ b/.github/release.yml
@@ -1,18 +1,13 @@
 # https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes#configuration-options
 changelog:
-  exclude:
-    labels:
-      - meta
   categories:
     - title: New
-      labels:
-        - enhancement
+      labels: [enhancement]
     - title: Bugfixes
-      labels:
-        - bug
+      labels: [bug]
     - title: Changes
-      labels:
-        - '*'
+      labels: ['*']
       exclude:
-        labels:
-          - bug
+        labels: [meta] # Only needed because Meta must be last
+    - title: Meta
+      labels: [meta]
diff --git a/package-lock.json b/package-lock.json
index f7552186..70f14cfd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -49,6 +49,7 @@
 				"webext-options-sync": "^4.2.4",
 				"webext-options-sync-per-domain": "^4.2.3",
 				"webext-permission-toggle": "^5.1.0",
+				"webext-storage": "^1.2.2",
 				"webext-storage-cache": "^6.0.3",
 				"webext-tools": "^2.0.1",
 				"zip-text-nodes": "^1.0.0"
@@ -11758,6 +11759,21 @@
 				"url": "https://github.com/sponsors/fregante"
 			}
 		},
+		"node_modules/webext-storage": {
+			"version": "1.2.2",
+			"resolved": "https://registry.npmjs.org/webext-storage/-/webext-storage-1.2.2.tgz",
+			"integrity": "sha512-544gs8/iyoXR+mR/rJpt+Pkl3h1SgrLBOvNWr2uu7VPtRrhiHZWGH4Po9GAKtswN0RBSsx2tRKnBtxFskBILgw==",
+			"license": "MIT",
+			"dependencies": {
+				"webext-polyfill-kinda": "^1.0.2"
+			},
+			"engines": {
+				"node": ">=20"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/fregante"
+			}
+		},
 		"node_modules/webext-storage-cache": {
 			"version": "6.0.3",
 			"resolved": "https://registry.npmjs.org/webext-storage-cache/-/webext-storage-cache-6.0.3.tgz",
diff --git a/package.json b/package.json
index 883de566..452aab14 100644
--- a/package.json
+++ b/package.json
@@ -102,6 +102,7 @@
 		"webext-options-sync": "^4.2.4",
 		"webext-options-sync-per-domain": "^4.2.3",
 		"webext-permission-toggle": "^5.1.0",
+		"webext-storage": "^1.2.2",
 		"webext-storage-cache": "^6.0.3",
 		"webext-tools": "^2.0.1",
 		"zip-text-nodes": "^1.0.0"
diff --git a/source/background.ts b/source/background.ts
index 6a43a45f..8ac3623e 100644
--- a/source/background.ts
+++ b/source/background.ts
@@ -3,16 +3,18 @@ import {globalCache} from 'webext-storage-cache'; // Also needed to regularly cl
 import {addOptionsContextMenu} from 'webext-tools';
 import addPermissionToggle from 'webext-permission-toggle';
 import webextAlert from 'webext-alert';
+import {StorageItem} from 'webext-storage';
 
-import optionsStorage from './options-storage.js';
+import optionsStorage, {hasToken} from './options-storage.js';
 import isDevelopmentVersion from './helpers/is-development-version.js';
-import getStorageBytesInUse from './helpers/used-storage.js';
 import {doesBrowserActionOpenOptions} from './helpers/feature-utils.js';
 import {styleHotfixes} from './helpers/hotfix.js';
 import {handleMessages} from './helpers/messaging.js';
 
 const {version} = chrome.runtime.getManifest();
 
+const welcomeShown = new StorageItem('welcomed', {defaultValue: false});
+
 // GHE support
 addPermissionToggle();
 
@@ -64,34 +66,31 @@ handleMessages({
 	});
 });
 
-async function hasUsedStorage(): Promise<boolean> {
-	return (
-		await getStorageBytesInUse('sync') > 0
-		|| Number(await getStorageBytesInUse('local')) > 0
-	);
-}
-
-async function isFirstInstall(suggestedReason: string): Promise<boolean> {
-	return (
-		// Always exclude local installs from the welcome screen
-		!isDevelopmentVersion()
+async function showWelcomePage(): Promise<void> {
+	if (await welcomeShown.get()) {
+		return;
+	}
 
-		// Only if the reason is explicitly "install"
-		&& suggestedReason === 'install'
+	const [token, permissions] = await Promise.all([
+		hasToken(), // We can't handle an invalid token on a "Welcome" page, so just check whether the user has ever set one
+		chrome.permissions.contains({origins: ['https://github.com/*']}),
+	]);
 
-		// Safari reports "install" even on updates #5412
-		&& !(await hasUsedStorage())
-	);
-}
+	try {
+		if (token && permissions) {
+			// Mark as welcomed
+			return;
+		}
 
-chrome.runtime.onInstalled.addListener(async ({reason}) => {
-	// Only notify on install
-	if (await isFirstInstall(reason)) {
-		await chrome.tabs.create({
-			url: chrome.runtime.getURL('assets/welcome.html'),
-		});
+		const url = chrome.runtime.getURL('assets/welcome.html');
+		await chrome.tabs.create({url});
+	} finally {
+		// Make sure it's always set to true even in case of errors
+		await welcomeShown.set(true);
 	}
+}
 
+chrome.runtime.onInstalled.addListener(async () => {
 	if (isDevelopmentVersion()) {
 		await globalCache.clear();
 	}
@@ -104,6 +103,9 @@ chrome.runtime.onInstalled.addListener(async ({reason}) => {
 			],
 		});
 	}
+
+	// Call after the reset above just in case we nuked Safari's base permissions
+	await showWelcomePage();
 });
 
 chrome.permissions.onAdded.addListener(async permissions => {
diff --git a/source/feature-manager.tsx b/source/feature-manager.tsx
index b261d993..0d69a56c 100644
--- a/source/feature-manager.tsx
+++ b/source/feature-manager.tsx
@@ -25,6 +25,7 @@ import {
 	_,
 } from './helpers/hotfix.js';
 import asyncForEach from './helpers/async-for-each.js';
+import {catchErrors, disableErrorLogging} from './helpers/errors.js';
 import {messageBackground} from './helpers/messaging.js';
 
 type CallerFunction = (callback: VoidFunction, signal: AbortSignal) => void | Promise<void> | Deinit;
@@ -60,54 +61,14 @@ type InternalRunConfig = RunConditions & {
 	shortcuts: Record<string, string>;
 };
 
-const {version} = chrome.runtime.getManifest();
-
 const shortcutMap = new Map<string, string>();
 const getFeatureID = (url: string): FeatureID => url.split('/').pop()!.split('.')[0] as FeatureID;
 
 const currentFeatureControllers = new ArrayMap<FeatureID, AbortController>();
-const fineGrainedTokenSuggestion = 'Please use a GitHub App, OAuth App, or a personal access token with fine-grained permissions.';
-const preferredMessage = 'Refined GitHub does not support per-organization fine-grained tokens. https://github.com/refined-github/refined-github/wiki/Security';
-
-function logError(url: string, error: unknown): void {
-	const id = getFeatureID(url);
-	const message = error instanceof Error ? error.message : String(error);
-
-	if (message.endsWith(fineGrainedTokenSuggestion)) {
-		console.log('ℹ️', id, '→', message.replace(fineGrainedTokenSuggestion, preferredMessage));
-		return;
-	}
-
-	if (message.includes('token')) {
-		console.log('ℹ️', id, '→', message);
-		return;
-	}
-
-	const searchIssueUrl = new URL('https://github.com/refined-github/refined-github/issues');
-	searchIssueUrl.searchParams.set('q', `is:issue is:open label:bug ${id}`);
-
-	const newIssueUrl = new URL('https://github.com/refined-github/refined-github/issues/new');
-	newIssueUrl.searchParams.set('template', '1_bug_report.yml');
-	newIssueUrl.searchParams.set('title', `\`${id}\`: ${message}`);
-	newIssueUrl.searchParams.set('repro', location.href);
-	newIssueUrl.searchParams.set('description', [
-		'```',
-		String(error instanceof Error ? error.stack! : error).trim(),
-		'```',
-	].join('\n'));
-
-	// Don't change this to `throw Error` because Firefox doesn't show extensions' errors in the console
-	console.group(`❌ ${id}`); // Safari supports only one parameter
-	console.log(`📕 ${version} ${pageDetect.isEnterprise() ? 'GHE →' : '→'}`, error); // One parameter improves Safari formatting
-	console.log('🔍 Search issue', searchIssueUrl.href);
-	console.log('🚨 Report issue', newIssueUrl.href);
-	console.groupEnd();
-}
 
 const log = {
 	info: console.log,
 	http: console.log.bind(console, '🌏'),
-	error: logError,
 };
 
 // eslint-disable-next-line no-async-promise-executor -- Rule assumes we don't want to leave it pending
@@ -172,8 +133,9 @@ const globalReady = new Promise<RGHOptions>(async resolve => {
 
 	if (elementExists('body.logged-out')) {
 		console.warn('Refined GitHub is only expected to work when you’re logged in to GitHub. Errors will not be shown.');
-		// eslint-disable-next-line ts/no-use-before-define -- TODO: Drop in https://github.com/refined-github/refined-github/issues/7750
-		features.log.error = () => {/* No logging */};
+		disableErrorLogging();
+	} else {
+		catchErrors();
 	}
 
 	// Detect unload via two events to catch both clicks and history navigation
@@ -200,28 +162,20 @@ async function setupPageLoad(id: FeatureID, config: InternalRunConfig): Promise<
 
 	const runFeature = async (): Promise<void> => {
 		await asyncForEach(castArray(init), async init => {
-			let result: FeatureInitResult | undefined;
-			try {
-				result = await init(featureController.signal);
-				// Features can return `false` when they decide not to run on the current page
-				if (result !== false && !isFeaturePrivate(id)) {
-					log.info('✅', id);
-					// Register feature shortcuts
-					for (const [hotkey, description] of Object.entries(shortcuts)) {
-						shortcutMap.set(hotkey, description);
-					}
+			const result = await init(featureController.signal);
+			// Features can return `false` when they decide not to run on the current page
+			if (result !== false && !isFeaturePrivate(id)) {
+				log.info('✅', id);
+				// Register feature shortcuts
+				for (const [hotkey, description] of Object.entries(shortcuts)) {
+					shortcutMap.set(hotkey, description);
 				}
-			} catch (error) {
-				log.error(id, error);
-			}
-
-			if (result) {
-				onAbort(featureController, result);
 			}
 		});
 	};
 
 	if (!onlyAdditionalListeners) {
+		// If the first run fails, `additionalListeners` won't be added
 		await runFeature();
 	}
 
diff --git a/source/features/bugs-tab.tsx b/source/features/bugs-tab.tsx
index 04fa9acd..01e0d4b2 100644
--- a/source/features/bugs-tab.tsx
+++ b/source/features/bugs-tab.tsx
@@ -13,6 +13,7 @@ import abbreviateNumber from '../helpers/abbreviate-number.js';
 import {highlightTab, unhighlightTab} from '../helpers/dom-utils.js';
 import isBugLabel from '../github-helpers/bugs-label.js';
 import CountBugs from './bugs-tab.gql';
+import {expectToken} from '../github-helpers/github-token.js';
 
 type Bugs = {
 	label: string;
@@ -156,6 +157,8 @@ async function updateBugsTagHighlighting(): Promise<void | false> {
 }
 
 async function init(): Promise<void | false> {
+	await expectToken();
+
 	if (!elementExists('.rgh-bugs-tab')) {
 		await addBugsTab();
 	}
diff --git a/source/features/ci-link.tsx b/source/features/ci-link.tsx
index daf3fa95..483437f7 100644
--- a/source/features/ci-link.tsx
+++ b/source/features/ci-link.tsx
@@ -7,6 +7,7 @@ import api from '../github-helpers/api.js';
 import {buildRepoURL} from '../github-helpers/index.js';
 import observe from '../helpers/selector-observer.js';
 import getChecks from './ci-link.gql';
+import {expectToken} from '../github-helpers/github-token.js';
 
 async function getCommitWithChecks(): Promise<string | undefined> {
 	const {repository} = await api.v4(getChecks);
@@ -44,16 +45,14 @@ async function add(anchor: HTMLElement): Promise<void> {
 }
 
 async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
+
 	observe([
 		// Desktop
 		'.AppHeader-context-item:not([data-hovercard-type])',
 
 		// Mobile. `> *:first-child` avoids finding our own element
 		'.AppHeader-context-compact-mainItem > span:first-child',
-
-		// Old selector: `.avatar` excludes "Global navigation update"
-		// Repo title (aware of forks and private repos)
-		'[itemprop="name"]:not(.avatar ~ [itemprop])',
 	], add, {signal});
 }
 
diff --git a/source/features/clean-conversation-filters.tsx b/source/features/clean-conversation-filters.tsx
index 2cdf8833..d4b25aaf 100644
--- a/source/features/clean-conversation-filters.tsx
+++ b/source/features/clean-conversation-filters.tsx
@@ -7,7 +7,7 @@ import features from '../feature-manager.js';
 import {cacheByRepo} from '../github-helpers/index.js';
 import HasAnyProjects from './clean-conversation-filters.gql';
 import api from '../github-helpers/api.js';
-import {expectTokenScope} from '../github-helpers/github-token.js';
+import {expectToken, expectTokenScope} from '../github-helpers/github-token.js';
 import observe from '../helpers/selector-observer.js';
 
 const hasAnyProjects = new CachedFunction('has-projects', {
@@ -53,14 +53,15 @@ async function hideMilestones(container: HTMLElement): Promise<void> {
 }
 
 async function hideProjects(container: HTMLElement): Promise<void> {
-	if (await hasAnyProjects.get()) {
-		return;
-	}
-
-	expectElement([
+	const filter = $([
 		'#project-select-menu', // TODO: Drop in March 2025
 		'[data-testid="action-bar-item-projects"]',
-	], container).remove();
+	], container);
+
+	// If the filter is missing, then it has been disabled organization-wide already
+	if (filter && !await hasAnyProjects.get()) {
+		filter.remove();
+	}
 }
 
 async function hide(container: HTMLElement): Promise<void> {
@@ -69,7 +70,8 @@ async function hide(container: HTMLElement): Promise<void> {
 	void hideProjects(container);
 }
 
-function init(signal: AbortSignal): void {
+async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	observe([
 		'#js-issues-toolbar', // TODO: Remove after March 2025
 		'[data-testid="list-view-metadata"]',
diff --git a/source/features/clean-conversation-headers.tsx b/source/features/clean-conversation-headers.tsx
index 3ad09829..08bc8841 100644
--- a/source/features/clean-conversation-headers.tsx
+++ b/source/features/clean-conversation-headers.tsx
@@ -8,6 +8,7 @@ import * as pageDetect from 'github-url-detection';
 import features from '../feature-manager.js';
 import getDefaultBranch from '../github-helpers/get-default-branch.js';
 import observe from '../helpers/selector-observer.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 async function cleanIssueHeader(byline: HTMLElement): Promise<void> {
 	byline.classList.add('rgh-clean-conversation-headers', 'rgh-clean-conversation-headers-hide-author');
@@ -52,6 +53,8 @@ async function cleanPrHeader(byline: HTMLElement): Promise<void> {
 }
 
 async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
+
 	const cleanConversationHeader = pageDetect.isIssue() ? cleanIssueHeader : cleanPrHeader;
 	observe([
 		'.gh-header-meta > .flex-auto', // Real
diff --git a/source/features/clear-pr-merge-commit-message.tsx b/source/features/clear-pr-merge-commit-message.tsx
index 37bfd87f..2cfb4144 100644
--- a/source/features/clear-pr-merge-commit-message.tsx
+++ b/source/features/clear-pr-merge-commit-message.tsx
@@ -6,8 +6,9 @@ import features from '../feature-manager.js';
 import {getBranches} from '../github-helpers/pr-branches.js';
 import getDefaultBranch from '../github-helpers/get-default-branch.js';
 import cleanCommitMessage from '../helpers/clean-commit-message.js';
-import {userCanLikelyMergePR} from '../github-helpers/index.js';
+import {userHasPushAccess} from '../github-helpers/get-user-permission.js';
 import observe from '../helpers/selector-observer.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 const isPrAgainstDefaultBranch = async (): Promise<boolean> => getBranches().base.branch === await getDefaultBranch();
 
@@ -38,16 +39,15 @@ async function clear(messageField: HTMLTextAreaElement): Promise<void | false> {
 	);
 }
 
-function init(signal: AbortSignal): void {
+async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	observe('textarea#merge_message_field', clear, {signal});
 }
 
 void features.add(import.meta.url, {
 	asLongAs: [
-		userCanLikelyMergePR,
-	],
-	include: [
 		pageDetect.isPRConversation,
+		userHasPushAccess,
 	],
 	exclude: [
 		// Don't clear 1-commit PRs #3140
diff --git a/source/features/closing-remarks.tsx b/source/features/closing-remarks.tsx
index 9a74bfa6..4c5023cb 100644
--- a/source/features/closing-remarks.tsx
+++ b/source/features/closing-remarks.tsx
@@ -11,13 +11,10 @@ import onPrMerge from '../github-events/on-pr-merge.js';
 import createBanner from '../github-helpers/banner.js';
 import TimelineItem from '../github-helpers/timeline-item.js';
 import attachElement from '../helpers/attach-element.js';
-import {canEditEveryComment} from './quick-comment-edit.js';
 import {buildRepoURL, getRepo, isRefinedGitHubRepo} from '../github-helpers/index.js';
 import {getReleases} from './releases-tab.js';
 import observe from '../helpers/selector-observer.js';
-
-// TODO: Not an exact match; Moderators can edit comments but not create releases
-const canCreateRelease = canEditEveryComment;
+import {userHasPushAccess} from '../github-helpers/get-user-permission.js';
 
 function excludeNightliesAndJunk({textContent}: HTMLAnchorElement): boolean {
 	// https://github.com/refined-github/refined-github/issues/7206
@@ -45,10 +42,6 @@ const firstTag = new CachedFunction('first-tag', {
 });
 
 function createReleaseUrl(): string | undefined {
-	if (!canCreateRelease()) {
-		return;
-	}
-
 	if (isRefinedGitHubRepo()) {
 		return 'https://github.com/refined-github/refined-github/actions/workflows/release.yml';
 	}
@@ -146,7 +139,7 @@ void features.add(import.meta.url, {
 	asLongAs: [
 		pageDetect.isPRConversation,
 		pageDetect.isOpenPR,
-		canCreateRelease,
+		userHasPushAccess,
 	],
 	additionalListeners: [
 		onPrMerge,
diff --git a/source/features/comments-time-machine-links.tsx b/source/features/comments-time-machine-links.tsx
index f5ad2e83..16b26f29 100644
--- a/source/features/comments-time-machine-links.tsx
+++ b/source/features/comments-time-machine-links.tsx
@@ -12,6 +12,7 @@ import {buildRepoURL, isPermalink} from '../github-helpers/index.js';
 import {saveOriginalHref} from './sort-conversations-by-update-time.js';
 import observe from '../helpers/selector-observer.js';
 import GetCommitAtDate from './comments-time-machine-links.gql';
+import {expectToken} from '../github-helpers/github-token.js';
 
 async function updateURLtoDatedSha(url: GitHubFileURL, date: string): Promise<void> {
 	const {repository} = await api.v4(GetCommitAtDate, {variables: {date, branch: url.branch}});
@@ -98,7 +99,9 @@ function addDropdownLink(menu: HTMLElement, timestamp: string): void {
 	);
 }
 
-function init(signal: AbortSignal): void {
+async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
+
 	observe('.timeline-comment-actions > details:last-child', menu => {
 		if (menu.closest('.js-pending-review-comment')) {
 			return;
diff --git a/source/features/conflict-marker.tsx b/source/features/conflict-marker.tsx
index 4baf37d0..0bc7c2f8 100644
--- a/source/features/conflict-marker.tsx
+++ b/source/features/conflict-marker.tsx
@@ -8,6 +8,7 @@ import features from '../feature-manager.js';
 import api from '../github-helpers/api.js';
 import observe from '../helpers/selector-observer.js';
 import {openPrsListLink} from '../github-helpers/selectors.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 async function addIcon(links: HTMLAnchorElement[]): Promise<void> {
 	const prConfigs = links.map(link => {
@@ -48,7 +49,8 @@ async function addIcon(links: HTMLAnchorElement[]): Promise<void> {
 	}
 }
 
-function init(signal: AbortSignal): void {
+async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	observe(openPrsListLink, batchedFunction(addIcon, {delay: 100}), {signal});
 }
 
diff --git a/source/features/convert-release-to-draft.tsx b/source/features/convert-release-to-draft.tsx
index 733a97b8..4e6a5976 100644
--- a/source/features/convert-release-to-draft.tsx
+++ b/source/features/convert-release-to-draft.tsx
@@ -37,11 +37,10 @@ async function onConvertClick(): Promise<void> {
 		return;
 	}
 
-	try {
-		await showToast(convertToDraft(), {message: 'Converting…', doneMessage: 'Redirecting…'});
-	} catch (error) {
-		features.log.error(import.meta.url, error);
-	}
+	await showToast(convertToDraft(), {
+		message: 'Converting…',
+		doneMessage: 'Redirecting…',
+	});
 }
 
 function attachButton(editButton: HTMLAnchorElement): void {
diff --git a/source/features/deep-reblame.tsx b/source/features/deep-reblame.tsx
index bd62ce14..b716b5f3 100644
--- a/source/features/deep-reblame.tsx
+++ b/source/features/deep-reblame.tsx
@@ -14,6 +14,7 @@ import looseParseInt from '../helpers/loose-parse-int.js';
 import observe from '../helpers/selector-observer.js';
 import GetPullRequestBlameCommit from './deep-reblame.gql';
 import {multilineAriaLabel} from '../github-helpers/index.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 const getPullRequestBlameCommit = mem(async (commit: string, prNumbers: number[], currentFilename: string): Promise<string> => {
 	const {repository} = await api.v4(GetPullRequestBlameCommit, {
@@ -86,7 +87,9 @@ function addButton(hunk: HTMLElement): void {
 	}
 }
 
-function init(signal: AbortSignal): void {
+async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
+
 	delegate('.rgh-deep-reblame', 'click', redirectToBlameCommit, {signal});
 	observe('.react-blame-for-range:has([data-hovercard-type="pull_request"])', addButton, {signal});
 }
diff --git a/source/features/default-branch-button.tsx b/source/features/default-branch-button.tsx
index d664df1c..275ae123 100644
--- a/source/features/default-branch-button.tsx
+++ b/source/features/default-branch-button.tsx
@@ -13,6 +13,7 @@ import observe from '../helpers/selector-observer.js';
 import {branchSelector} from '../github-helpers/selectors.js';
 import isDefaultBranch from '../github-helpers/is-default-branch.js';
 import {fixFileHeaderOverlap, isRepoCommitListRoot} from '../github-helpers/index.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 const getUrl = memoize(async (currentUrl: string): Promise<string> => {
 	const defaultUrl = new GitHubFileURL(currentUrl);
@@ -77,7 +78,8 @@ async function add(branchSelector: HTMLElement): Promise<void> {
 	wrapButtons([defaultLink, selectorWrapper]);
 }
 
-function init(signal: AbortSignal): void {
+async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	observe(branchSelector, add, {signal});
 }
 
diff --git a/source/features/expand-all-hidden-comments.tsx b/source/features/expand-all-hidden-comments.tsx
index 6df4ac5c..39568bf0 100644
--- a/source/features/expand-all-hidden-comments.tsx
+++ b/source/features/expand-all-hidden-comments.tsx
@@ -30,7 +30,10 @@ async function handleAltClick({altKey, delegateTarget}: DelegateEvent<MouseEvent
 		return;
 	}
 
-	await showToast(expandHidden(delegateTarget), {message: 'Expanding…', doneMessage: 'Expanded'});
+	await showToast(expandHidden(delegateTarget), {
+		message: 'Expanding…',
+		doneMessage: 'Expanded',
+	});
 }
 
 function init(signal: AbortSignal): void {
diff --git a/source/features/github-actions-indicators.tsx b/source/features/github-actions-indicators.tsx
index 4d78b905..9b4d84cc 100644
--- a/source/features/github-actions-indicators.tsx
+++ b/source/features/github-actions-indicators.tsx
@@ -10,6 +10,7 @@ import api from '../github-helpers/api.js';
 import {cacheByRepo} from '../github-helpers/index.js';
 import observe from '../helpers/selector-observer.js';
 import GetWorkflows from './github-actions-indicators.gql';
+import {expectToken} from '../github-helpers/github-token.js';
 
 type Workflow = {
 	name: string;
@@ -133,6 +134,7 @@ async function addIndicators(workflowListItem: HTMLAnchorElement): Promise<void>
 }
 
 async function init(signal: AbortSignal): Promise<false | void> {
+	await expectToken();
 	observe('a.ActionListContent', addIndicators, {signal});
 }
 
diff --git a/source/features/highlight-non-default-base-branch.tsx b/source/features/highlight-non-default-base-branch.tsx
index a5341a13..788c9026 100644
--- a/source/features/highlight-non-default-base-branch.tsx
+++ b/source/features/highlight-non-default-base-branch.tsx
@@ -8,6 +8,7 @@ import api from '../github-helpers/api.js';
 import {buildRepoURL} from '../github-helpers/index.js';
 import getDefaultBranch from '../github-helpers/get-default-branch.js';
 import observe from '../helpers/selector-observer.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 type BranchInfo = {
 	baseRef: string;
@@ -70,6 +71,7 @@ async function add(prLinks: HTMLElement[]): Promise<void> {
 }
 
 async function init(signal: AbortSignal): Promise<false | void> {
+	await expectToken();
 	observe('.js-issue-row .js-navigation-open[data-hovercard-type="pull_request"]', batchedFunction(add, {delay: 100}), {signal});
 }
 
diff --git a/source/features/linkify-code.tsx b/source/features/linkify-code.tsx
index 88194d15..3967ee19 100644
--- a/source/features/linkify-code.tsx
+++ b/source/features/linkify-code.tsx
@@ -28,11 +28,7 @@ function linkifyContent(wrapper: Element): void {
 	// Mark code block as touched to avoid `shorten-links` from acting on these new links in code
 	wrapper.classList.add(linkifiedURLClass);
 
-	const errors = linkifyURLs(wrapper);
-	if (errors) {
-		features.log.error(import.meta.url, 'Links already exist');
-		console.log(errors);
-	}
+	linkifyURLs(wrapper);
 
 	const currentRepo = pageDetect.isGlobalSearchResults()
 		// Look for the link on the line number
diff --git a/source/features/linkify-user-labels.tsx b/source/features/linkify-user-labels.tsx
index a70793b8..b0a1bcfc 100644
--- a/source/features/linkify-user-labels.tsx
+++ b/source/features/linkify-user-labels.tsx
@@ -9,8 +9,7 @@ import observe from '../helpers/selector-observer.js';
 
 function linkify(label: Element): void {
 	if (label.closest('a')) {
-		features.log.error(import.meta.url, 'Already linkified, feature needs to be updated');
-		return;
+		throw new Error('Already linkified, feature needs to be updated');
 	}
 
 	const url = new URL(buildRepoURL('commits'));
diff --git a/source/features/list-prs-for-file.tsx b/source/features/list-prs-for-file.tsx
index 49a3e780..c3aacde1 100644
--- a/source/features/list-prs-for-file.tsx
+++ b/source/features/list-prs-for-file.tsx
@@ -12,6 +12,7 @@ import {buildRepoURL, cacheByRepo} from '../github-helpers/index.js';
 import GitHubFileURL from '../github-helpers/github-file-url.js';
 import observe from '../helpers/selector-observer.js';
 import listPrsForFileQuery from './list-prs-for-file.gql';
+import {expectToken} from '../github-helpers/github-token.js';
 
 function getPRUrl(prNumber: number): string {
 	// https://caniuse.com/url-scroll-to-text-fragment
@@ -132,7 +133,9 @@ async function add(anchor: HTMLElement): Promise<false | void> {
 	}
 }
 
-function init(signal: AbortSignal): void {
+async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
+
 	observe([
 		'[aria-label="More file actions"]', // `isSingleFile`
 		'[data-hotkey="Mod+s"]', // `isEditingFile`
diff --git a/source/features/mark-merge-commits-in-list.tsx b/source/features/mark-merge-commits-in-list.tsx
index b8435332..859a94ac 100644
--- a/source/features/mark-merge-commits-in-list.tsx
+++ b/source/features/mark-merge-commits-in-list.tsx
@@ -11,6 +11,7 @@ import features from '../feature-manager.js';
 import api from '../github-helpers/api.js';
 import {commitHashLinkInLists, commitTitleInLists} from '../github-helpers/selectors.js';
 import {assertCommitHash} from '../github-helpers/index.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 const filterMergeCommits = async (commits: string[]): Promise<string[]> => {
 	const {repository} = await api.v4(`
@@ -64,6 +65,7 @@ async function markCommits(commits: HTMLElement[]): Promise<void> {
 }
 
 async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	observe([
 		'[data-testid="commit-row-item"]',
 
diff --git a/source/features/netiquette.tsx b/source/features/netiquette.tsx
index 779a0b38..bef5bd18 100644
--- a/source/features/netiquette.tsx
+++ b/source/features/netiquette.tsx
@@ -12,12 +12,9 @@ import features from '../feature-manager.js';
 import observe from '../helpers/selector-observer.js';
 import {buildRepoURL, isAnyRefinedGitHubRepo, isOwnConversation} from '../github-helpers/index.js';
 import {closedOrMergedMarkerSelector, getLastCloseEvent} from './jump-to-conversation-close-event.js';
-import {canEditEveryComment} from './quick-comment-edit.js';
 
 import {newCommentField} from '../github-helpers/selectors.js';
-
-// TODO: Not exact, replace with API
-const isCollaborator = canEditEveryComment;
+import {userIsModerator} from '../github-helpers/get-user-permission.js';
 
 const isClosedOrMerged = (): boolean => elementExists(closedOrMergedMarkerSelector);
 
@@ -92,11 +89,11 @@ function initDraft(signal: AbortSignal): void {
 	observe(newCommentField, addDraftBanner, {signal});
 }
 
-function initBanner(signal: AbortSignal): void | false {
+async function initBanner(signal: AbortSignal): Promise<void | false> {
 	// Do not move to `asLongAs` because those conditions are run before `isConversation`
 	if (wasClosedLongAgo()) {
 		observe(newCommentField, addConversationBanner, {signal});
-	} else if (isPopular() && !isCollaborator()) {
+	} else if (isPopular() && !await userIsModerator()) {
 		observe(newCommentField, addPopularBanner, {signal});
 	} else {
 		return false;
@@ -113,7 +110,7 @@ function makeFieldKinder(field: HTMLParagraphElement): void {
 		// https://github.com/refined-github/refined-github/pull/6991/files
 		field.textContent = 'Leave a comment, be kind';
 	} else {
-		features.log.error(import.meta.url, `Unexpected placeholder text: ${field.textContent}`);
+		throw new Error(`Unexpected placeholder text: ${field.textContent}`);
 	}
 }
 
diff --git a/source/features/new-or-deleted-file.tsx b/source/features/new-or-deleted-file.tsx
index d8f68c13..12b75c1e 100644
--- a/source/features/new-or-deleted-file.tsx
+++ b/source/features/new-or-deleted-file.tsx
@@ -14,9 +14,8 @@ function add(filename: HTMLAnchorElement): void {
 
 	const fileInList = $(`[href="${filename.hash}"]`, list);
 	if (!fileInList) {
-		features.log.error(import.meta.url, 'Could not find file in sidebar, is the sidebar loaded?');
 		features.unload(import.meta.url);
-		return;
+		throw new Error('Could not find file in sidebar, is the sidebar loaded?');
 	}
 
 	const icon = $(['.octicon-diff-removed', '.octicon-diff-added'], fileInList)
diff --git a/source/features/one-click-review-submission.tsx b/source/features/one-click-review-submission.tsx
index 02d31f23..1a13f23a 100644
--- a/source/features/one-click-review-submission.tsx
+++ b/source/features/one-click-review-submission.tsx
@@ -17,7 +17,7 @@ function replaceCheckboxes(originalSubmitButton: HTMLButtonElement): void {
 	// `RadioNodeList` is dynamic, so we need to make a copy
 	const radios = [...form.elements.namedItem('pull_request_review[event]') as RadioNodeList] as HTMLInputElement[];
 	if (radios.length === 0) {
-		features.log.error(import.meta.url, 'Could not find radio buttons');
+		throw new Error('Could not find radio buttons');
 		return;
 	}
 
diff --git a/source/features/pinned-issues-update-time.tsx b/source/features/pinned-issues-update-time.tsx
index 109add41..323c35ab 100644
--- a/source/features/pinned-issues-update-time.tsx
+++ b/source/features/pinned-issues-update-time.tsx
@@ -9,6 +9,7 @@ import api from '../github-helpers/api.js';
 import {getRepo} from '../github-helpers/index.js';
 import looseParseInt from '../helpers/loose-parse-int.js';
 import observe from '../helpers/selector-observer.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 type IssueInfo = {
 	updatedAt: string;
@@ -54,6 +55,7 @@ async function update(pinnedIssues: HTMLElement[]): Promise<void> {
 }
 
 async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	observe('.pinned-issue-item', batchedFunction(update, {delay: 100}), {signal});
 }
 
diff --git a/source/features/pr-branch-auto-delete.tsx b/source/features/pr-branch-auto-delete.tsx
index c203772c..dd19188c 100644
--- a/source/features/pr-branch-auto-delete.tsx
+++ b/source/features/pr-branch-auto-delete.tsx
@@ -7,13 +7,9 @@ import * as pageDetect from 'github-url-detection';
 import features from '../feature-manager.js';
 import onPrMerge from '../github-events/on-pr-merge.js';
 import featureLink from '../helpers/feature-link.js';
-import {canEditEveryComment} from './quick-comment-edit.js';
 import {getBranches} from '../github-helpers/pr-branches.js';
 import matchesAnyPattern from '../helpers/matches-any-patterns.js';
 
-// TODO: Not an exact match; Moderators can edit comments but not create releases
-const canCreateRelease = canEditEveryComment;
-
 const exceptions = [
 	'dev',
 	'develop',
@@ -58,12 +54,10 @@ void features.add(import.meta.url, {
 	asLongAs: [
 		pageDetect.isPRConversation,
 		pageDetect.isOpenPR,
-		canCreateRelease,
 	],
 	additionalListeners: [
 		onPrMerge,
 	],
-	awaitDomReady: true, // TODO: Remove after https://github.com/refined-github/refined-github/issues/6566
 	onlyAdditionalListeners: true,
 	init,
 });
diff --git a/source/features/pr-filters.tsx b/source/features/pr-filters.tsx
index fdbdb188..b55242ff 100644
--- a/source/features/pr-filters.tsx
+++ b/source/features/pr-filters.tsx
@@ -9,6 +9,7 @@ import api from '../github-helpers/api.js';
 import observe from '../helpers/selector-observer.js';
 import {cacheByRepo} from '../github-helpers/index.js';
 import HasChecks from './pr-filters.gql';
+import {expectToken} from '../github-helpers/github-token.js';
 
 const reviewsFilterSelector = '#reviews-select-menu';
 
@@ -86,6 +87,7 @@ async function addChecksFilter(reviewsFilter: HTMLElement): Promise<void> {
 }
 
 async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	observe(reviewsFilterSelector, addChecksFilter, {signal});
 	observe(`${reviewsFilterSelector} .SelectMenu-list`, addDraftFilter, {signal});
 }
diff --git a/source/features/previous-version.tsx b/source/features/previous-version.tsx
index b46026d2..1e0d485a 100644
--- a/source/features/previous-version.tsx
+++ b/source/features/previous-version.tsx
@@ -9,6 +9,7 @@ import api from '../github-helpers/api.js';
 import GitHubFileURL from '../github-helpers/github-file-url.js';
 import previousVersionQuery from './previous-version.gql';
 import onReactPageUpdate from '../github-events/on-react-page-update.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 async function getPreviousCommitForFile(pathname: string): Promise<string | undefined> {
 	const {user, repository, branch, filePath} = new GitHubFileURL(pathname);
@@ -89,6 +90,7 @@ async function add(historyButton: HTMLAnchorElement, {signal}: SignalAsOptions):
 }
 
 async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	observe('a:has([data-component="leadingVisual"] svg.octicon-history)', add, {signal});
 }
 
diff --git a/source/features/profile-gists-link.tsx b/source/features/profile-gists-link.tsx
index d742e76c..9bee194f 100644
--- a/source/features/profile-gists-link.tsx
+++ b/source/features/profile-gists-link.tsx
@@ -11,6 +11,7 @@ import createDropdownItem from '../github-helpers/create-dropdown-item.js';
 import observe from '../helpers/selector-observer.js';
 import GetGistCount from './profile-gists-link.gql';
 import {repoUnderlineNavDropdownUl} from '../github-helpers/selectors.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 const gistCount = new CachedFunction('gist-count', {
 	async updater(username: string): Promise<number> {
@@ -70,6 +71,7 @@ async function appendTab(navigationBar: Element): Promise<void> {
 }
 
 async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	observe('nav[aria-label="User"] > ul', appendTab, {signal});
 }
 
diff --git a/source/features/quick-comment-edit.tsx b/source/features/quick-comment-edit.tsx
index fd18640a..adea5804 100644
--- a/source/features/quick-comment-edit.tsx
+++ b/source/features/quick-comment-edit.tsx
@@ -7,19 +7,20 @@ import memoize from 'memoize';
 import observe from '../helpers/selector-observer.js';
 import features from '../feature-manager.js';
 import {isArchivedRepoAsync} from '../github-helpers/index.js';
+import {userIsModerator} from '../github-helpers/get-user-permission.js';
 
 // The signal is only used to memoize calls on the current page. A new page load will use a new signal.
 const isIssueIneditable = memoize(
 	// If .js-pick-reaction is the first child, `reaction-menu` doesn't exist, which means that the conversation is locked.
 	// However, if you can edit every comment, you can still edit the comment
-	(_signal: AbortSignal | undefined): boolean => elementExists('.js-pick-reaction:first-child') && !canEditEveryComment(),
+	async (_signal: AbortSignal | undefined): Promise<boolean> => elementExists('.js-pick-reaction:first-child') && !await userIsModerator(),
 	{
 		cache: new WeakMap(),
 	},
 );
 
-function addQuickEditButton(commentDropdown: HTMLDetailsElement, {signal}: SignalAsOptions): void {
-	if (isIssueIneditable(signal)) {
+async function addQuickEditButton(commentDropdown: HTMLDetailsElement, {signal}: SignalAsOptions): Promise<void> {
+	if (await isIssueIneditable(signal)) {
 		features.unload(import.meta.url);
 		return;
 	}
@@ -49,27 +50,13 @@ function addQuickEditButton(commentDropdown: HTMLDetailsElement, {signal}: Signa
 	);
 }
 
-export function canEditEveryComment(): boolean {
-	return elementExists([
-		// If you can lock conversations, you have write access
-		'.lock-toggle-link > .octicon-lock',
-
-		// Some pages like `isPRFiles` does not have a lock button
-		// These elements only exist if you commented on the page
-		'[aria-label^="You have been invited to collaborate"]',
-		'[aria-label^="You are the owner"]',
-		'[title^="You are a maintainer"]',
-		'[title^="You are a collaborator"]',
-	]) || pageDetect.canUserEditRepo();
-}
-
 async function init(signal: AbortSignal): Promise<void> {
 	if (await isArchivedRepoAsync()) {
 		return;
 	}
 
 	// If true then the resulting selector will match all comments, otherwise it will only match those made by you
-	const preSelector = canEditEveryComment() ? '' : '.current-user';
+	const preSelector = await userIsModerator() ? '' : '.current-user';
 
 	observe(preSelector + '.js-comment.unminimized-comment .timeline-comment-actions details.position-relative', addQuickEditButton, {signal});
 }
diff --git a/source/features/quick-repo-deletion.tsx b/source/features/quick-repo-deletion.tsx
index 67ef855e..8d9fdf98 100644
--- a/source/features/quick-repo-deletion.tsx
+++ b/source/features/quick-repo-deletion.tsx
@@ -9,6 +9,7 @@ import delegate, {DelegateEvent} from 'delegate-it';
 import features from '../feature-manager.js';
 import {buildRepoURL, getForkedRepo, getRepo} from '../github-helpers/index.js';
 import observe from '../helpers/selector-observer.js';
+import {userIsAdmin} from '../github-helpers/get-user-permission.js';
 import {expectTokenScope} from '../github-helpers/github-token.js';
 import addNotice from '../github-widgets/notice-bar.js';
 import api from '../github-helpers/api.js';
@@ -17,11 +18,6 @@ import showToast from '../github-helpers/toast.js';
 const tooltip = 'Instant deletion: shift-alt-click';
 const buttonHashSelector = '#dialog-show-repo-delete-menu-dialog';
 
-// TODO: Replace with https://github.com/refined-github/github-url-detection/issues/85
-async function canUserDeleteRepository(): Promise<boolean> {
-	return Boolean(await elementReady('nav [data-content="Settings"]'));
-}
-
 // Only if the repository hasn't been starred
 async function isRepoUnpopular(): Promise<boolean> {
 	const counter = await elementReady('.starring-container .Counter');
@@ -120,7 +116,7 @@ void features.add(import.meta.url, {
 	asLongAs: [
 		pageDetect.isRepoRoot,
 		pageDetect.isForkedRepo,
-		canUserDeleteRepository,
+		userIsAdmin,
 		isRepoUnpopular,
 	],
 	init: initRepoRoot,
diff --git a/source/features/quick-review-comment-deletion.tsx b/source/features/quick-review-comment-deletion.tsx
index b7eca56d..8a6208e7 100644
--- a/source/features/quick-review-comment-deletion.tsx
+++ b/source/features/quick-review-comment-deletion.tsx
@@ -18,7 +18,7 @@ function onButtonClick({delegateTarget: button}: DelegateEvent): void {
 			.click();
 	} catch (error) {
 		void showToast(new Error('Feature broken. Please open an issue with the link found in the console'));
-		features.log.error(import.meta.url, (error as Error).message);
+		throw error;
 	}
 }
 
diff --git a/source/features/release-download-count.tsx b/source/features/release-download-count.tsx
index eb490713..fc89edac 100644
--- a/source/features/release-download-count.tsx
+++ b/source/features/release-download-count.tsx
@@ -16,6 +16,7 @@ import features from '../feature-manager.js';
 import api from '../github-helpers/api.js';
 import observe from '../helpers/selector-observer.js';
 import {createHeatIndexFunction} from '../helpers/math.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 type Asset = {
 	name: string;
@@ -73,7 +74,9 @@ async function addCounts(assetsList: HTMLElement): Promise<void> {
 	}
 }
 
-function init(signal: AbortSignal): void {
+async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
+
 	observe('.Box-footer .Box--condensed:has(.octicon-package)', addCounts, {signal});
 }
 
diff --git a/source/features/releases-dropdown.tsx b/source/features/releases-dropdown.tsx
index 7e2149b2..7297ed29 100644
--- a/source/features/releases-dropdown.tsx
+++ b/source/features/releases-dropdown.tsx
@@ -8,6 +8,7 @@ import features from '../feature-manager.js';
 import {buildRepoURL, cacheByRepo} from '../github-helpers/index.js';
 import observe from '../helpers/selector-observer.js';
 import GetReleases from './releases-dropdown.gql';
+import {expectToken} from '../github-helpers/github-token.js';
 
 const getReleases = new CachedFunction('releases', {
 	async updater(): Promise<string[]> {
@@ -46,6 +47,7 @@ async function addList(searchField: HTMLInputElement): Promise<void> {
 
 const searchFieldSelector = 'input#release-filter';
 async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	observe(searchFieldSelector, addList, {signal});
 	delegate(searchFieldSelector, 'input', selectionHandler, {signal});
 }
diff --git a/source/features/repo-header-info.tsx b/source/features/repo-header-info.tsx
index cf6e12f8..04813349 100644
--- a/source/features/repo-header-info.tsx
+++ b/source/features/repo-header-info.tsx
@@ -13,6 +13,7 @@ import api from '../github-helpers/api.js';
 import GetRepositoryInfo from './repo-header-info.gql';
 import {buildRepoURL, cacheByRepo} from '../github-helpers/index.js';
 import abbreviateNumber from '../helpers/abbreviate-number.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 type RepositoryInfo = {
 	isFork: boolean;
@@ -72,7 +73,8 @@ async function add(repoLink: HTMLAnchorElement): Promise<void> {
 	}
 }
 
-function init(signal: AbortSignal): void {
+async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	observe('.AppHeader-context-full li:last-child a.AppHeader-context-item', add, {signal});
 }
 
diff --git a/source/features/repo-wide-file-finder.tsx b/source/features/repo-wide-file-finder.tsx
index 407be229..d292e416 100644
--- a/source/features/repo-wide-file-finder.tsx
+++ b/source/features/repo-wide-file-finder.tsx
@@ -6,8 +6,10 @@ import getDefaultBranch from '../github-helpers/get-default-branch.js';
 import {buildRepoURL} from '../github-helpers/index.js';
 import getCurrentGitRef from '../github-helpers/get-current-git-ref.js';
 import {registerHotkey} from '../github-helpers/hotkey.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	const ref = getCurrentGitRef() ?? await getDefaultBranch();
 	const url = buildRepoURL('tree', ref) + '?search=1';
 	registerHotkey('t', url, {signal});
diff --git a/source/features/restore-file.tsx b/source/features/restore-file.tsx
index 07d5aff4..52df805f 100644
--- a/source/features/restore-file.tsx
+++ b/source/features/restore-file.tsx
@@ -9,6 +9,7 @@ import showToast from '../github-helpers/toast.js';
 import {getBranches} from '../github-helpers/pr-branches.js';
 import getPrInfo from '../github-helpers/get-pr-info.js';
 import observe from '../helpers/selector-observer.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 async function getMergeBaseReference(): Promise<string> {
 	const {base, head} = getBranches();
@@ -86,22 +87,18 @@ async function discardChanges(progress: (message: string) => void, originalFileN
 async function handleClick(event: DelegateEvent<MouseEvent, HTMLButtonElement>): Promise<void> {
 	const menuItem = event.delegateTarget;
 
-	try {
-		const [originalFileName, newFileName = originalFileName] = menuItem
-			.closest('[data-path]')!
-			.querySelector('.Link--primary')!
-			.textContent
-			.split(' → ');
-		await showToast(async progress => discardChanges(progress!, originalFileName, newFileName), {
-			message: 'Loading info…',
-			doneMessage: 'Changes discarded',
-		});
-
-		// Hide file from view
-		menuItem.closest('.file')!.remove();
-	} catch (error) {
-		features.log.error(import.meta.url, error);
-	}
+	const [originalFileName, newFileName = originalFileName] = menuItem
+		.closest('[data-path]')!
+		.querySelector('.Link--primary')!
+		.textContent
+		.split(' → ');
+	await showToast(async progress => discardChanges(progress!, originalFileName, newFileName), {
+		message: 'Loading info…',
+		doneMessage: 'Changes discarded',
+	});
+
+	// Hide file from view
+	menuItem.closest('.file')!.remove();
 }
 
 function add(editFile: HTMLAnchorElement): void {
@@ -116,7 +113,9 @@ function add(editFile: HTMLAnchorElement): void {
 	);
 }
 
-function init(signal: AbortSignal): void {
+async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
+
 	observe('.js-file-header-dropdown a[aria-label^="Change this"]', add, {signal});
 
 	// `capture: true` required to be fired before GitHub's handlers
diff --git a/source/features/rgh-improve-new-issue-form.tsx b/source/features/rgh-improve-new-issue-form.tsx
index 71ff103c..145f9980 100644
--- a/source/features/rgh-improve-new-issue-form.tsx
+++ b/source/features/rgh-improve-new-issue-form.tsx
@@ -6,7 +6,7 @@ import * as pageDetect from 'github-url-detection';
 import features from '../feature-manager.js';
 import openOptions from '../helpers/open-options.js';
 import clearCacheHandler from '../helpers/clear-cache-handler.js';
-import {expectTokenScope} from '../github-helpers/github-token.js';
+import {baseApiFetch} from '../github-helpers/github-token.js';
 import {getToken} from '../options-storage.js';
 import {isRefinedGitHubRepo} from '../github-helpers/index.js';
 
@@ -17,24 +17,29 @@ function addNotice(adjective: JSX.Element | string): void {
 	$('#issue_body_template_name')!.before(
 		<div className="flash flash-error h3 my-9" style={{animation: 'pulse-in 0.3s 2'}}>
 			<p>
-				Your Personal Access Token is {adjective}. Some Refined GitHub features will not work without it.
+				Your token is {adjective}. Many Refined GitHub features don't work without it.
 				You can update it <button className="btn-link" type="button" onClick={openOptions as unknown as React.MouseEventHandler}>in the options</button>.
 			</p>
-			<p>Add a valid token and confirm the problem still occurs, before submitting this issue.</p>
+			<p>Before creating this issue, add a valid token and confirm the problem still occurs.</p>
 		</div>,
 	);
 }
 
 async function checkToken(): Promise<void> {
-	if (!await getToken()) {
+	const token = await getToken();
+	if (!token) {
 		addNotice('missing');
 		return;
 	}
 
 	try {
-		await expectTokenScope('repo');
-	} catch {
-		addNotice('invalid, expired or insufficient permissions');
+		await baseApiFetch({apiBase: 'https://api.github.com/', path: 'user', token});
+	} catch (error) {
+		if (!navigator.onLine || (error as any)?.message === 'Failed to fetch') {
+			return;
+		}
+
+		addNotice('invalid or expired');
 		return;
 	}
 
diff --git a/source/features/show-associated-branch-prs-on-fork.tsx b/source/features/show-associated-branch-prs-on-fork.tsx
index b7fdde20..f77d02c9 100644
--- a/source/features/show-associated-branch-prs-on-fork.tsx
+++ b/source/features/show-associated-branch-prs-on-fork.tsx
@@ -14,6 +14,7 @@ import features from '../feature-manager.js';
 import api from '../github-helpers/api.js';
 import {cacheByRepo} from '../github-helpers/index.js';
 import AssociatedPullRequests from './show-associated-branch-prs-on-fork.gql';
+import {expectToken} from '../github-helpers/github-token.js';
 
 type PullRequest = {
 	timelineItems: {
@@ -89,7 +90,8 @@ async function addLink(branch: HTMLElement): Promise<void> {
 	);
 }
 
-function init(signal: AbortSignal): void {
+async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	// Memoize because it's being called twice for each. Ideally this should be part of the selector observer
 	// https://github.com/refined-github/refined-github/pull/7194#issuecomment-1894972091
 	observe('react-app[app-name=repos-branches] a[class^=BranchName-] div[title]', memoize(addLink), {signal});
diff --git a/source/features/show-names.tsx b/source/features/show-names.tsx
index ea976876..52b26347 100644
--- a/source/features/show-names.tsx
+++ b/source/features/show-names.tsx
@@ -9,6 +9,7 @@ import {getUsername, isUsernameAlreadyFullName} from '../github-helpers/index.js
 import observe from '../helpers/selector-observer.js';
 import {removeTextNodeContaining} from '../helpers/dom-utils.js';
 import {usernameLinksSelector} from '../github-helpers/selectors.js';
+import {expectToken} from '../github-helpers/github-token.js';
 
 async function dropExtraCopy(link: HTMLAnchorElement): Promise<void> {
 	// Drop 'commented' label to shorten the copy
@@ -67,7 +68,7 @@ async function updateLinks(found: HTMLAnchorElement[]): Promise<void> {
 	}
 }
 
-const updateLink = batchedFunction(updateLinks, {delay: 100});
+const updateLink = batchedFunction(updateLinks, {delay: 200});
 
 function updateDom(link: HTMLAnchorElement): void {
 	// `dropExtraCopy` is async so that errors in this part don't break the entire feature
@@ -76,7 +77,8 @@ function updateDom(link: HTMLAnchorElement): void {
 	updateLink(link);
 }
 
-function init(signal: AbortSignal): void {
+async function init(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	document.body.classList.add('rgh-show-names');
 	observe(usernameLinksSelector, updateDom, {signal});
 }
diff --git a/source/features/show-open-prs-of-forks.tsx b/source/features/show-open-prs-of-forks.tsx
index cc26157e..99859279 100644
--- a/source/features/show-open-prs-of-forks.tsx
+++ b/source/features/show-open-prs-of-forks.tsx
@@ -40,6 +40,7 @@ const countPRs = new CachedFunction('prs-on-forked-repo', {
 // eslint-disable-next-line ts/no-restricted-types
 async function getPRs(): Promise<[prCount: number, url: string] | []> {
 	// Wait for the tab bar to be loaded
+	// Maybe replace with https://github.com/refined-github/github-url-detection/issues/85
 	await elementReady('.UnderlineNav-body');
 	if (!pageDetect.canUserEditRepo()) {
 		return [];
diff --git a/source/features/tags-on-commits-list.tsx b/source/features/tags-on-commits-list.tsx
index 0a608707..7f12ce9f 100644
--- a/source/features/tags-on-commits-list.tsx
+++ b/source/features/tags-on-commits-list.tsx
@@ -9,6 +9,7 @@ import api from '../github-helpers/api.js';
 import {getCommitHash} from './mark-merge-commits-in-list.js';
 import {buildRepoURL, getRepo} from '../github-helpers/index.js';
 import GetTagsOnCommit from './tags-on-commits-list.gql';
+import {expectToken} from '../github-helpers/github-token.js';
 
 type CommitTags = Record<string, string[]>;
 
@@ -84,6 +85,7 @@ async function getTags(lastCommit: string, after?: string): Promise<CommitTags>
 }
 
 async function init(): Promise<void | false> {
+	await expectToken();
 	const cacheKey = `tags:${getRepo()!.nameWithOwner}`;
 
 	const commitsOnPage = $$('[data-testid="commit-row-item"]');
diff --git a/source/features/unfinished-comments.tsx b/source/features/unfinished-comments.tsx
index 0bb5e191..e3b45cae 100644
--- a/source/features/unfinished-comments.tsx
+++ b/source/features/unfinished-comments.tsx
@@ -8,11 +8,15 @@ let submitting: number | undefined;
 
 const prefix = '✏️ Comment - ';
 
+function isFieldDirty(field: HTMLTextAreaElement): boolean {
+	return field.matches('[class*="Textarea__StyledTextarea"]')
+		? field.value.length > 0 // React fields update both value and textContent, so default to "filled === dirty"
+		: field.value !== field.textContent;
+}
+
 function hasDraftComments(): boolean {
 	// `[id^="convert-to-issue-body"]` excludes the hidden pre-filled textareas created when opening the dropdown menu of review comments
-	return $$('textarea:not([id^="convert-to-issue-body"])').some(textarea =>
-		textarea.value !== textarea.textContent, // Exclude comments being edited but not yet changed (and empty comment fields)
-	);
+	return $$('textarea:not([id^="convert-to-issue-body"])').some(f => isFieldDirty(f));
 }
 
 function disableOnSubmit(): void {
@@ -28,7 +32,7 @@ function updateDocumentTitle(): void {
 	}
 
 	if (document.visibilityState === 'hidden' && hasDraftComments()) {
-		document.title = '✏️ Comment - ' + document.title;
+		document.title = prefix + document.title;
 	} else if (document.title.startsWith(prefix)) {
 		document.title = document.title.replace(prefix, '');
 	}
diff --git a/source/features/unreleased-commits.tsx b/source/features/unreleased-commits.tsx
index 0c5782a3..93358d6d 100644
--- a/source/features/unreleased-commits.tsx
+++ b/source/features/unreleased-commits.tsx
@@ -23,6 +23,7 @@ import abbreviateString from '../helpers/abbreviate-string.js';
 import {wrapAll} from '../helpers/dom-utils.js';
 import {groupButtons} from '../github-helpers/group-buttons.js';
 import {expectToken} from '../github-helpers/github-token.js';
+import {userHasPushAccess} from '../github-helpers/get-user-permission.js';
 
 type RepoPublishState = {
 	latestTag: string | false;
@@ -39,11 +40,6 @@ type Tags = {
 	};
 };
 
-// TODO: This detects admins, but could detect more
-function canUserCreateReleases(): boolean {
-	return elementExists('nav [data-content="Settings"]');
-}
-
 const undeterminableAheadBy = Number.MAX_SAFE_INTEGER; // For when the branch is ahead by more than 20 commits #5505
 
 const repoPublishState = new CachedFunction('tag-ahead-by', {
@@ -102,7 +98,7 @@ async function createLink(
 
 async function createLinkGroup(latestTag: string, aheadBy: number): Promise<HTMLElement> {
 	const link = await createLink(latestTag, aheadBy);
-	if (!canUserCreateReleases()) {
+	if (!userHasPushAccess()) {
 		return link;
 	}
 
diff --git a/source/features/update-pr-from-base-branch.tsx b/source/features/update-pr-from-base-branch.tsx
index 94238970..dbbdea4d 100644
--- a/source/features/update-pr-from-base-branch.tsx
+++ b/source/features/update-pr-from-base-branch.tsx
@@ -28,11 +28,10 @@ async function mergeBranches(): Promise<AnyObject> {
 async function handler({delegateTarget: button}: DelegateEvent<MouseEvent, HTMLButtonElement>): Promise<void> {
 	button.disabled = true;
 	await showToast(async () => {
+		// Reads Error#message or GitHub’s "message" response
 		const response = await mergeBranches().catch(error => error);
 		if (response instanceof Error || !response.ok) {
-			features.log.error(import.meta.url, response);
-			// Reads Error#message or GitHub’s "message" response
-			throw new Error(`Error updating the branch: ${response.message as string}`);
+			throw new Error(`Error updating the branch: ${response.message as string}`, {cause: response});
 		}
 	}, {
 		message: 'Updating branch…',
diff --git a/source/features/useful-not-found-page.tsx b/source/features/useful-not-found-page.tsx
index 1505cbfc..f1ae6f33 100644
--- a/source/features/useful-not-found-page.tsx
+++ b/source/features/useful-not-found-page.tsx
@@ -11,6 +11,7 @@ import getDefaultBranch from '../github-helpers/get-default-branch.js';
 import {getCleanPathname, isUrlReachable} from '../github-helpers/index.js';
 import observe from '../helpers/selector-observer.js';
 import GetLatestCommitToFile from './useful-not-found-page.gql';
+import {expectToken} from '../github-helpers/github-token.js';
 
 type File = {
 	previous_filename?: string;
@@ -184,7 +185,9 @@ async function showGitObjectHistoryOnRepo(description: HTMLDivElement): Promise<
 	}
 }
 
-function init(): void {
+async function init(): Promise<void> {
+	await expectToken();
+
 	void showDefaultBranchLink();
 	void showGitObjectHistory();
 }
@@ -201,7 +204,8 @@ async function initPRCommit(): Promise<void | false> {
 	);
 }
 
-function initRepoFile(signal: AbortSignal): void {
+async function initRepoFile(signal: AbortSignal): Promise<void> {
+	await expectToken();
 	observe('#repos-header-breadcrumb-wide-heading + ol a', crossIfNonExistent, {signal});
 	observe('main div[data-testid="eror-404-description"]', showGitObjectHistoryOnRepo, {signal});	// "eror" as misspelled by GitHub
 }
diff --git a/source/features/user-local-time.tsx b/source/features/user-local-time.tsx
index 661a5985..7ca62ec0 100644
--- a/source/features/user-local-time.tsx
+++ b/source/features/user-local-time.tsx
@@ -156,10 +156,7 @@ async function insertUserLocalTime(hovercardContainer: Element): Promise<void> {
 	void display({datePromise, placeholder, container});
 }
 
-const selector = [
-	'.js-hovercard-content .Popover-message div.d-flex.mt-3.overflow-hidden > div.d-flex',
-	'.js-hovercard-content .Popover-message div.d-flex.mt-3 > div.overflow-hidden.ml-3', // GHE 2022/06/24
-];
+const selector = '.js-hovercard-content .Popover-message div.d-flex.mt-3.overflow-hidden > div.d-flex';
 
 function init(signal: AbortSignal): void {
 	observe(selector, insertUserLocalTime, {signal});
diff --git a/source/features/visit-tag.tsx b/source/features/visit-tag.tsx
index 0aa4b9be..0a2b86d1 100644
--- a/source/features/visit-tag.tsx
+++ b/source/features/visit-tag.tsx
@@ -23,8 +23,7 @@ async function addLink(branchSelector: HTMLButtonElement): Promise<void> {
 
 	const tag = branchSelector.getAttribute('aria-label')?.replace(/ tag$/, '');
 	if (!tag) {
-		features.log.error(import.meta.url, 'Tag not found in DOM. The feature needs to be updated');
-		return;
+		throw new Error('Tag not found in DOM. The feature needs to be updated');
 	}
 
 	wrapAll(
diff --git a/source/github-events/on-field-keydown.tsx b/source/github-events/on-field-keydown.tsx
index fc8e491e..591c579e 100644
--- a/source/github-events/on-field-keydown.tsx
+++ b/source/github-events/on-field-keydown.tsx
@@ -7,8 +7,13 @@ function onFieldKeydown(selector: string, callback: DelegateFieldEvent, signal:
 	delegate(selector as 'textarea', 'keydown', event => {
 		const field = event.delegateTarget;
 
-		// The suggester is GitHub’s autocomplete dropdown
-		if (elementExists('.suggester', field.form!) || event.isComposing) {
+		if (
+			event.isComposing
+			// New autocomplete dropdown
+			|| field.hasAttribute('aria-autocomplete')
+			// Classic autocomplete dropdown
+			|| elementExists('.suggester', field.form!)
+		) {
 			return;
 		}
 
@@ -21,7 +26,7 @@ function onFieldKeydown(selector: string, callback: DelegateFieldEvent, signal:
 }
 
 export function onCommentFieldKeydown(callback: DelegateFieldEvent, signal: AbortSignal): void {
-	onFieldKeydown('.js-comment-field, #commit-description-textarea, #merge_message_field', callback, signal);
+	onFieldKeydown('textarea', callback, signal);
 }
 
 export function onConversationTitleFieldKeydown(callback: DelegateFieldEvent, signal: AbortSignal): void {
diff --git a/source/github-helpers/api.ts b/source/github-helpers/api.ts
index b0dba25b..498cc8a8 100644
--- a/source/github-helpers/api.ts
+++ b/source/github-helpers/api.ts
@@ -166,6 +166,10 @@ const v4uncached = async (
 ): Promise<AnyObject> => {
 	const personalToken = await getToken();
 
+	if (!personalToken) {
+		throw new RefinedGitHubAPIError('Personal token required for this feature');
+	}
+
 	// TODO: Remove automatic usage of globals via `getRepo()`
 	// https://github.com/refined-github/refined-github/issues/5821
 	const currentRepoIfAny = getRepo(); // Don't destructure, it's `undefined` outside repos
diff --git a/source/github-helpers/dom-formatters.tsx b/source/github-helpers/dom-formatters.tsx
index fa899f39..d6d2b6a0 100644
--- a/source/github-helpers/dom-formatters.tsx
+++ b/source/github-helpers/dom-formatters.tsx
@@ -1,5 +1,5 @@
 import React from 'dom-chef';
-import {$$, elementExists} from 'select-dom';
+import {elementExists} from 'select-dom';
 import zipTextNodes from 'zip-text-nodes';
 import {applyToLink} from 'shorten-repo-url';
 import {linkifyUrlsToDom} from 'linkify-urls';
@@ -62,13 +62,14 @@ export function linkifyIssues(
 	zipTextNodes(element, linkified);
 }
 
-export function linkifyURLs(element: Element): Element[] | void {
+export function linkifyURLs(element: Element): void {
 	if (element.textContent.length < 15) { // Must be long enough for a URL
 		return;
 	}
 
 	if (elementExists(linkifiedURLSelector, element)) {
-		return $$(linkifiedURLSelector, element);
+		console.warn('Links already exist', element);
+		throw new Error('Links already exist');
 	}
 
 	const linkified = linkifyUrlsToDom(element.textContent, {
diff --git a/source/github-helpers/get-user-permission.ts b/source/github-helpers/get-user-permission.ts
new file mode 100644
index 00000000..2cf408b6
--- /dev/null
+++ b/source/github-helpers/get-user-permission.ts
@@ -0,0 +1,88 @@
+import {CachedFunction} from 'webext-storage-cache';
+import {canUserEditRepo} from 'github-url-detection';
+import {elementExists} from 'select-dom';
+
+import {hasToken} from '../options-storage.js';
+import {getRepo} from './index.js';
+import api from './api.js';
+
+/*
+From https://docs.github.com/en/graphql/reference/enums#repositorypermission
+
+ADMIN: Can read, clone, and push to this repository. Can also manage issues, pull requests, and repository settings, including adding collaborators.
+
+MAINTAIN: Can read, clone, and push to this repository. They can also manage issues, pull requests, and some repository settings.
+
+READ: Can read and clone this repository. Can also open and comment on issues and pull requests.
+
+TRIAGE: Can read and clone this repository. Can also manage issues and pull requests.
+
+WRITE: Can read, clone, and push to this repository. Can also manage issues and pull requests.
+*/
+type RepositoryPermission = 'ADMIN' | 'MAINTAIN' | 'READ' | 'TRIAGE' | 'WRITE';
+
+async function getViewerPermission(): Promise<RepositoryPermission> {
+	if (getRepo() === null) {
+		throw new Error('This can only be called on a repository page');
+	}
+
+	// Faster DOM-based check, if the DOM is available.
+	// This can be cached because it's the highest level of access
+	if (canUserEditRepo()) {
+		return 'ADMIN';
+	}
+
+	if (!await hasToken()) {
+		return 'READ';
+	}
+
+	try {
+		const {repository} = await api.v4(`
+			repository() {
+				viewerPermission
+			}
+		`);
+
+		return repository.viewerPermission;
+	} catch {
+		return 'READ';
+	}
+}
+
+const viewerPermission = new CachedFunction('viewer-permission', {
+	updater: getViewerPermission,
+	cacheKey: () => getRepo()?.nameWithOwner ?? '',
+});
+
+export async function userIsAdmin(): Promise<boolean> {
+	const repoAccess = await viewerPermission.get();
+	return repoAccess === 'ADMIN';
+}
+
+/** Check if the user has complete write access to the repo (but no access to the repo Settings) */
+export async function userHasPushAccess(): Promise<boolean> {
+	const repoAccess = await viewerPermission.get();
+	return repoAccess !== 'READ' && repoAccess !== 'TRIAGE';
+}
+
+/** Check if the user can edit all comments and comment on locked issues on the current repo */
+export async function userIsModerator(): Promise<boolean> {
+	// Faster DOM-based check, if the DOM is available.
+	// This cannot be cached in `viewerPermission` because it guarantees you have *at least* moderation access, but can't tell if you have *more* capabilities
+	const domCheck = elementExists([
+		'.lock-toggle-link > .octicon-lock',
+		'[aria-label^="You have been invited to collaborate"]',
+		'[title^="You are a member"]',
+		'[title^="You are a maintainer"]',
+		'[title^="You are a collaborator"]',
+
+		// Don't check for admin access here. If the user has admin access, the DOM check in `viewerPermission` will use the DOM and be cached anyway
+	]);
+
+	if (domCheck) {
+		return true;
+	}
+
+	const repoAccess = await viewerPermission.get();
+	return repoAccess !== 'READ';
+}
diff --git a/source/github-helpers/github-token.ts b/source/github-helpers/github-token.ts
index d87c0a02..9c2d7391 100644
--- a/source/github-helpers/github-token.ts
+++ b/source/github-helpers/github-token.ts
@@ -50,12 +50,26 @@ export const tokenUser = new CachedFunction('token-user', {
 });
 
 export async function expectToken(): Promise<string> {
-	const personalToken = await getToken();
-	if (!personalToken) {
+	const token = await getToken();
+	if (!token) {
 		throw new Error('Personal token required for this feature');
 	}
 
-	return personalToken;
+	return token;
+}
+
+export async function hasValidGitHubComToken(token?: string): Promise<boolean> {
+	token ??= await getToken();
+	if (!token) {
+		return false;
+	}
+
+	try {
+		await baseApiFetch({apiBase: 'https://api.github.com/', path: '', token});
+		return true;
+	} catch {
+		return false;
+	}
 }
 
 function parseTokenScopes(headers: Headers): string[] {
@@ -79,7 +93,7 @@ function parseTokenScopes(headers: Headers): string[] {
 }
 
 export async function getTokenScopes(apiBase: string, personalToken: string): Promise<string[]> {
-	const response = await baseApiFetch({apiBase, token: personalToken, path: '/'});
+	const response = await baseApiFetch({apiBase, token: personalToken, path: ''});
 	return parseTokenScopes(response.headers);
 }
 
diff --git a/source/github-helpers/index.ts b/source/github-helpers/index.ts
index 429564a5..4feafcef 100644
--- a/source/github-helpers/index.ts
+++ b/source/github-helpers/index.ts
@@ -51,7 +51,7 @@ export function isUsernameAlreadyFullName(username: string, realname: string): b
 		.replaceAll(/\W/g, '')
 		.toLowerCase();
 
-	return username === realname;
+	return username === realname || username.startsWith(realname);
 }
 
 const validVersion = /^[vr]?\d+(?:\.\d+)+/;
diff --git a/source/helpers/clear-cache-handler.ts b/source/helpers/clear-cache-handler.ts
index 7e427d0a..5b36e611 100644
--- a/source/helpers/clear-cache-handler.ts
+++ b/source/helpers/clear-cache-handler.ts
@@ -1,13 +1,12 @@
 import {globalCache} from 'webext-storage-cache';
 
-export default async function clearCacheHandler(event: MouseEvent): Promise<void> {
+export default async function clearCacheHandler(this: HTMLButtonElement): Promise<void> {
 	await globalCache.clear();
-	const button = event.target as HTMLButtonElement;
-	const initialText = button.textContent;
-	button.textContent = 'Cache cleared!';
-	button.disabled = true;
+	const initialText = this.textContent;
+	this.textContent = 'Cache cleared!';
+	this.disabled = true;
 	setTimeout(() => {
-		button.textContent = initialText;
-		button.disabled = false;
+		this.textContent = initialText;
+		this.disabled = false;
 	}, 2000);
 }
diff --git a/source/helpers/dom-utils.ts b/source/helpers/dom-utils.ts
index 3fcc4671..73a0cebe 100644
--- a/source/helpers/dom-utils.ts
+++ b/source/helpers/dom-utils.ts
@@ -89,7 +89,7 @@ const isTextNode = (node: Text | ChildNode): boolean =>
 export const isTextNodeContaining = (node: Nullable<Text | ChildNode>, expectation: RegExp | string): boolean => {
 	// Make sure only text is being considered, not links, icons, etc
 	if (!node || !isTextNode(node)) {
-		console.warn('TypeError', node);
+		console.warn('Expected Text node', node);
 		throw new TypeError(`Expected Text node, received ${String(node?.nodeName)}`);
 	}
 
@@ -102,7 +102,7 @@ export const assertNodeContent = <N extends Text | ChildNode>(node: Nullable<N>,
 		return node!;
 	}
 
-	console.warn('Error', node!.parentElement);
+	console.warn('Expected node:', node!.parentElement);
 	const content = node!.textContent.trim();
 	throw new Error(`Expected node matching ${escapeMatcher(expectation)}, found ${escapeMatcher(content)}`);
 };
diff --git a/source/helpers/errors.ts b/source/helpers/errors.ts
new file mode 100644
index 00000000..3596ab27
--- /dev/null
+++ b/source/helpers/errors.ts
@@ -0,0 +1,101 @@
+import {isEnterprise} from 'github-url-detection';
+import memoize from 'memoize';
+
+const warnOnce = memoize(console.warn, {cacheKey: JSON.stringify});
+
+let loggingEnabled = true;
+
+export function disableErrorLogging(): void {
+	loggingEnabled = false;
+}
+
+const {version} = chrome.runtime.getManifest();
+
+const fineGrainedTokenSuggestion = 'Please use a GitHub App, OAuth App, or a personal access token with fine-grained permissions.';
+const preferredMessage = 'Refined GitHub does not support per-organization fine-grained tokens. https://github.com/refined-github/refined-github/wiki/Security';
+
+// reads from path like assets/features/NAME.js
+function parseFeatureNameFromStack(stack: string): FeatureID | undefined {
+	// The stack may show other features due to cross-feature imports, but we want the top-most caller so we need to reverse it
+	const match = stack
+		.split('\n')
+		.toReversed()
+		.join('\n')
+		.match(/assets\/features\/(.+)\.js/);
+	return match?.[1] as FeatureID | undefined;
+}
+
+/* Log errors only once */
+const loggedStacks = new Set<string>();
+
+export function logError(error: Error): void {
+	if (!loggingEnabled) {
+		return;
+	}
+
+	const {message, stack} = error;
+
+	if (message === 'Extension context invalidated.') {
+		warnOnce('ℹ️ Refined GitHub has been disabled or updated. Reload the page');
+		return;
+	}
+
+	const id = parseFeatureNameFromStack(stack!);
+
+	// Avoid duplicate errors
+	if (loggedStacks.has(stack!)) {
+		return;
+	}
+
+	loggedStacks.add(stack!);
+
+	if (message.endsWith(fineGrainedTokenSuggestion)) {
+		console.log('ℹ️', id, '→', message.replace(fineGrainedTokenSuggestion, preferredMessage));
+		return;
+	}
+
+	if (message.includes('token')) {
+		console.log('ℹ️ Refined GitHub →', message, '→', id);
+		return;
+	}
+
+	const searchIssueUrl = new URL('https://github.com/refined-github/refined-github/issues');
+	searchIssueUrl.searchParams.set('q', `is:issue is:open label:bug ${id ?? message}`);
+
+	const newIssueUrl = new URL('https://github.com/refined-github/refined-github/issues/new');
+	newIssueUrl.searchParams.set('template', '1_bug_report.yml');
+	newIssueUrl.searchParams.set('title', id ? `\`${id}\`: ${message}` : message);
+	newIssueUrl.searchParams.set('repro', location.href);
+	newIssueUrl.searchParams.set('description', [
+		'```',
+		String(error instanceof Error ? error.stack! : error).trim(),
+		'```',
+	].join('\n'));
+
+	// Don't change this to `throw Error` because Firefox doesn't show extensions' errors in the console
+	console.group(`❌ Refined GitHub: ${id ?? 'global'}`); // Safari supports only one parameter
+	console.log(`📕 ${version} ${isEnterprise() ? 'GHE →' : '→'}`, error); // One parameter improves Safari formatting
+	console.log('🔍 Search issue', searchIssueUrl.href);
+	console.log('🚨 Report issue', newIssueUrl.href);
+	console.groupEnd();
+}
+
+export function catchErrors(): void {
+	window.addEventListener('error', event => {
+		const {error} = event; // Access only once
+		// Don't use `assertError` or it'll loop
+		if (error) {
+			logError(error);
+			event.preventDefault();
+		}
+	});
+
+	addEventListener('unhandledrejection', event => {
+		const error = event.reason; // Access only once
+		// Don't use `assertError` or it'll loop
+		if (error) {
+			logError(error);
+			event.preventDefault();
+		}
+	});
+}
diff --git a/source/helpers/used-storage.ts b/source/helpers/used-storage.ts
index 7c2fb613..7a253a19 100644
--- a/source/helpers/used-storage.ts
+++ b/source/helpers/used-storage.ts
@@ -12,3 +12,10 @@ export default async function getStorageBytesInUse(area: 'local' | 'sync'): Prom
 		).length;
 	}
 }
+
+export async function hasUsedStorage(): Promise<boolean> {
+	return (
+		await getStorageBytesInUse('sync') > 0
+		|| Number(await getStorageBytesInUse('local')) > 0
+	);
+}
diff --git a/source/options-storage.ts b/source/options-storage.ts
index 39f1fe45..be328d05 100644
--- a/source/options-storage.ts
+++ b/source/options-storage.ts
@@ -40,3 +40,7 @@ export async function getToken(): Promise<string | undefined> {
 	const {personalToken} = await cachedSettings;
 	return personalToken;
 }
+
+export async function hasToken(): Promise<boolean> {
+	return Boolean(await getToken());
+}
diff --git a/source/options.css b/source/options.css
index 6552d64d..cdd86ead 100644
--- a/source/options.css
+++ b/source/options.css
@@ -104,51 +104,39 @@ details[open] > :not(summary) {
 	font-size: 11px;
 }
 
+[name='personalToken']:not(:focus) {
+	-webkit-text-security: circle;
+}
+
 .feature:not([hidden]) {
 	display: flex;
+	align-items: baseline;
+	flex-wrap: wrap;
+	gap: 0 0.4em;
 }
 
-.js-features input[type='checkbox'] {
+.feature input[type='checkbox'] {
 	flex-shrink: 0;
 }
 
-.js-features input[type='checkbox']:not(*:root) {
-	/* Selector for Chrome only http://browserhacks.com/#ch */
-	margin-inline-end: 0.6em;
-	margin-top: 2px;
-}
-
-.js-features .feature-checkbox:not(:checked) + .feature-name {
+.feature-checkbox:not(:checked) + .info .feature-name {
 	text-decoration: line-through;
 }
 
-.js-features .description {
-	opacity: 80%;
-}
-
-.feature-link {
-	margin: 0 0.6em;
+.feature:has(.feature-checkbox:disabled) > *:not(.hotfix-notice) {
+	opacity: 50%;
 }
 
-.js-features .info {
-	flex-grow: 1;
+.feature .info {
+	flex: 1;
 }
 
-.js-features kbd {
-	display: inline-block;
-	padding: 3px 5px;
-	font-size: 0.8em;
-	line-height: 10px;
-	color: #444d56;
-	vertical-align: middle;
-	background-color: #fafbfc;
-	border: 1px solid #d1d5da;
-	border-radius: 6px;
-	box-shadow: inset 0 -1px 0 #d1d5da;
+.feature .description {
+	opacity: 80%;
 }
 
-.info:has(.feature-checkbox:disabled) > *:not(.hotfix-notice) {
-	opacity: 50%;
+.feature-link {
+	margin: 0 0.6em;
 }
 
 .screenshot {
diff --git a/source/options.html b/source/options.html
index 934111a9..cdbecde5 100644
--- a/source/options.html
+++ b/source/options.html
@@ -23,7 +23,15 @@
 		<p>You should <a id="personal-token-link" href="https://github.com/settings/tokens/new?description=Refined%20GitHub&scopes=repo,read:project">generate a token</a> to ensure that every feature works correctly. You can read more about the token on <a href="https://github.com/refined-github/refined-github/wiki/Security">the wiki.</a></p>
 		<p><strong>Token-less usage is not officially supported.</strong></p>
 		<p>
-			<input type="password" name="personalToken" spellcheck="false" autocomplete="off" autocapitalize="off" size="20"  class="monospace-field">
+			<input
+				type="text"
+				name="personalToken"
+				spellcheck="false"
+				autocomplete="password"
+				autocapitalize="off"
+				size="20"
+				class="monospace-field"
+			>
 			<span id="validation"></span>
 		</p>
 		<ul data-token-type="classic">
@@ -57,18 +65,6 @@
 		<p>
 			<button id="find-feature">Start process to identify feature…</button>
 		</p>
-
-		<p>Related debugging tools:</p>
-		<p>
-			<button id="toggle-all-features">Toggle all features…</button>
-		</p>
-		<p class="toggle-all-features" hidden>
-			If you're trying to identify a feature, please use "Identify feature" instead. Refined GitHub only implements lightweight features that are helpful to most people, even if they're tiny improvements. They're meant to "blend in" and fill in the gaps of GitHub's interface. If you want to go through and only select a few improvements, you'll miss out on the best parts of Refined GitHub. Also note that new features will still be enabled by default and that some CSS-only refinements cannot be disabled.
-		</p>
-		<p class="toggle-all-features" hidden>
-			<button id="disable-all-features">Disable all features</button>
-			<button id="enable-all-features">Enable all features</button>
-		</p>
 	</details>
 
 	<details id="css">
@@ -108,6 +104,18 @@
 		<p>
 			<button id="clear-cache">Clear cache</button>
 		</p>
+
+		<p>Related debugging tools:</p>
+		<p>
+			<button id="toggle-all-features">Toggle all features…</button>
+		</p>
+		<p class="toggle-all-features" hidden>
+			If you're trying to identify a feature, please use "Identify feature" instead. Refined GitHub only implements lightweight features that are helpful to most people, even if they're tiny improvements. They're meant to "blend in" and fill in the gaps of GitHub's interface. If you want to go through and only select a few improvements, you'll miss out on the best parts of Refined GitHub. Also note that new features will still be enabled by default and that some CSS-only refinements cannot be disabled.
+		</p>
+		<p class="toggle-all-features" hidden>
+			<button id="disable-all-features">Disable all features</button>
+			<button id="enable-all-features">Enable all features</button>
+		</p>
 	</details>
 
 	<details id="hotfixes">
diff --git a/source/options.tsx b/source/options.tsx
index 4809a9d4..77a9440a 100644
--- a/source/options.tsx
+++ b/source/options.tsx
@@ -39,16 +39,15 @@ async function updateStorageUsage(area: 'sync' | 'local'): Promise<void> {
 	}
 }
 
-async function findFeatureHandler(event: Event): Promise<void> {
+async function findFeatureHandler(this: HTMLButtonElement): Promise<void> {
 	// TODO: Add support for GHE
 	const options = await perDomainOptions.getOptionsForOrigin().getAll();
 	const enabledFeatures = importedFeatures.filter(featureId => options['feature:' + featureId]);
 	await bisectState.set(enabledFeatures);
 
-	const button = event.target as HTMLButtonElement;
-	button.disabled = true;
+	this.disabled = true;
 	setTimeout(() => {
-		button.disabled = false;
+		this.disabled = false;
 	}, 10_000);
 
 	$('#find-feature-message').hidden = false;
@@ -93,8 +92,8 @@ async function showStoredCssHotfixes(): Promise<void> {
 				: cachedCSS ?? 'No CSS found in cache.';
 }
 
-function enableToggleAll({currentTarget: button}: Event): void {
-	(button as HTMLButtonElement).parentElement!.remove();
+function enableToggleAll(this: HTMLButtonElement): void {
+	this.parentElement!.remove();
 	for (const ui of $$('.toggle-all-features')) {
 		ui.hidden = false;
 	}
diff --git a/source/options/feature-list.tsx b/source/options/feature-list.tsx
index acda3e41..a287eafc 100644
--- a/source/options/feature-list.tsx
+++ b/source/options/feature-list.tsx
@@ -35,8 +35,8 @@ async function markLocalHotfixes(): Promise<void> {
 function buildFeatureCheckbox({id, description, screenshot}: FeatureMeta): HTMLElement {
 	return (
 		<div className="feature" data-text={`${id} ${description}`.toLowerCase()}>
+			<input type="checkbox" name={`feature:${id}`} id={id} className="feature-checkbox" />
 			<div className="info">
-				<input type="checkbox" name={`feature:${id}`} id={id} className="feature-checkbox" />
 				<label className="feature-name" htmlFor={id}>{id}</label>
 				{' '}
 				<a href={featureLink(id)} className="feature-link">
@@ -64,22 +64,20 @@ function summaryHandler(event: DelegateEvent<MouseEvent>): void {
 
 	event.preventDefault();
 	if (event.altKey) {
-		for (const screenshotLink of $$('.screenshot-link')) {
-			toggleScreenshot(screenshotLink.parentElement!);
+		for (const toggle of $$('input.screenshot-toggle')) {
+			toggle.checked = !toggle.checked;
 		}
 	} else {
-		const feature = event.delegateTarget.parentElement!;
-		toggleScreenshot(feature);
+		const toggle = event
+			.delegateTarget
+			.closest('.feature')!
+			.querySelector('input.screenshot-toggle')!;
+		toggle.checked = !toggle.checked;
 	}
 }
 
-function toggleScreenshot(feature: Element): void {
-	const toggle = feature.querySelector('input.screenshot-toggle')!;
-	toggle.checked = !toggle.checked;
-}
-
-function featuresFilterHandler(event: Event): void {
-	const keywords = (event.currentTarget as HTMLInputElement)
+function featuresFilterHandler(this: HTMLInputElement): void {
+	const keywords = this
 		.value
 		.toLowerCase()
 		.replaceAll(/\W/g, ' ')
@@ -104,7 +102,7 @@ export default async function initFeatureList(): Promise<void> {
 	delegate('.screenshot-link', 'click', summaryHandler);
 
 	// Filter feature list
-	$('#filter-features')!.addEventListener('input', featuresFilterHandler);
+	$('input#filter-features')!.addEventListener('input', featuresFilterHandler);
 
 	// Add feature count. CSS-only features are added approximately
 	$('.features-header').append(` (${featuresMeta.length + 25})`);
diff --git a/source/options/token-validation.tsx b/source/options/token-validation.tsx
index ba9774eb..f47a6208 100644
--- a/source/options/token-validation.tsx
+++ b/source/options/token-validation.tsx
@@ -1,6 +1,6 @@
 import {expectElement as $, $$} from 'select-dom';
 import {assertError} from 'ts-extras';
-import {SyncedForm} from 'webext-options-sync-per-domain';
+import type {SyncedForm} from 'webext-options-sync-per-domain';
 
 import {getTokenScopes, tokenUser} from '../github-helpers/github-token.js';
 
diff --git a/source/welcome.svelte b/source/welcome.svelte
index 5f193bca..0ecab428 100644
--- a/source/welcome.svelte
+++ b/source/welcome.svelte
@@ -6,7 +6,7 @@
 
 	import './helpers/target-blank-polyfill.js';
 	import optionsStorage from './options-storage.js';
-	import {baseApiFetch} from './github-helpers/github-token.js';
+	import {hasValidGitHubComToken} from './github-helpers/github-token.js';
 
 	let stepVisible = 1;
 	let stepValid = 0;
@@ -53,7 +53,7 @@
 
 	async function verifyToken() {
 		try {
-			await baseApiFetch({apiBase: 'https://api.github.com/', token: tokenInput, path: '/'});
+			await hasValidGitHubComToken(tokenInput);
 			stepValid = 3;
 			tokenError = '';
 		} catch {
@@ -112,8 +112,9 @@
 			<label for='token-input'>Paste token:</label>
 			<input
 				id='token-input'
-				type='password'
+				type='text'
 				size='10'
+				autocomplete='current-password'
 				name='personalToken'
 				bind:value={tokenInput}
 			/>


Test patch:
diff --git a/build/features.test.ts b/build/features.test.ts
index 2e6de30f..6cbed1b9 100755
--- a/build/features.test.ts
+++ b/build/features.test.ts
@@ -149,6 +149,12 @@ function validateTsx(file: FeatureFile): void {
 
 	assert(/test url/i.test(file.contents().toString()), 'Should have test URLs');
 
+	if (/api\.v4|getDefaultBranch/.test(String(file.contents())) && /observe\(|delegate\(/.test(String(file.contents()))) {
+		assert(
+			file.contents().includes('await expectToken()'),
+			`${file.id} uses the v4 API, so it should include await expectToken() in its init function`,
+		);
+	}
 	if (file.contents().includes('.addCssFeature')) {
 		assert(
 			!file.contents().includes('.add('),
diff --git a/source/github-helpers/index.test.ts b/source/github-helpers/index.test.ts
index e3a7e40c..fd82c04f 100644
--- a/source/github-helpers/index.test.ts
+++ b/source/github-helpers/index.test.ts
@@ -91,6 +91,7 @@ test('isUsernameAlreadyFullName', () => {
 	assert.isTrue(isUsernameAlreadyFullName('john-wdoe', 'John W. Doe'));
 	assert.isTrue(isUsernameAlreadyFullName('john-doe-jr', 'John Doe Jr.'));
 	assert.isTrue(isUsernameAlreadyFullName('nicolo', 'Nicolò'));
+	assert.isTrue(isUsernameAlreadyFullName('johnwashere', 'John'));
 	assert.isFalse(isUsernameAlreadyFullName('dotconnor', 'Connor Love'));
 	assert.isFalse(isUsernameAlreadyFullName('fregante ', 'Federico Brigante'));
 });