diff --git a/background.js b/background.js
new file mode 100644
index 0000000..b5038bc
--- /dev/null
+++ b/background.js
@@ -0,0 +1,38 @@
+// Side Notes — Background Service Worker
+//
+// Responsibilities:
+// - Set up side panel behavior on install.
+// - Detect in-page navigation (SPA pushState/replaceState, hash changes)
+// and notify any open side panel for the affected tab to reload its note.
+
+import { buildNoteKey } from './shared/key-builder.js';
+
+// ---------------------------------------------------------------------------
+// Install
+// ---------------------------------------------------------------------------
+chrome.runtime.onInstalled.addListener(() => {
+ chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
+});
+
+// ---------------------------------------------------------------------------
+// In-page navigation detection (SPA support)
+// ---------------------------------------------------------------------------
+const bc = new BroadcastChannel('side-notes');
+
+function notifyTab(tabId, url) {
+ const key = buildNoteKey(url);
+ bc.postMessage({ type: 'navigation', tabId, key });
+}
+
+chrome.webNavigation.onHistoryStateUpdated.addListener((details) => {
+ // details.frameId === 0 => main frame only (ignore iframes)
+ if (details.frameId === 0) {
+ notifyTab(details.tabId, details.url);
+ }
+});
+
+chrome.webNavigation.onReferenceFragmentUpdated.addListener((details) => {
+ if (details.frameId === 0) {
+ notifyTab(details.tabId, details.url);
+ }
+});
diff --git a/icons/icon-128.png b/icons/icon-128.png
new file mode 100644
index 0000000..d4a7973
Binary files /dev/null and b/icons/icon-128.png differ
diff --git a/icons/icon-16.png b/icons/icon-16.png
new file mode 100644
index 0000000..b4d9415
Binary files /dev/null and b/icons/icon-16.png differ
diff --git a/icons/icon-48.png b/icons/icon-48.png
new file mode 100644
index 0000000..6138e1d
Binary files /dev/null and b/icons/icon-48.png differ
diff --git a/manifest.json b/manifest.json
new file mode 100644
index 0000000..ecccf44
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,35 @@
+{
+ "manifest_version": 3,
+ "name": "Side Notes",
+ "version": "0.1.0",
+ "description": "One markdown note per page, available instantly from a side panel.",
+ "permissions": [
+ "sidePanel",
+ "tabs",
+ "webNavigation",
+ "unlimitedStorage"
+ ],
+ "icons": {
+ "16": "icons/icon-16.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ },
+ "action": {
+ "default_title": "Open Side Notes"
+ },
+ "side_panel": {
+ "default_path": "sidepanel/sidepanel.html"
+ },
+ "background": {
+ "service_worker": "background.js",
+ "type": "module"
+ },
+ "commands": {
+ "_execute_action": {
+ "suggested_key": {
+ "default": "Ctrl+Shift+N",
+ "mac": "Command+Shift+N"
+ }
+ }
+ }
+}
diff --git a/notes-list/notes-list.css b/notes-list/notes-list.css
new file mode 100644
index 0000000..3d61d26
--- /dev/null
+++ b/notes-list/notes-list.css
@@ -0,0 +1,132 @@
+/* Side Notes — Notes List (Dark Theme, chrome://history-inspired) */
+
+:root {
+ --bg: #1e1e1e;
+ --bg-header: #252525;
+ --text: #d4d4d4;
+ --text-muted: #888;
+ --text-dim: #666;
+ --border: #3c3c3c;
+ --accent: #569cd6;
+ --hover-bg: #2a2a2a;
+ --section-label: #999;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+html, body {
+ height: 100%;
+ background: var(--bg);
+ color: var(--text);
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+ font-size: 14px;
+}
+
+/* ---- Header ---- */
+header {
+ background: var(--bg-header);
+ border-bottom: 1px solid var(--border);
+ padding: 24px 32px 16px;
+}
+
+header h1 {
+ font-size: 22px;
+ font-weight: 600;
+ color: var(--text);
+ margin-bottom: 4px;
+}
+
+#subtitle {
+ font-size: 13px;
+ color: var(--text-muted);
+}
+
+/* ---- Main list ---- */
+main {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 16px 32px 48px;
+}
+
+/* ---- Day sections ---- */
+.day-section {
+ margin-bottom: 24px;
+}
+
+.day-label {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--section-label);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding: 8px 0;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 4px;
+ position: sticky;
+ top: 0;
+ background: var(--bg);
+ z-index: 1;
+}
+
+/* ---- Note entries ---- */
+.note-entry {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.1s;
+}
+
+.note-entry:hover {
+ background: var(--hover-bg);
+}
+
+.note-entry img {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+}
+
+.note-entry .entry-text {
+ flex: 1;
+ min-width: 0;
+}
+
+.note-entry .entry-title {
+ font-size: 14px;
+ color: var(--text);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.note-entry .entry-url {
+ font-size: 12px;
+ color: var(--text-dim);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-top: 2px;
+}
+
+.note-entry .entry-time {
+ font-size: 12px;
+ color: var(--text-muted);
+ flex-shrink: 0;
+ text-align: right;
+ min-width: 70px;
+}
+
+/* ---- Empty state ---- */
+#empty-message {
+ text-align: center;
+ color: var(--text-muted);
+ padding: 48px 0;
+ font-size: 14px;
+}
diff --git a/notes-list/notes-list.html b/notes-list/notes-list.html
new file mode 100644
index 0000000..a574458
--- /dev/null
+++ b/notes-list/notes-list.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Side Notes — All Notes
+
+
+
+
+ Side Notes
+ All notes
+
+
+
+ No notes yet. Open a page and use the side panel to write one.
+
+
+
+
+
diff --git a/notes-list/notes-list.js b/notes-list/notes-list.js
new file mode 100644
index 0000000..7867d53
--- /dev/null
+++ b/notes-list/notes-list.js
@@ -0,0 +1,216 @@
+// Side Notes — Notes List Page
+//
+// A dedicated extension page that lists all notes grouped by day and sorted
+// by recency. Clicking an entry either focuses an already-open tab (if one
+// exists for that note's URL key) or navigates the current tab to it.
+// The side panel is then opened on the target tab.
+
+import { buildNoteKey } from '../shared/key-builder.js';
+import { listNotes } from '../shared/storage.js';
+
+// ---------------------------------------------------------------------------
+// DOM
+// ---------------------------------------------------------------------------
+const listEl = document.getElementById('note-list');
+const emptyMsg = document.getElementById('empty-message');
+
+// ---------------------------------------------------------------------------
+// Group notes by day
+// ---------------------------------------------------------------------------
+
+/**
+ * Return a human-readable day label for a date.
+ * Today → "Today", Yesterday → "Yesterday", otherwise the locale date.
+ */
+function dayLabel(date) {
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const target = new Date(date.getFullYear(), date.getMonth(), date.getDate());
+ const diffDays = Math.round((today - target) / (1000 * 60 * 60 * 24));
+
+ if (diffDays === 0) return 'Today';
+ if (diffDays === 1) return 'Yesterday';
+ return target.toLocaleDateString(undefined, {
+ weekday: 'long',
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+}
+
+/**
+ * Format a time string from an ISO timestamp.
+ */
+function formatTime(isoString) {
+ const d = new Date(isoString);
+ return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
+}
+
+function groupByDay(notes) {
+ const groups = new Map();
+ for (const note of notes) {
+ const d = new Date(note.updatedAt);
+ const key = dayLabel(d);
+ if (!groups.has(key)) groups.set(key, []);
+ groups.get(key).push(note);
+ }
+ return groups;
+}
+
+// ---------------------------------------------------------------------------
+// Render
+// ---------------------------------------------------------------------------
+async function render() {
+ const notes = await listNotes();
+
+ if (notes.length === 0) {
+ listEl.innerHTML = '';
+ listEl.appendChild(emptyMsg);
+ return;
+ }
+
+ emptyMsg.remove();
+ listEl.innerHTML = '';
+
+ const groups = groupByDay(notes);
+ for (const [label, groupNotes] of groups) {
+ const section = document.createElement('div');
+ section.className = 'day-section';
+
+ const labelEl = document.createElement('div');
+ labelEl.className = 'day-label';
+ labelEl.textContent = label;
+ section.appendChild(labelEl);
+
+ for (const note of groupNotes) {
+ section.appendChild(buildEntry(note));
+ }
+
+ listEl.appendChild(section);
+ }
+}
+
+function buildEntry(note) {
+ const entry = document.createElement('div');
+ entry.className = 'note-entry';
+ entry.dataset.noteId = note.id;
+ entry.dataset.key = note.key;
+ entry.dataset.url = note.url || note.key; // fallback: reconstruct from key
+
+ // Favicon
+ const img = document.createElement('img');
+ img.src = note.favIconUrl || '';
+ img.alt = '';
+ img.width = 16;
+ img.height = 16;
+ img.onerror = () => { img.style.visibility = 'hidden'; };
+ entry.appendChild(img);
+
+ // Text block
+ const textBlock = document.createElement('div');
+ textBlock.className = 'entry-text';
+
+ const title = document.createElement('div');
+ title.className = 'entry-title';
+ title.textContent = note.pageTitle || note.key || 'Untitled';
+
+ const url = document.createElement('div');
+ url.className = 'entry-url';
+ url.textContent = note.key || '';
+
+ textBlock.appendChild(title);
+ textBlock.appendChild(url);
+ entry.appendChild(textBlock);
+
+ // Time
+ const time = document.createElement('div');
+ time.className = 'entry-time';
+ time.textContent = formatTime(note.updatedAt);
+ entry.appendChild(time);
+
+ // Click handler — see handleEntryClick below
+ entry.addEventListener('click', () => handleEntryClick(note));
+
+ return entry;
+}
+
+// Our own tab ID — resolved at init time so we can call sidePanel.open()
+// synchronously in the click handler (required for user-gesture propagation).
+let ownTabId = null;
+
+// ---------------------------------------------------------------------------
+// Click handling — focus existing tab or navigate current
+// ---------------------------------------------------------------------------
+
+/**
+ * Handle a click on a notes-list entry.
+ *
+ * IMPORTANT: `chrome.sidePanel.open()` requires a live user gesture and MUST
+ * be called synchronously (no preceding await) in the click handler. We call
+ * it first on our own tab as a fallback, then do the async matching work. If
+ * a matching tab is found we call sidePanel.open() again on the matched tab.
+ */
+async function handleEntryClick(note) {
+ const noteKey = note.key;
+
+ // Step 1 (SYNCHRONOUS): open the side panel on our own tab to satisfy the
+ // user-gesture requirement. This will be superseded if a matching tab is
+ // found below, or if we navigate away.
+ if (ownTabId != null) {
+ chrome.sidePanel.open({ tabId: ownTabId });
+ }
+
+ // Step 2: query all tabs and check for a match.
+ let allTabs;
+ try {
+ allTabs = await chrome.tabs.query({});
+ } catch {
+ return;
+ }
+
+ let matchTab = null;
+ for (const tab of allTabs) {
+ if (!tab.url) continue;
+ try {
+ const tabKey = buildNoteKey(tab.url);
+ if (tabKey === noteKey) {
+ matchTab = tab;
+ break;
+ }
+ } catch {
+ // URL parsing may fail for special URLs (chrome://, etc.) — skip.
+ }
+ }
+
+ if (matchTab) {
+ // A tab is already open at this note's URL → focus it and open its panel.
+ chrome.windows.update(matchTab.windowId, { focused: true });
+ chrome.tabs.update(matchTab.id, { active: true });
+ chrome.sidePanel.open({ tabId: matchTab.id });
+ } else if (ownTabId != null) {
+ // No match → navigate our own tab to the note's URL.
+ // The key is the normalized URL (scheme://host/path?search), sufficient
+ // for navigation even though the hash was stripped.
+ chrome.tabs.update(ownTabId, { url: noteKey });
+ chrome.sidePanel.open({ tabId: ownTabId });
+ } else {
+ // Fallback (shouldn't normally happen): create a new tab.
+ try {
+ const tab = await chrome.tabs.create({ url: noteKey });
+ chrome.sidePanel.open({ tabId: tab.id });
+ } catch { /* last resort — do nothing */ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Init
+// ---------------------------------------------------------------------------
+(async function init() {
+ // Resolve our own tab ID so click handlers can use it synchronously.
+ try {
+ const tab = await chrome.tabs.getCurrent();
+ if (tab) ownTabId = tab.id;
+ } catch { /* not opened as a tab — ownTabId stays null */ }
+
+ render();
+})();
diff --git a/shared/key-builder.js b/shared/key-builder.js
new file mode 100644
index 0000000..a09e232
--- /dev/null
+++ b/shared/key-builder.js
@@ -0,0 +1,43 @@
+// Shared URL-key derivation for note lookups.
+// Used by the side panel (load/save), the notes-list click handler
+// (tab matching), and background navigation detection.
+
+const DEFAULT_CONFIG = {
+ // Default: scheme://domain/path?search, hash excluded.
+ buildKey(url) {
+ try {
+ const u = new URL(url);
+ return `${u.protocol}//${u.host}${u.pathname}${u.search}`;
+ } catch {
+ // If URL parsing fails, use the raw string as-is.
+ return url;
+ }
+ },
+};
+
+let _config = { ...DEFAULT_CONFIG };
+
+/**
+ * Derive a storage key from a page URL.
+ * @param {string} url
+ * @param {object} [config] — optional override (for testing / future UI changes)
+ * @returns {string}
+ */
+export function buildNoteKey(url, config = _config) {
+ return config.buildKey(url);
+}
+
+/**
+ * Swap the key-derivation rule at runtime.
+ * @param {object} newConfig — must expose a `buildKey(url)` function
+ */
+export function setKeyConfig(newConfig) {
+ _config = { ...newConfig };
+}
+
+/**
+ * Return the current config (read-only copy).
+ */
+export function getKeyConfig() {
+ return { ..._config };
+}
diff --git a/shared/storage.js b/shared/storage.js
new file mode 100644
index 0000000..55e921c
--- /dev/null
+++ b/shared/storage.js
@@ -0,0 +1,177 @@
+// IndexedDB storage layer for Side Notes.
+//
+// Schema:
+// Object store: "notes"
+// Key path: id (auto-generated)
+// Indexes: "byKey" (non-unique, on derived URL key)
+// "byUpdated" (on updatedAt)
+//
+// Retrieval always filters `deletedAt == null`, so at most one live note
+// per URL key is visible even if soft-deleted records coexist.
+
+const DB_NAME = 'side-notes';
+const DB_VERSION = 1;
+const STORE_NAME = 'notes';
+
+/** @returns {Promise} */
+function openDB() {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
+ req.onupgradeneeded = (event) => {
+ const db = event.target.result;
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ const store = db.createObjectStore(STORE_NAME, {
+ keyPath: 'id',
+ autoIncrement: true,
+ });
+ store.createIndex('byKey', 'key', { unique: false });
+ store.createIndex('byUpdated', 'updatedAt');
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+/**
+ * Generate a unique id. crypto.randomUUID is available in service workers
+ * and extension pages in Chromium-based browsers.
+ */
+function generateId() {
+ return crypto.randomUUID();
+}
+
+/**
+ * Retrieve the live (non-deleted) note for a given URL key.
+ * @param {string} key
+ * @returns {Promise