diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md
index efbc066..3861126 100644
--- a/docs/RELEASE_CHECKLIST.md
+++ b/docs/RELEASE_CHECKLIST.md
@@ -80,6 +80,7 @@ Test on Chrome Stable, Firefox Stable, Edge Stable, and current Arc. Test Safari
- [ ] On a blocked local HTML file in Chrome, choose **Allow local files**, approve Chrome's toggle, return to the file, and confirm App Notes reloads it automatically.
- [ ] Confirm local notes default to **This file**; switch to **This folder** and verify sibling-file notes appear while a different folder remains isolated.
- [ ] Restricted browser pages fail gracefully without broken controls.
+- [ ] On a tab opened before installing App Notes, the popup offers **Reload page**, and annotations work after it. Browser store pages say App Notes isn't available and offer no reload.
- [ ] Switch the operating system/browser between light and dark appearance; popup, composer, toast, markers, and notes workspace update automatically and remain legible on both light and dark host pages.
- [ ] Keyboard focus order, visible focus, screen-reader labels, and 200% zoom are usable.
- [ ] Reduced-motion mode avoids unnecessary motion.
diff --git a/entrypoints/popup/App.tsx b/entrypoints/popup/App.tsx
index 90dfe80..880ad6a 100644
--- a/entrypoints/popup/App.tsx
+++ b/entrypoints/popup/App.tsx
@@ -21,6 +21,10 @@ import {
} from '@/lib/storage';
import { openNotesWorkspace } from '@/lib/open-notes-workspace';
import { parseOpenLocalFileSettingsResult } from '@/lib/local-file-access';
+import {
+ parsePageReloadAttempt,
+ resolveUnreachablePageAvailability,
+} from '@/lib/page-availability';
import type { AnnotationCollectionIdentity } from '@/lib/annotation-scope';
interface AnnotationModeResponse {
@@ -31,9 +35,20 @@ type PageAvailability =
| 'checking'
| 'file-access-blocked'
| 'file-reload-required'
+ | 'page-reload-required'
| 'ready'
| 'unsupported';
+const PAGE_RELOAD_ATTEMPT_KEY = 'app-notes:page-reload-attempt';
+
+function getUnreachableAvailability(tabId: number, url: string): PageAvailability {
+ return resolveUnreachablePageAvailability(
+ tabId,
+ url,
+ parsePageReloadAttempt(localStorage.getItem(PAGE_RELOAD_ATTEMPT_KEY)),
+ );
+}
+
function parseAnnotationModeResponse(value: unknown): AnnotationModeResponse | null {
if (typeof value !== 'object' || value === null) return null;
const active = Reflect.get(value, 'active');
@@ -96,15 +111,14 @@ function App() {
const response = parseAnnotationModeResponse(rawResponse);
if (cancelled) return;
if (!response) {
- setAvailability(isLocalFileUrl(tab.url) ? 'file-reload-required' : 'unsupported');
+ setAvailability(getUnreachableAvailability(tab.id, tab.url));
return;
}
+ localStorage.removeItem(PAGE_RELOAD_ATTEMPT_KEY);
setActive(response.active);
setAvailability('ready');
} catch {
- if (!cancelled) {
- setAvailability(isLocalFileUrl(tab.url) ? 'file-reload-required' : 'unsupported');
- }
+ if (!cancelled) setAvailability(getUnreachableAvailability(tab.id, tab.url));
}
};
@@ -154,7 +168,7 @@ function App() {
}
setActive(response.active);
} catch {
- setAvailability(isLocalFileUrl(currentUrl) ? 'file-reload-required' : 'unsupported');
+ setAvailability(getUnreachableAvailability(tabId, currentUrl));
} finally {
setIsToggling(false);
}
@@ -181,14 +195,20 @@ function App() {
}
};
- const handleReloadLocalFile = async () => {
+ const handleReloadTab = async () => {
if (tabId === null || isToggling) return;
setIsToggling(true);
try {
await browser.tabs.reload(tabId);
+ if (availability === 'page-reload-required') {
+ localStorage.setItem(
+ PAGE_RELOAD_ATTEMPT_KEY,
+ JSON.stringify({ tabId, url: currentUrl }),
+ );
+ }
window.close();
} catch {
- setStatus('Reload this file, then open App Notes again.');
+ setStatus(`Reload this ${availability === 'file-reload-required' ? 'file' : 'page'}, then open App Notes again.`);
setIsToggling(false);
}
};
@@ -246,6 +266,8 @@ function App() {
return 'Chrome needs one approval to open local files.';
case 'file-reload-required':
return 'Reload this file to finish enabling App Notes.';
+ case 'page-reload-required':
+ return 'Reload this page to start using App Notes.';
case 'unsupported':
return 'App Notes isn’t available on this page.';
case 'ready':
@@ -263,13 +285,19 @@ function App() {
? {
icon: ,
label: 'Reload local file',
- onClick: handleReloadLocalFile,
+ onClick: handleReloadTab,
}
- : {
- icon: active ? : ,
- label: active ? 'Annotations enabled' : 'Enable annotations',
- onClick: toggleMode,
- };
+ : availability === 'page-reload-required'
+ ? {
+ icon: ,
+ label: 'Reload page',
+ onClick: handleReloadTab,
+ }
+ : {
+ icon: active ? : ,
+ label: active ? 'Annotations enabled' : 'Enable annotations',
+ onClick: toggleMode,
+ };
return (
diff --git a/lib/page-availability.test.ts b/lib/page-availability.test.ts
new file mode 100644
index 0000000..a487544
--- /dev/null
+++ b/lib/page-availability.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ parsePageReloadAttempt,
+ resolveUnreachablePageAvailability,
+} from './page-availability';
+
+describe('unreachable page availability', () => {
+ test('offers a reload for a website tab with no content script', () => {
+ expect(resolveUnreachablePageAvailability(7, 'https://example.com/a', null))
+ .toBe('page-reload-required');
+ });
+
+ test('keeps the local-file reload flow', () => {
+ expect(resolveUnreachablePageAvailability(7, 'file:///tmp/a.html', null))
+ .toBe('file-reload-required');
+ });
+
+ test.each([
+ 'https://chromewebstore.google.com/detail/app-notes/lkknpieefdjpoancolcioblkpgnhpffl',
+ 'https://chrome.google.com/webstore/category/extensions',
+ 'https://microsoftedge.microsoft.com/addons/detail/example',
+ 'https://addons.mozilla.org/en-US/firefox/',
+ ])('never offers a reload on a browser store page: %s', (url) => {
+ expect(resolveUnreachablePageAvailability(7, url, null)).toBe('unsupported');
+ });
+
+ test('offers a reload on other pages of hosts that only block a store path', () => {
+ expect(resolveUnreachablePageAvailability(7, 'https://chrome.google.com/intl/en/chrome/', null))
+ .toBe('page-reload-required');
+ expect(resolveUnreachablePageAvailability(7, 'https://microsoftedge.microsoft.com/other', null))
+ .toBe('page-reload-required');
+ });
+
+ test('stops offering a reload once that page stayed silent after one', () => {
+ const url = 'https://blocked-by-policy.example/';
+ expect(resolveUnreachablePageAvailability(7, url, { tabId: 7, url })).toBe('unsupported');
+ });
+
+ test('still offers a reload for a different tab or page', () => {
+ const url = 'https://example.com/a';
+ expect(resolveUnreachablePageAvailability(8, url, { tabId: 7, url }))
+ .toBe('page-reload-required');
+ expect(resolveUnreachablePageAvailability(7, 'https://example.com/b', { tabId: 7, url }))
+ .toBe('page-reload-required');
+ });
+});
+
+describe('page reload attempt parsing', () => {
+ test('parses a valid attempt as a fresh DTO', () => {
+ expect(parsePageReloadAttempt(JSON.stringify({
+ tabId: 7,
+ url: 'https://example.com/',
+ ignored: true,
+ }))).toEqual({ tabId: 7, url: 'https://example.com/' });
+ });
+
+ test.each([
+ null,
+ '',
+ 'not json',
+ 'null',
+ '[]',
+ '{"tabId":-1,"url":"https://example.com/"}',
+ '{"tabId":1.5,"url":"https://example.com/"}',
+ '{"tabId":"7","url":"https://example.com/"}',
+ '{"tabId":7}',
+ ])('rejects malformed input: %s', (input) => {
+ expect(parsePageReloadAttempt(input)).toBeNull();
+ });
+});
diff --git a/lib/page-availability.ts b/lib/page-availability.ts
new file mode 100644
index 0000000..8432e43
--- /dev/null
+++ b/lib/page-availability.ts
@@ -0,0 +1,72 @@
+import { isLocalFileUrl } from './annotation-scope';
+
+/** What the popup offers when a tab's content script does not answer. */
+export type UnreachablePageAvailability =
+ | 'file-reload-required'
+ | 'page-reload-required'
+ | 'unsupported';
+
+/** The page the popup last reloaded to start its content script. */
+export interface PageReloadAttempt {
+ readonly tabId: number;
+ readonly url: string;
+}
+
+/** Store pages where browsers never run extension content scripts. */
+const BROWSER_STORE_PAGES: ReadonlyArray<{
+ readonly hostname: string;
+ readonly pathPrefix: string;
+}> = [
+ { hostname: 'chromewebstore.google.com', pathPrefix: '/' },
+ { hostname: 'chrome.google.com', pathPrefix: '/webstore' },
+ { hostname: 'microsoftedge.microsoft.com', pathPrefix: '/addons' },
+ { hostname: 'addons.mozilla.org', pathPrefix: '/' },
+];
+
+function isBrowserStorePage(url: string): boolean {
+ try {
+ const { hostname, pathname } = new URL(url);
+ return BROWSER_STORE_PAGES.some((page) => (
+ hostname === page.hostname && pathname.startsWith(page.pathPrefix)
+ ));
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Decide what to offer when an annotatable tab has no content script.
+ *
+ * Tabs opened before App Notes was installed need one reload. Browsers also
+ * block extensions on pages no list can fully name, such as sites restricted by
+ * policy, so a page that stays silent after a reload is reported as unavailable
+ * instead of offering the same reload forever.
+ */
+export function resolveUnreachablePageAvailability(
+ tabId: number,
+ url: string,
+ lastAttempt: PageReloadAttempt | null,
+): UnreachablePageAvailability {
+ if (isLocalFileUrl(url)) return 'file-reload-required';
+ if (isBrowserStorePage(url)) return 'unsupported';
+ if (lastAttempt?.tabId === tabId && lastAttempt.url === url) return 'unsupported';
+ return 'page-reload-required';
+}
+
+/** Parse a persisted reload attempt. */
+export function parsePageReloadAttempt(input: string | null): PageReloadAttempt | null {
+ if (input === null) return null;
+
+ let value: unknown;
+ try {
+ value = JSON.parse(input);
+ } catch {
+ return null;
+ }
+
+ if (typeof value !== 'object' || value === null) return null;
+ const tabId = Reflect.get(value, 'tabId');
+ const url = Reflect.get(value, 'url');
+ if (typeof tabId !== 'number' || !Number.isSafeInteger(tabId) || tabId < 0) return null;
+ return typeof url === 'string' ? { tabId, url } : null;
+}
diff --git a/package.json b/package.json
index 8481890..e35c9d1 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "@plannotator/app-notes",
"description": "A local-first browser extension for annotating elements on any web page.",
"private": true,
- "version": "0.1.2",
+ "version": "0.1.3",
"license": "SEE LICENSE IN LICENSE",
"author": {
"name": "Michael Ramos",