diff --git a/.gitignore b/.gitignore index e91a3ce..a811ec2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ package-lock.json /*.zip /manifest.json /background/index-compilers.js + +# Local-only planning docs, not part of the shipped feature +/DESIGN-comments.md +/ROADMAP-comments.md diff --git a/COMMENTS-README.md b/COMMENTS-README.md new file mode 100644 index 0000000..9e9b528 --- /dev/null +++ b/COMMENTS-README.md @@ -0,0 +1,125 @@ +# Comments + +Attach comments to any selected text in a rendered markdown document. Comments +are anchored to the selected passage and highlighted inline; a sidebar panel +lists them all, filterable and searchable. + +## Enabling + +Two independent settings under Content Options: + +| Setting | Default | Effect | +| :- | :-: | :- | +| **Show comments** | `true` | Renders highlights on anchored text and lets you open the sidebar to browse existing comments. Read-only — no way to create, edit, or delete anything. | +| **Create comments** | `false` | Adds everything needed to author comments: the selection tooltip, `Cmd/Ctrl+Shift+K`, the right-click "Add Comment" context menu item, and every mutating sidebar action (reply, edit, resolve, delete, import, Resolve All, Delete All). Has no visible effect if *Show comments* is off. | + +New installs default to view-only (`Show comments` on, `Create comments` +off). Users upgrading from a version where comments were already fully +enabled keep write access automatically — the migration sets `Create +comments` to `true` for anyone who already had the old single `comments` +setting on, so existing workflows aren't broken by the split. + +## Using it + +With **Create comments** enabled: + +- Select text in the rendered document → a tooltip appears near the + selection → click it, or press `Cmd/Ctrl+Shift+K`, to open the comment + input. +- Right-click a selection for an "Add Comment" context menu item. +- `Cmd/Ctrl+Enter` saves, `Escape` cancels, while writing a comment, reply, + or edit. +- Each comment can be given an optional **tag** (`note`, `question`, + `suggestion`, `issue`, `outdated`, `action-needed`) and an optional + **severity** (`low`, `medium`, `high`, `critical`), rendered as pills in + the sidebar. +- Comments can be **resolved** (strikethrough, dimmed) and reopened + individually, or in bulk via **Resolve All** / **Delete All** in the + sidebar header — both prompt for confirmation before acting. +- **Reply** to a comment to start a thread; replies show author and + timestamp, indented under the parent. +- **Suggestion mode**: propose replacement text for the selected passage; + the sidebar renders it as a strikethrough/insert diff. + +With just **Show comments** enabled (or as a read-only viewer of someone +else's comments): + +- Highlighted passages are visible and clickable. +- `Ctrl+]` / `Ctrl+[` jump to the next/previous comment in document order, + scrolling the page and flashing the corresponding sidebar entry. +- Clicking outside the sidebar closes it. +- Comments can still be exported (JSON or Markdown) — export is + non-destructive and available in both modes. + +## Storage & persistence + +Comments are stored in `chrome.storage.local`, keyed by page URL — not in a +sidecar file on disk and not via native messaging. There is no additional +permission required beyond what the extension already has for local file +access. + +On load, comments come from two sources that get merged: + +1. **Stored comments** — whatever was previously saved for this URL. +2. **Inline comments** — parsed from HTML comment markers embedded directly + in the markdown source: + + ```html + + + ``` + + These are picked up from the rendered `
` (raw view) or, on `file://`
+   pages where that's unavailable, re-fetched from the source file. Inline
+   comments let you seed or share comments by committing them directly into
+   the markdown file — see [Export as Markdown](#export) below.
+
+Stored comments take priority on merge; inline-only comments (not already in
+storage) are added, deduplicated by ID and by anchor+body.
+
+## Anchoring
+
+Each comment anchors to a passage using the selected text plus a small
+amount of surrounding context (up to ~100 characters before, ~50 after, cut
+at paragraph boundaries) and the nearest preceding heading, so that minor
+edits elsewhere in the document don't break the anchor. If the anchor text
+can no longer be found in the current rendering, the comment still appears
+in the sidebar, flagged as an orphan.
+
+Anchors that span more than one inline element (for example, a selection
+that starts in plain text and continues into a `` or ``) are
+highlighted by wrapping each affected text node individually, rather than
+requiring the whole selection to sit inside one DOM node.
+
+Highlights are automatically reapplied if the underlying rendered content
+is replaced — for example after autoreload picks up a file change, or after
+switching themes or toggling the raw view.
+
+## Import / Export
+
+- **Export as JSON** — the full comment set for the current page, suitable
+  for backup or hand-off to another viewer/session.
+- **Export as Markdown** — writes the current comments back into the
+  document as `` / ``
+  markers near their anchored text, producing a `.commented.md` file. This
+  is the round-trip mechanism for sharing comments as part of the document
+  itself (e.g. committing them to a git repo) rather than as separate
+  storage.
+- **Import** (write mode only) — load a previously exported JSON file back
+  into the current page's comment set.
+
+All comment data — whether loaded from storage, parsed from inline markers,
+or imported from JSON — is validated against an allowlist before being
+trusted: tags and severities must match a fixed known set, IDs must match
+`[\w-]+`, and text fields are length-capped. Nothing from an untrusted
+source is rendered as raw HTML.
+
+## Keyboard reference
+
+| Shortcut | Context | Action |
+| :- | :- | :- |
+| `Cmd/Ctrl+Shift+K` | Text selected, write mode | Open comment input for the selection |
+| `Cmd/Ctrl+Enter` | Comment/reply/edit box focused | Save |
+| `Escape` | Comment/reply/edit box focused | Cancel |
+| `Ctrl+]` | Anywhere | Jump to next comment |
+| `Ctrl+[` | Anywhere | Jump to previous comment |
diff --git a/README.md b/README.md
index 9d0be28..5981427 100644
--- a/README.md
+++ b/README.md
@@ -113,6 +113,8 @@ Full **CommonMark** support including **GFM** tables and strikethrough **+**
 | Option         | Default | Description
 | :-             | :-:     | :-
 | **autoreload** | `false` | Auto reload on file change
+| **comments**   | **`true`**  | Show comments (highlights + sidebar). See [Comments](COMMENTS-README.md)
+| **commentsWrite** | `false` | Allow adding/editing comments. See [Comments](COMMENTS-README.md)
 | **emoji**      | `false` | Convert emoji `:shortnames:` into EmojiOne images
 | **mathjax**    | `false` | Render MathJax formulas
 | **mermaid**    | `false` | Render Mermaid diagrams
@@ -126,6 +128,15 @@ When enabled the extension will make a GET request every second to markdown file
 - `file:///` URLs
 - any host that resolves to localhost IPv4 `127.0.0.1` or IPv6 `::1`
 
+## Comments
+
+Select any text in a rendered markdown document to attach a comment to it.
+Comments persist locally per page, can be tagged/prioritized/replied to/
+resolved, and can be exported as JSON or written back into the markdown
+source as HTML comment markers. See [COMMENTS-README.md](COMMENTS-README.md)
+for the full write-up, including the read/write settings split, keyboard
+shortcuts, and data format.
+
 ## Emoji
 
 Convert emoji :shortnames: into EmojiOne images:
diff --git a/background/comments.js b/background/comments.js
new file mode 100644
index 0000000..2248ce1
--- /dev/null
+++ b/background/comments.js
@@ -0,0 +1,150 @@
+md.comments = ({storage: {state}}) => {
+
+  // Register context menu
+  chrome.contextMenus.create({
+    id: 'markdown-viewer-add-comment',
+    title: 'Add Comment',
+    contexts: ['selection'],
+    documentUrlPatterns: ['file:///*']
+  })
+
+  // Handle context menu click
+  chrome.contextMenus.onClicked.addListener((info, tab) => {
+    if (info.menuItemId === 'markdown-viewer-add-comment') {
+      chrome.tabs.sendMessage(tab.id, {
+        message: 'comments.add-from-menu',
+        selectionText: info.selectionText
+      })
+    }
+  })
+
+  // Handle comment storage messages
+  return (req, sender, sendResponse) => {
+    if (req.message === 'comments.load') {
+      var key = 'comments:' + req.url
+      chrome.storage.local.get(key, (res) => {
+        sendResponse({comments: res[key] || []})
+      })
+      return true
+    }
+
+    else if (req.message === 'comments.save') {
+      var key = 'comments:' + req.url
+      chrome.storage.local.set({[key]: req.comments}, () => {
+        sendResponse({ok: true})
+      })
+      return true
+    }
+
+    else if (req.message === 'comments.export') {
+      var key = 'comments:' + req.url
+      chrome.storage.local.get(key, (res) => {
+        var comments = res[key] || []
+        var filename = req.filename || 'comments.json'
+
+        var exportData = {
+          version: 1,
+          source: req.url,
+          exportedAt: new Date().toISOString(),
+          comments: comments
+        }
+
+        // Create a data URL and trigger download
+        var json = JSON.stringify(exportData, null, 2)
+        var blob = new Blob([json], {type: 'application/json'})
+        var reader = new FileReader()
+        reader.onload = () => {
+          chrome.downloads.download({
+            url: reader.result,
+            filename: filename,
+            saveAs: true
+          }, () => {
+            sendResponse({ok: true})
+          })
+        }
+        reader.readAsDataURL(blob)
+      })
+      return true
+    }
+
+    else if (req.message === 'comments.export-md') {
+      // Fetch the raw markdown source, inject inline HTML comments, download as .md
+      fetch(req.url)
+        .then((res) => res.text())
+        .then((markdown) => {
+          var annotated = injectInlineComments(markdown, req.comments)
+          var blob = new Blob([annotated], {type: 'text/markdown'})
+          var reader = new FileReader()
+          reader.onload = () => {
+            chrome.downloads.download({
+              url: reader.result,
+              filename: req.filename,
+              saveAs: true
+            }, () => {
+              sendResponse({ok: true})
+            })
+          }
+          reader.readAsDataURL(blob)
+        })
+        .catch((err) => {
+          sendResponse({ok: false, error: err.message})
+        })
+      return true
+    }
+
+    else if (req.message === 'comments.badge') {
+      var text = req.count > 0 ? String(req.count) : ''
+      chrome.action.setBadgeText({text: text, tabId: sender.tab.id})
+      chrome.action.setBadgeBackgroundColor({color: '#2563eb', tabId: sender.tab.id})
+      return false
+    }
+
+    else if (req.message === 'comments.clear') {
+      var key = 'comments:' + req.url
+      chrome.storage.local.remove(key, () => {
+        sendResponse({ok: true})
+      })
+      return true
+    }
+  }
+
+  function injectInlineComments (markdown, comments) {
+    // Sort comments by position in the source (later first so insertions don't shift offsets)
+    var sorted = comments
+      .filter((c) => c.anchor && c.anchor.text)
+      .map((c) => {
+        var idx = findAnchorInSource(markdown, c.anchor)
+        return {comment: c, idx: idx}
+      })
+      .filter((item) => item.idx >= 0)
+      .sort((a, b) => b.idx - a.idx)
+
+    var result = markdown
+    sorted.forEach((item) => {
+      var c = item.comment
+      var anchorEnd = item.idx + c.anchor.text.length
+      var status = c.resolved ? ' [RESOLVED]' : ''
+      var commentTag = ''
+      result = result.substring(0, anchorEnd) + commentTag + result.substring(anchorEnd)
+    })
+
+    return result
+  }
+
+  function findAnchorInSource (markdown, anchor) {
+    // Try exact text match with prefix context
+    if (anchor.prefix) {
+      var withPrefix = anchor.prefix + anchor.text
+      var idx = markdown.indexOf(withPrefix)
+      if (idx >= 0) return idx + anchor.prefix.length
+    }
+    // Try exact text match with suffix context
+    if (anchor.suffix) {
+      var withSuffix = anchor.text + anchor.suffix
+      var idx = markdown.indexOf(withSuffix)
+      if (idx >= 0) return idx
+    }
+    // Fallback: plain text match
+    return markdown.indexOf(anchor.text)
+  }
+}
diff --git a/background/index.js b/background/index.js
index 54a1e7b..06f8dc7 100644
--- a/background/index.js
+++ b/background/index.js
@@ -14,6 +14,7 @@ importScripts('/background/messages.js')
 importScripts('/background/mathjax.js')
 importScripts('/background/xhr.js')
 importScripts('/background/icon.js')
+importScripts('/background/comments.js')
 
 ;(() => {
   var storage = md.storage(md)
@@ -23,6 +24,7 @@ importScripts('/background/icon.js')
   var mathjax = md.mathjax()
   var xhr = md.xhr()
   var icon = md.icon({storage})
+  var comments = md.comments({storage})
 
   var compilers = Object.keys(md.compilers)
     .reduce((all, compiler) => (
@@ -34,6 +36,7 @@ importScripts('/background/icon.js')
 
   chrome.tabs.onUpdated.addListener(detect.tab)
   chrome.runtime.onMessage.addListener(messages)
+  chrome.runtime.onMessage.addListener(comments)
 
   icon()
 })()
diff --git a/background/inject.js b/background/inject.js
index da617b3..b8da56d 100644
--- a/background/inject.js
+++ b/background/inject.js
@@ -1,6 +1,15 @@
 
 md.inject = ({storage: {state}}) => (id) => {
 
+  chrome.scripting.insertCSS({
+    target: {tabId: id},
+    files: [
+      '/content/index.css',
+      '/content/themes.css',
+      (state.content.comments || state.content.commentsWrite) && '/content/comments.css',
+    ].filter(Boolean)
+  })
+
   chrome.scripting.executeScript({
     target: {tabId: id},
     args: [{
@@ -19,14 +28,6 @@ md.inject = ({storage: {state}}) => (id) => {
     injectImmediately: true
   })
 
-  chrome.scripting.insertCSS({
-    target: {tabId: id},
-    files: [
-      '/content/index.css',
-      '/content/themes.css',
-    ]
-  })
-
   chrome.scripting.executeScript({
     target: {tabId: id},
     files: [
@@ -38,6 +39,7 @@ md.inject = ({storage: {state}}) => (id) => {
       '/content/index.js',
       '/content/scroll.js',
       state.content.autoreload && '/content/autoreload.js',
+      (state.content.comments || state.content.commentsWrite) && '/content/comments.js',
     ].filter(Boolean).flat(),
     injectImmediately: true
   })
diff --git a/background/storage.js b/background/storage.js
index 8f5e403..e2a07f6 100644
--- a/background/storage.js
+++ b/background/storage.js
@@ -53,6 +53,8 @@ md.storage.defaults = (compilers) => {
       mermaid: false,
       syntax: true,
       toc: false,
+      comments: true,
+      commentsWrite: false,
     },
     origins: {
       'file://': {
@@ -193,4 +195,15 @@ md.storage.migrations = (state) => {
       color: 'auto'
     }
   }
+  // v5.3 -> v5.3.1
+  if (state.content.comments === undefined) {
+    state.content.comments = true
+  }
+  // v5.3.1 -> v5.3.2
+  if (state.content.commentsWrite === undefined) {
+    // Existing users who already had comments enabled keep write access
+    // (they may already be actively creating comments). New installs
+    // default to read-only per the updated default above.
+    state.content.commentsWrite = state.content.comments === true
+  }
 }
diff --git a/content/comments.css b/content/comments.css
new file mode 100644
index 0000000..924f0ba
--- /dev/null
+++ b/content/comments.css
@@ -0,0 +1,456 @@
+/* ─── Tooltip ─────────────────────────────────────────────── */
+
+#_comments-tooltip {
+  position: absolute;
+  z-index: 10003;
+  background: var(--comments-btn-bg, #2563eb);
+  color: #fff;
+  border-radius: 4px;
+  padding: 4px 10px;
+  font-size: 12px;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+  cursor: pointer;
+  box-shadow: 0 2px 8px rgba(0,0,0,0.2);
+  user-select: none;
+  white-space: nowrap;
+}
+#_comments-tooltip:hover { opacity: 0.9; }
+
+/* ─── Toggle Button ───────────────────────────────────────── */
+
+#_comments-toggle {
+  position: fixed;
+  bottom: 20px;
+  right: 20px;
+  z-index: 10000;
+  background: var(--comments-btn-bg, #2563eb);
+  color: var(--comments-btn-fg, #fff);
+  border: none;
+  border-radius: 24px;
+  padding: 8px 14px;
+  font-size: 14px;
+  cursor: pointer;
+  box-shadow: 0 2px 8px rgba(0,0,0,0.2);
+  transition: transform 0.15s, box-shadow 0.15s;
+}
+#_comments-toggle:hover {
+  transform: scale(1.05);
+  box-shadow: 0 4px 12px rgba(0,0,0,0.3);
+}
+
+/* ─── Highlights ──────────────────────────────────────────── */
+
+mark._comment-highlight {
+  background: var(--comments-highlight-bg, rgba(255, 220, 0, 0.25));
+  border-bottom: 2px solid var(--comments-highlight-border, rgba(200, 160, 0, 0.5));
+  cursor: pointer;
+  border-radius: 2px;
+  transition: background 0.2s;
+}
+mark._comment-highlight:hover { background: var(--comments-highlight-hover, rgba(255, 220, 0, 0.45)); }
+mark._comment-highlight._severity-critical { border-bottom-color: #dc2626; background: rgba(220, 38, 38, 0.1); }
+mark._comment-highlight._severity-high { border-bottom-color: #ea580c; background: rgba(234, 88, 12, 0.1); }
+mark._comment-highlight._severity-medium { border-bottom-color: #ca8a04; }
+mark._comment-highlight._flash { animation: _comment-flash 1s ease; }
+
+@keyframes _comment-flash {
+  0%, 100% { background: var(--comments-highlight-bg, rgba(255, 220, 0, 0.25)); }
+  50% { background: rgba(255, 180, 0, 0.5); }
+}
+
+/* ─── Sidebar ─────────────────────────────────────────────── */
+
+#_comments-sidebar {
+  position: fixed;
+  top: 0;
+  right: -360px;
+  width: 340px;
+  height: 100vh;
+  z-index: 10001;
+  background: var(--comments-sidebar-bg, #fff);
+  border-left: 1px solid var(--comments-sidebar-border, #e0e0e0);
+  box-shadow: -2px 0 12px rgba(0,0,0,0.1);
+  display: flex;
+  flex-direction: column;
+  transition: right 0.25s ease;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+  font-size: 13px;
+  color: var(--comments-sidebar-fg, #333);
+}
+#_comments-sidebar._visible { right: 0; }
+
+._comments-sidebar-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 8px 10px;
+  border-bottom: 1px solid var(--comments-sidebar-border, #e0e0e0);
+  flex-shrink: 0;
+  gap: 4px;
+}
+._comments-title { font-weight: 600; font-size: 13px; white-space: nowrap; }
+._comments-badge {
+  display: inline-block;
+  background: var(--comments-badge-bg, #2563eb);
+  color: #fff;
+  border-radius: 10px;
+  padding: 1px 7px;
+  font-size: 11px;
+  margin-left: 6px;
+}
+._comments-sidebar-actions { display: flex; gap: 2px; flex-wrap: nowrap; }
+._comments-sidebar-actions button {
+  background: none;
+  border: 1px solid var(--comments-sidebar-border, #ddd);
+  border-radius: 4px;
+  cursor: pointer;
+  padding: 3px 5px;
+  font-size: 10px;
+  color: var(--comments-sidebar-fg, #555);
+  white-space: nowrap;
+  flex-shrink: 0;
+}
+._comments-sidebar-actions button:hover { background: var(--comments-item-hover, #f5f5f5); }
+
+/* ─── Filters ─────────────────────────────────────────────── */
+
+._comments-sidebar-filters {
+  padding: 8px 12px;
+  border-bottom: 1px solid var(--comments-sidebar-border, #e0e0e0);
+  flex-shrink: 0;
+}
+._comments-search {
+  width: 100%;
+  border: 1px solid var(--comments-sidebar-border, #ddd);
+  border-radius: 4px;
+  padding: 5px 8px;
+  font-size: 12px;
+  background: var(--comments-input-bg, #fff);
+  color: var(--comments-sidebar-fg, #333);
+  box-sizing: border-box;
+  margin-bottom: 6px;
+}
+._comments-search:focus { outline: none; border-color: var(--comments-btn-bg, #2563eb); }
+._comments-filter-row { display: flex; gap: 4px; }
+._comments-filter-row select {
+  flex: 1;
+  border: 1px solid var(--comments-sidebar-border, #ddd);
+  border-radius: 3px;
+  padding: 3px 4px;
+  font-size: 11px;
+  background: var(--comments-input-bg, #fff);
+  color: var(--comments-sidebar-fg, #333);
+}
+
+._comments-sidebar-body { flex: 1; overflow-y: auto; padding: 8px; }
+
+/* ─── Comment Items ───────────────────────────────────────── */
+
+._comments-item {
+  padding: 10px 12px;
+  border: 1px solid var(--comments-item-border, #eee);
+  border-radius: 6px;
+  margin-bottom: 8px;
+  transition: background 0.15s;
+  cursor: pointer;
+}
+._comments-item:hover { background: var(--comments-item-hover, #f9f9f9); }
+._comments-item._resolved { opacity: 0.5; }
+._comments-item._orphan { border-left: 3px solid #f59e0b; }
+._comments-item._orphan ._comments-item-anchor { color: var(--comments-meta-fg, #999); }
+._comments-item._flash { animation: _comment-item-flash 1s ease; }
+@keyframes _comment-item-flash {
+  0%, 100% { background: transparent; }
+  50% { background: var(--comments-highlight-bg, rgba(255, 220, 0, 0.25)); }
+}
+
+/* Pills */
+._comments-item-pills { margin-bottom: 4px; display: flex; gap: 4px; flex-wrap: wrap; }
+._pill {
+  display: inline-block;
+  font-size: 10px;
+  font-weight: 600;
+  padding: 1px 6px;
+  border-radius: 8px;
+  text-transform: uppercase;
+  letter-spacing: 0.3px;
+}
+._pill-tag { background: #e0e7ff; color: #3730a3; }
+._pill-note { background: #e0e7ff; color: #3730a3; }
+._pill-question { background: #dbeafe; color: #1d4ed8; }
+._pill-suggestion { background: #d1fae5; color: #065f46; }
+._pill-issue { background: #fee2e2; color: #991b1b; }
+._pill-outdated { background: #fef3c7; color: #92400e; }
+._pill-action-needed { background: #fce7f3; color: #9d174d; }
+._pill-orphan { background: #fef3c7; color: #92400e; }
+._pill-severity { font-weight: 700; }
+._pill-critical { background: #dc2626; color: #fff; }
+._pill-high { background: #ea580c; color: #fff; }
+._pill-medium { background: #ca8a04; color: #fff; }
+._pill-low { background: #6b7280; color: #fff; }
+
+._comments-item-anchor {
+  font-size: 12px;
+  color: var(--comments-anchor-fg, #666);
+  font-style: italic;
+  margin-bottom: 6px;
+  cursor: pointer;
+  padding: 4px 6px;
+  background: var(--comments-anchor-bg, #f7f7f7);
+  border-radius: 3px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+._comments-item-anchor:hover { background: var(--comments-anchor-hover, #eef); }
+
+._comments-item-hint {
+  font-size: 11px;
+  color: var(--comments-meta-fg, #999);
+  margin-bottom: 4px;
+  font-style: italic;
+}
+
+._comments-item-body {
+  white-space: pre-wrap;
+  line-height: 1.4;
+  margin-bottom: 6px;
+}
+._comments-item-body a { color: var(--comments-btn-bg, #2563eb); text-decoration: underline; }
+
+/* Suggestion diff */
+._comments-item-suggestion {
+  font-size: 12px;
+  padding: 6px 8px;
+  background: var(--comments-anchor-bg, #f7f7f7);
+  border-radius: 4px;
+  margin-bottom: 6px;
+  line-height: 1.4;
+}
+._suggestion-label { font-weight: 600; color: var(--comments-anchor-fg, #666); }
+._comments-item-suggestion del { color: #dc2626; text-decoration: line-through; }
+._comments-item-suggestion ins { color: #16a34a; text-decoration: none; font-weight: 500; }
+
+/* Replies */
+._comments-replies {
+  border-left: 2px solid var(--comments-sidebar-border, #e0e0e0);
+  margin: 6px 0 6px 8px;
+  padding-left: 10px;
+}
+._comments-reply {
+  font-size: 12px;
+  margin-bottom: 4px;
+  line-height: 1.4;
+}
+._comments-reply-date { color: var(--comments-meta-fg, #999); font-size: 10px; }
+._comments-reply-input { margin-top: 8px; }
+._comments-reply-textarea {
+  width: 100%;
+  border: 1px solid var(--comments-sidebar-border, #ddd);
+  border-radius: 4px;
+  padding: 6px;
+  font-family: inherit;
+  font-size: 12px;
+  resize: vertical;
+  min-height: 40px;
+  background: var(--comments-input-bg, #fff);
+  color: var(--comments-sidebar-fg, #333);
+  box-sizing: border-box;
+}
+._comments-reply-textarea:focus { outline: none; border-color: var(--comments-btn-bg, #2563eb); }
+
+._comments-item-meta {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 11px;
+  color: var(--comments-meta-fg, #999);
+}
+._comments-item-actions button {
+  background: none;
+  border: none;
+  cursor: pointer;
+  font-size: 12px;
+  padding: 2px 5px;
+  border-radius: 3px;
+  color: var(--comments-meta-fg, #888);
+}
+._comments-item-actions button:hover { background: var(--comments-item-hover, #eee); color: var(--comments-sidebar-fg, #333); }
+
+._comments-empty {
+  text-align: center;
+  color: var(--comments-meta-fg, #999);
+  padding: 40px 20px;
+  line-height: 1.6;
+}
+._comments-empty kbd {
+  background: var(--comments-anchor-bg, #f0f0f0);
+  border: 1px solid var(--comments-sidebar-border, #ddd);
+  border-radius: 3px;
+  padding: 1px 5px;
+  font-family: inherit;
+  font-size: 12px;
+}
+
+/* ─── Edit textarea ───────────────────────────────────────── */
+
+._comments-edit-textarea {
+  width: 100%;
+  border: 1px solid var(--comments-sidebar-border, #ddd);
+  border-radius: 4px;
+  padding: 6px;
+  font-family: inherit;
+  font-size: 13px;
+  resize: vertical;
+  min-height: 50px;
+  background: var(--comments-input-bg, #fff);
+  color: var(--comments-sidebar-fg, #333);
+  box-sizing: border-box;
+}
+._comments-edit-textarea:focus { outline: none; border-color: var(--comments-btn-bg, #2563eb); }
+._comments-edit-actions { display: flex; gap: 6px; margin-top: 6px; }
+._comments-edit-actions button {
+  font-size: 11px;
+  padding: 3px 10px;
+  border-radius: 3px;
+  cursor: pointer;
+  border: 1px solid var(--comments-sidebar-border, #ddd);
+  background: none;
+  color: var(--comments-sidebar-fg, #555);
+}
+._comments-edit-actions ._comments-edit-save,
+._comments-edit-actions ._comments-reply-save {
+  background: var(--comments-btn-bg, #2563eb);
+  color: #fff;
+  border: none;
+}
+
+/* ─── Comment Input ───────────────────────────────────────── */
+
+#_comments-input {
+  position: absolute;
+  right: 20px;
+  z-index: 10002;
+  width: 340px;
+  background: var(--comments-sidebar-bg, #fff);
+  border: 1px solid var(--comments-sidebar-border, #ddd);
+  border-radius: 8px;
+  box-shadow: 0 4px 16px rgba(0,0,0,0.15);
+  padding: 12px;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
+  font-size: 13px;
+  color: var(--comments-sidebar-fg, #333);
+}
+._comments-input-header {
+  font-size: 12px;
+  color: var(--comments-meta-fg, #777);
+  margin-bottom: 8px;
+  line-height: 1.4;
+}
+._comments-input-header em { color: var(--comments-anchor-fg, #555); }
+._comments-input-textarea,
+._comments-input-suggestion {
+  width: 100%;
+  border: 1px solid var(--comments-sidebar-border, #ddd);
+  border-radius: 4px;
+  padding: 8px;
+  font-family: inherit;
+  font-size: 13px;
+  resize: vertical;
+  min-height: 50px;
+  background: var(--comments-input-bg, #fff);
+  color: var(--comments-sidebar-fg, #333);
+  box-sizing: border-box;
+  margin-bottom: 6px;
+}
+._comments-input-textarea:focus,
+._comments-input-suggestion:focus {
+  outline: none;
+  border-color: var(--comments-btn-bg, #2563eb);
+  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
+}
+._comments-input-options {
+  display: flex;
+  gap: 6px;
+  align-items: center;
+  margin-bottom: 6px;
+  flex-wrap: wrap;
+}
+._comments-input-options select {
+  border: 1px solid var(--comments-sidebar-border, #ddd);
+  border-radius: 3px;
+  padding: 3px 6px;
+  font-size: 11px;
+  background: var(--comments-input-bg, #fff);
+  color: var(--comments-sidebar-fg, #333);
+}
+._comments-input-suggest-label {
+  font-size: 11px;
+  color: var(--comments-meta-fg, #777);
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 3px;
+}
+._comments-input-actions {
+  display: flex;
+  gap: 8px;
+  justify-content: flex-end;
+}
+._comments-btn-save {
+  background: var(--comments-btn-bg, #2563eb);
+  color: #fff;
+  border: none;
+  border-radius: 4px;
+  padding: 6px 14px;
+  cursor: pointer;
+  font-size: 12px;
+  font-weight: 500;
+}
+._comments-btn-save:hover { opacity: 0.9; }
+._comments-btn-cancel {
+  background: none;
+  border: 1px solid var(--comments-sidebar-border, #ddd);
+  border-radius: 4px;
+  padding: 6px 14px;
+  cursor: pointer;
+  font-size: 12px;
+  color: var(--comments-sidebar-fg, #555);
+}
+._comments-btn-cancel:hover { background: var(--comments-item-hover, #f5f5f5); }
+
+/* ─── Dark Mode ───────────────────────────────────────────── */
+
+body._color-dark {
+  --comments-btn-bg: #3b82f6;
+  --comments-btn-fg: #fff;
+  --comments-highlight-bg: rgba(200, 160, 0, 0.2);
+  --comments-highlight-border: rgba(200, 160, 0, 0.4);
+  --comments-highlight-hover: rgba(200, 160, 0, 0.35);
+  --comments-sidebar-bg: #1e1e1e;
+  --comments-sidebar-border: #333;
+  --comments-sidebar-fg: #e0e0e0;
+  --comments-item-border: #333;
+  --comments-item-hover: #2a2a2a;
+  --comments-anchor-bg: #2a2a2a;
+  --comments-anchor-fg: #aaa;
+  --comments-anchor-hover: #333;
+  --comments-meta-fg: #777;
+  --comments-badge-bg: #3b82f6;
+  --comments-input-bg: #252525;
+}
+
+body._color-dark ._pill-tag,
+body._color-dark ._pill-note { background: #312e81; color: #c7d2fe; }
+body._color-dark ._pill-question { background: #1e3a5f; color: #93c5fd; }
+body._color-dark ._pill-suggestion { background: #064e3b; color: #6ee7b7; }
+body._color-dark ._pill-issue { background: #7f1d1d; color: #fca5a5; }
+body._color-dark ._pill-outdated { background: #78350f; color: #fde68a; }
+body._color-dark ._pill-action-needed { background: #831843; color: #f9a8d4; }
+
+/* ─── Responsive ──────────────────────────────────────────── */
+
+@media (max-width: 768px) {
+  #_comments-sidebar { width: 100%; right: -100%; }
+  #_comments-input { width: calc(100% - 40px); right: 20px; left: 20px; }
+}
diff --git a/content/comments.js b/content/comments.js
new file mode 100644
index 0000000..e936a2e
--- /dev/null
+++ b/content/comments.js
@@ -0,0 +1,1125 @@
+;(() => {
+  // ─── STATE ────────────────────────────────────────────────────
+
+  var comments = []
+  var pendingSelection = null
+  var sidebarVisible = false
+  var closeSidebarOnClick = true
+  var pageUrl = location.href
+  var authorName = ''
+  var filters = {status: 'all', tag: 'all', severity: 'all'}
+  var searchQuery = ''
+  var editingId = null
+  var replyingId = null
+  var showingInput = false
+  var showingTooltip = null // {top, left} or null
+  // `args` is set globally by background/inject.js before this script runs.
+  // Fall back to write-enabled if unavailable (e.g. loaded standalone/tests).
+  var writeEnabled = (typeof args !== 'undefined' && args.content)
+    ? !!args.content.commentsWrite
+    : true
+
+  var TAGS = ['note', 'question', 'suggestion', 'issue', 'outdated', 'action-needed']
+  var SEVERITIES = ['low', 'medium', 'high', 'critical']
+
+  // ─── SANITIZATION HELPERS ───────────────────────────────────────
+  // Only allow known-good values through to CSS class names / attributes.
+  // Anything imported from JSON or parsed from inline comments passes
+  // through these before being trusted anywhere.
+
+  function sanitizeTag (tag) {
+    return TAGS.indexOf(tag) !== -1 ? tag : null
+  }
+
+  function sanitizeSeverity (severity) {
+    return SEVERITIES.indexOf(severity) !== -1 ? severity : null
+  }
+
+  function sanitizeId (id, prefix) {
+    return (typeof id === 'string' && /^[\w-]+$/.test(id))
+      ? id
+      : (prefix || 'id_') + Date.now() + '_' + Math.random().toString(36).substring(2, 8)
+  }
+
+  function sanitizeComment (c) {
+    if (!c || typeof c !== 'object') return null
+    return {
+      id: sanitizeId(c.id, 'imp_'),
+      anchor: {
+        text: typeof c.anchor?.text === 'string' ? c.anchor.text.substring(0, 200) : '',
+        prefix: typeof c.anchor?.prefix === 'string' ? c.anchor.prefix.substring(0, 30) : '',
+        suffix: typeof c.anchor?.suffix === 'string' ? c.anchor.suffix.substring(0, 30) : '',
+        heading: typeof c.anchor?.heading === 'string' ? c.anchor.heading.substring(0, 200) : ''
+      },
+      body: typeof c.body === 'string' ? c.body : '',
+      author: typeof c.author === 'string' ? c.author.substring(0, 100) : null,
+      tag: sanitizeTag(c.tag),
+      severity: sanitizeSeverity(c.severity),
+      suggestion: typeof c.suggestion === 'string' ? c.suggestion : null,
+      replies: Array.isArray(c.replies) ? c.replies.map(sanitizeReply).filter(Boolean) : [],
+      createdAt: typeof c.createdAt === 'string' ? c.createdAt : new Date().toISOString(),
+      updatedAt: typeof c.updatedAt === 'string' ? c.updatedAt : null,
+      resolved: !!c.resolved,
+      _fromInline: !!c._fromInline
+    }
+  }
+
+  function sanitizeReply (r) {
+    if (!r || typeof r !== 'object') return null
+    return {
+      id: sanitizeId(r.id, 'r_'),
+      author: typeof r.author === 'string' ? r.author.substring(0, 100) : null,
+      body: typeof r.body === 'string' ? r.body : '',
+      createdAt: typeof r.createdAt === 'string' ? r.createdAt : new Date().toISOString()
+    }
+  }
+
+  // ─── INITIALIZATION ───────────────────────────────────────────
+
+  function init () {
+    loadAuthor()
+    loadComments()
+    if (writeEnabled) {
+      setupKeyboardShortcut()
+      setupSelectionListener()
+      setupMessageListener()
+    } else {
+      setupNavigationShortcut()
+      setupCloseOnClickListener()
+    }
+    mountUi()
+    createToggleButton()
+    watchContentReplacement()
+  }
+
+  // ─── STORAGE ──────────────────────────────────────────────────
+
+  function loadAuthor () {
+    chrome.storage.sync.get(['commentsAuthor', 'commentsCloseOnClick'], (res) => {
+      authorName = res.commentsAuthor || ''
+      closeSidebarOnClick = res.commentsCloseOnClick !== false // default true
+    })
+  }
+
+  function loadComments () {
+    chrome.runtime.sendMessage({
+      message: 'comments.load',
+      url: pageUrl
+    }, (res) => {
+      // Distinguish "background responded with zero comments" from
+      // "background didn't respond" (e.g. MV3 service worker was asleep
+      // and sendMessage resolved with undefined, or chrome.runtime.lastError
+      // was set). Treating the latter as "stored = []" would cause the
+      // merge below to silently overwrite real stored comments.
+      if (chrome.runtime.lastError || !res) {
+        console.error('[comments] failed to load stored comments, skipping merge/save:', chrome.runtime.lastError)
+        comments = parseInlineComments()
+        renderHighlights()
+        redraw()
+        updateBadge()
+        return
+      }
+
+      var stored = Array.isArray(res.comments) ? res.comments.map(sanitizeComment).filter(Boolean) : []
+      var inline = parseInlineComments()
+      comments = mergeComments(stored, inline)
+      if (comments.length > stored.length) {
+        // Persist merged result so inline comments are editable
+        saveComments()
+      }
+      renderHighlights()
+      redraw()
+      updateBadge()
+    })
+  }
+
+  function parseInlineComments () {
+    // The original 
 may have been replaced by Mithril's mount.
+    // Try to read it first; if empty, fetch the raw file.
+    var pre = document.querySelector('pre')
+    var source = pre ? (pre.textContent || pre.innerText || '') : ''
+
+    if (source && source.includes('/g
+    var match
+
+    while ((match = regex.exec(source)) !== null) {
+      var resolved = !!match[1]
+      var body = match[2].replace(/—/g, '--')
+
+      var commentStart = match.index
+      var commentEnd = commentStart + match[0].length
+
+      // Grab text before the comment on the same line
+      var beforeChunk = source.substring(Math.max(0, commentStart - 100), commentStart)
+      var lastNewline = beforeChunk.lastIndexOf('\n')
+      var lineBeforeComment = lastNewline >= 0 ? beforeChunk.substring(lastNewline + 1) : beforeChunk
+
+      // Grab text after the comment (up to 50 chars, stop at newline)
+      var afterChunk = source.substring(commentEnd, commentEnd + 50)
+      var firstNewline = afterChunk.indexOf('\n')
+      var lineAfterComment = firstNewline >= 0 ? afterChunk.substring(0, firstNewline) : afterChunk
+
+      // Use text before comment as anchor (strip markdown syntax)
+      var anchorText = lineBeforeComment.replace(/[#*_`>\[\]|]/g, '').trim()
+      if (anchorText.length < 3) {
+        anchorText = lineAfterComment.replace(/[#*_`>\[\]|]/g, '').trim()
+      }
+      if (anchorText.length < 3) continue
+
+      // Prefix for disambiguation
+      var prefixStart = Math.max(0, commentStart - 130)
+      var prefixChunk = source.substring(prefixStart, Math.max(0, commentStart - 100))
+      var prefix = prefixChunk.replace(/[#*_`>\[\]|]/g, '').trim()
+
+      // Find nearest heading above
+      var heading = ''
+      var beforeAll = source.substring(0, commentStart)
+      var headingMatches = beforeAll.match(/^#{1,6}\s+.+$/gm)
+      if (headingMatches) heading = headingMatches[headingMatches.length - 1].replace(/^#+\s*/, '')
+
+      results.push(sanitizeComment({
+        id: 'inline_' + hashCode(anchorText + body),
+        anchor: {
+          text: anchorText.substring(0, 200),
+          prefix: prefix.substring(0, 30),
+          suffix: lineAfterComment.replace(/[#*_`>\[\]|]/g, '').trim().substring(0, 30),
+          heading: heading
+        },
+        body: body.trim(),
+        author: null,
+        tag: null,
+        severity: null,
+        suggestion: null,
+        replies: [],
+        createdAt: new Date().toISOString(),
+        updatedAt: null,
+        resolved: resolved,
+        _fromInline: true
+      }))
+    }
+    return results
+  }
+
+  function mergeComments (stored, inline) {
+    if (!inline.length) return stored
+    if (!stored.length) return inline
+
+    // Merge: stored comments take priority (user may have edited them)
+    var storedIds = new Set(stored.map((c) => c.id))
+    var newInline = inline.filter((c) => !storedIds.has(c.id))
+
+    // Also check by anchor text to avoid duplicating if ID format differs
+    var storedAnchors = new Set(stored.map((c) => c.anchor.text + '|||' + c.body))
+    var deduped = newInline.filter((c) => !storedAnchors.has(c.anchor.text + '|||' + c.body))
+
+    return stored.concat(deduped)
+  }
+
+  function hashCode (str) {
+    var hash = 0
+    for (var i = 0; i < str.length; i++) {
+      var ch = str.charCodeAt(i)
+      hash = ((hash << 5) - hash) + ch
+      hash |= 0
+    }
+    return Math.abs(hash).toString(36)
+  }
+
+  function saveComments () {
+    // Strip internal edit/reply scratch fields (_draftBody/_draftReply)
+    // before persisting — they're only used for the controlled-textarea
+    // pattern in renderEditBox/renderReplyBox and should never reach
+    // storage, e.g. if a user abandons an edit without pressing Escape.
+    var toPersist = comments.map((c) => {
+      var clean = Object.assign({}, c)
+      delete clean._draftBody
+      delete clean._draftReply
+      return clean
+    })
+    chrome.runtime.sendMessage({
+      message: 'comments.save',
+      url: pageUrl,
+      comments: toPersist
+    }, () => {
+      if (chrome.runtime.lastError) {
+        console.error('[comments] failed to save comments:', chrome.runtime.lastError)
+      }
+    })
+    updateBadge()
+  }
+
+  // ─── SELECTION HANDLING ───────────────────────────────────────
+
+  function setupSelectionListener () {
+    document.addEventListener('mouseup', (e) => {
+      if (e.target.closest('#_comments-sidebar') || e.target.closest('#_comments-input') || e.target.closest('#_comments-tooltip')) {
+        return
+      }
+      showingTooltip = null
+      var sel = window.getSelection()
+      if (sel && sel.toString().trim().length > 0) {
+        pendingSelection = captureSelection(sel)
+        showingTooltip = {top: e.pageY - 40, left: e.pageX}
+        redraw()
+      } else {
+        redraw()
+      }
+    })
+    setupCloseOnClickListener()
+  }
+
+  function setupCloseOnClickListener () {
+    document.addEventListener('mousedown', (e) => {
+      if (!e.target.closest('#_comments-tooltip')) {
+        if (showingTooltip) { showingTooltip = null; redraw() }
+      }
+      // Close sidebar on click in document (not on our UI elements)
+      if (sidebarVisible && closeSidebarOnClick
+          && !e.target.closest('#_comments-sidebar')
+          && !e.target.closest('#_comments-toggle')
+          && !e.target.closest('#_comments-input')
+          && !e.target.closest('#_comments-tooltip')
+          && !e.target.closest('mark._comment-highlight')) {
+        hideSidebar()
+      }
+    })
+  }
+
+  function setupKeyboardShortcut () {
+    document.addEventListener('keydown', (e) => {
+      // Cmd+Shift+K — add comment (write mode only; init() only calls this fn when writeEnabled)
+      if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'K') {
+        e.preventDefault()
+        e.stopPropagation()
+        var sel = window.getSelection()
+        if (sel && sel.toString().trim().length > 0) {
+          pendingSelection = captureSelection(sel)
+          showCommentInput()
+        }
+      }
+    })
+    setupNavigationShortcut()
+  }
+
+  function setupNavigationShortcut () {
+    document.addEventListener('keydown', (e) => {
+      // Ctrl+] — next comment
+      if (e.ctrlKey && e.key === ']') {
+        e.preventDefault()
+        navigateComment(1)
+      }
+      // Ctrl+[ — previous comment
+      if (e.ctrlKey && e.key === '[') {
+        e.preventDefault()
+        navigateComment(-1)
+      }
+    })
+  }
+
+  function setupMessageListener () {
+    chrome.runtime.onMessage.addListener((req) => {
+      if (req.message === 'comments.add-from-menu') {
+        if (pendingSelection) showCommentInput()
+      }
+    })
+  }
+
+  function captureSelection (sel) {
+    var range = sel.getRangeAt(0)
+    var text = sel.toString().trim()
+    var container = range.commonAncestorContainer
+    var fullText = (container.textContent || '')
+    var startOffset = fullText.indexOf(text)
+    var prefix = fullText.substring(Math.max(0, startOffset - 30), startOffset)
+    var suffix = fullText.substring(startOffset + text.length, startOffset + text.length + 30)
+
+    var heading = ''
+    var node = range.startContainer
+    while (node && node !== document.body) {
+      if (node.previousElementSibling) {
+        var prev = node.previousElementSibling
+        if (/^H[1-6]$/.test(prev.tagName)) {
+          heading = prev.textContent
+          break
+        }
+      }
+      node = node.parentElement
+    }
+
+    var rect = range.getBoundingClientRect()
+    return {
+      text: text.substring(0, 200),
+      prefix: prefix,
+      suffix: suffix,
+      heading: heading,
+      rect: {top: rect.top + window.scrollY, left: rect.left}
+    }
+  }
+
+  // ─── HIGHLIGHTS ───────────────────────────────────────────────
+
+  var anchoredIds = new Set()
+  var applyingHighlights = false
+
+  function renderHighlights () {
+    applyingHighlights = true
+    document.querySelectorAll('mark._comment-highlight').forEach((el) => el.replaceWith(...el.childNodes))
+    anchoredIds.clear()
+    comments.forEach((comment) => {
+      if (comment.resolved) return
+      if (highlightText(comment)) {
+        anchoredIds.add(comment.id)
+      }
+    })
+    redraw() // orphan/anchored state in the sidebar may have changed
+    // Let the DOM settle before re-arming mutation detection, so our
+    // own  insertions/removals above don't re-trigger the observer.
+    requestAnimationFrame(() => { applyingHighlights = false })
+  }
+
+  // content/index.js's own Mithril app rebuilds #_html/#_markdown from
+  // scratch on autoreload, theme switch, and raw-view toggle (m.trust()
+  // replaces the whole subtree), destroying our  elements each
+  // time with no callback into this script. Watch for that and
+  // re-apply highlights rather than leaving them silently gone.
+  var highlightObserver = null
+
+  function watchContentReplacement () {
+    var content = document.getElementById('_html') || document.getElementById('_markdown')
+    if (!content || !content.parentElement) return
+
+    var debounceTimer = null
+    highlightObserver = new MutationObserver(() => {
+      if (applyingHighlights) return // ignore mutations caused by our own renderHighlights()
+      // A single Mithril re-render can fire multiple mutation records;
+      // coalesce into one renderHighlights() call.
+      clearTimeout(debounceTimer)
+      debounceTimer = setTimeout(renderHighlights, 50)
+    })
+    // Observe the parent, since #_html/#_markdown itself may be replaced
+    // wholesale (not just mutated) on theme/raw toggles.
+    highlightObserver.observe(content.parentElement, {childList: true, subtree: true})
+  }
+
+  function highlightText (comment) {
+    var content = document.getElementById('_html') || document.getElementById('_markdown')
+    if (!content) return false
+
+    var walker = document.createTreeWalker(content, NodeFilter.SHOW_TEXT, null)
+    var searchText = comment.anchor.text
+    var node
+
+    while ((node = walker.nextNode())) {
+      var idx = node.textContent.indexOf(searchText)
+      if (idx === -1) continue
+
+      var parentText = node.parentElement.textContent || ''
+      if (comment.anchor.prefix && !parentText.includes(comment.anchor.prefix + searchText)) {
+        if (comment.anchor.suffix && !parentText.includes(searchText + comment.anchor.suffix)) {
+          continue
+        }
+      }
+
+      if (highlightWithinSingleNode(node, idx, searchText, comment)) return true
+      // surroundContents() threw for this single-node match (shouldn't
+      // normally happen for a genuinely single-node range, but fall
+      // through to the cross-node path defensively rather than give up).
+      break
+    }
+
+    // No single text node contains the full anchor text — this is the
+    // common case for anchors that span an inline element boundary
+    // (bold/code/link). Search across node boundaries instead.
+    return highlightAcrossNodes(content, searchText, comment)
+  }
+
+  function highlightWithinSingleNode (node, idx, searchText, comment) {
+    var range = document.createRange()
+    range.setStart(node, idx)
+    range.setEnd(node, Math.min(idx + searchText.length, node.textContent.length))
+
+    var mark = document.createElement('mark')
+    mark.className = '_comment-highlight'
+    if (comment.severity) mark.classList.add('_severity-' + comment.severity)
+    mark.dataset.commentId = comment.id
+    mark.addEventListener('click', () => {
+      showSidebar()
+      scrollSidebarTo(comment.id)
+    })
+
+    try {
+      range.surroundContents(mark)
+      return true
+    } catch (e) {
+      // Range spans multiple elements (e.g. crosses a //
+      // boundary) — surroundContents() cannot wrap a partial multi-element
+      // range. Caller falls back to highlightAcrossNodes().
+      return false
+    }
+  }
+
+  // Fallback for anchors that span more than one text node/element.
+  // Finds all text nodes under `root` that together contain `searchText`
+  // (allowing it to cross element boundaries) and wraps each node's
+  // matching segment individually, rather than requiring a single
+  // contiguous Range.surroundContents() call.
+  function highlightAcrossNodes (root, searchText, comment) {
+    var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null)
+    var textNodes = []
+    var node
+    while ((node = walker.nextNode())) textNodes.push(node)
+
+    var combined = textNodes.map((n) => n.textContent).join('')
+    var start = combined.indexOf(searchText)
+    if (start === -1) return false
+    var end = start + searchText.length
+
+    var wrapped = []
+    var pos = 0
+    for (var i = 0; i < textNodes.length; i++) {
+      var n = textNodes[i]
+      var len = n.textContent.length
+      var nodeStart = pos
+      var nodeEnd = pos + len
+      pos += len
+
+      var overlapStart = Math.max(start, nodeStart)
+      var overlapEnd = Math.min(end, nodeEnd)
+      if (overlapStart >= overlapEnd) continue // no overlap with this node
+
+      var localStart = overlapStart - nodeStart
+      var localEnd = overlapEnd - nodeStart
+
+      try {
+        var range = document.createRange()
+        range.setStart(n, localStart)
+        range.setEnd(n, localEnd)
+        var mark = document.createElement('mark')
+        mark.className = '_comment-highlight'
+        if (comment.severity) mark.classList.add('_severity-' + comment.severity)
+        mark.dataset.commentId = comment.id
+        mark.addEventListener('click', () => {
+          showSidebar()
+          scrollSidebarTo(comment.id)
+        })
+        range.surroundContents(mark)
+        wrapped.push(mark)
+      } catch (e) {
+        // A single text-node segment should never throw (it's always a
+        // simple, single-node range), but guard anyway rather than
+        // leaving a partially-wrapped anchor.
+        return wrapped.length > 0
+      }
+    }
+    return wrapped.length > 0
+  }
+
+  // ─── KEYBOARD NAVIGATION ──────────────────────────────────────
+
+  var navIndex = -1
+
+  function navigateComment (direction) {
+    var open = comments.filter((c) => !c.resolved)
+    if (!open.length) return
+    navIndex = (navIndex + direction + open.length) % open.length
+    var c = open[navIndex]
+    scrollToHighlight(c.id)
+    showSidebar()
+    scrollSidebarTo(c.id)
+  }
+
+  // ─── MITHRIL UI ───────────────────────────────────────────────
+  // Everything that renders comment content (body, tag, severity, id,
+  // author, replies, anchor text) goes through Mithril's `m()`, which
+  // treats all non-`m.trust()` children as text nodes and escapes them
+  // by construction. There is no innerHTML template-string surface here
+  // for a stray unescaped field to slip through.
+
+  var uiRoot = null
+
+  function mountUi () {
+    // content/index.js does `m.mount($('body'), {...})`, which means
+    // Mithril owns and fully diffs body's children on every redraw
+    // (theme switch, autoreload, raw toggle, etc). Any DOM node we append
+    // as a child of  that isn't part of that app's own vnode tree
+    // gets silently removed on its next redraw. Mount as a sibling of
+    //  instead (a child of ) so it's outside that app's
+    // managed subtree entirely.
+    uiRoot = document.createElement('div')
+    uiRoot.id = '_comments-ui-root'
+    document.documentElement.appendChild(uiRoot)
+    m.mount(uiRoot, {view: renderUi})
+  }
+
+  function redraw () {
+    if (uiRoot) m.redraw()
+  }
+
+  function renderUi () {
+    return [
+      renderTooltip(),
+      showingInput && renderCommentInput(),
+      renderSidebarPanel()
+    ]
+  }
+
+  function renderTooltip () {
+    if (!showingTooltip) return null
+    return m('div#_comments-tooltip', {
+      style: {top: showingTooltip.top + 'px', left: showingTooltip.left + 'px'},
+      onclick: () => {
+        showingTooltip = null
+        showCommentInput()
+      }
+    }, '💬 Comment')
+  }
+
+  function showCommentInput () {
+    showingTooltip = null
+    showingInput = true
+    inputDraft = {body: '', tag: '', severity: '', suggestOn: false, suggestion: ''}
+    redraw()
+  }
+
+  function removeCommentInput () {
+    showingInput = false
+    pendingSelection = null
+    redraw()
+  }
+
+  var inputDraft = {body: '', tag: '', severity: '', suggestOn: false, suggestion: ''}
+
+  function renderCommentInput () {
+    if (!pendingSelection) return null
+    var top = pendingSelection.rect.top + 30
+    var label = pendingSelection.text.substring(0, 50) + (pendingSelection.text.length > 50 ? '…' : '')
+
+    return m('div#_comments-input', {style: {top: top + 'px'}},
+      m('div._comments-input-header',
+        m('span', 'Comment on: ', m('em', '"' + label + '"'))
+      ),
+      m('textarea._comments-input-textarea', {
+        rows: 3,
+        placeholder: 'Type your comment...',
+        value: inputDraft.body,
+        oncreate: (vnode) => vnode.dom.focus(),
+        oninput: (e) => { inputDraft.body = e.target.value },
+        onkeydown: (e) => {
+          if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); saveNewComment() }
+          if (e.key === 'Escape') removeCommentInput()
+        }
+      }),
+      m('div._comments-input-options',
+        m('select._comments-input-tag', {
+          title: 'Tag (optional)',
+          value: inputDraft.tag,
+          onchange: (e) => { inputDraft.tag = e.target.value }
+        },
+          m('option', {value: ''}, '— tag —'),
+          TAGS.map((t) => m('option', {value: t}, t))
+        ),
+        m('select._comments-input-severity', {
+          title: 'Severity (optional)',
+          value: inputDraft.severity,
+          onchange: (e) => { inputDraft.severity = e.target.value }
+        },
+          m('option', {value: ''}, '— severity —'),
+          SEVERITIES.map((s) => m('option', {value: s}, s))
+        ),
+        m('label._comments-input-suggest-label',
+          m('input[type=checkbox]._comments-input-suggest-toggle', {
+            checked: inputDraft.suggestOn,
+            onchange: (e) => { inputDraft.suggestOn = e.target.checked }
+          }),
+          ' Suggest replacement'
+        )
+      ),
+      inputDraft.suggestOn && m('textarea._comments-input-suggestion', {
+        rows: 2,
+        placeholder: 'Suggested replacement text...',
+        value: inputDraft.suggestion,
+        oncreate: (vnode) => vnode.dom.focus(),
+        oninput: (e) => { inputDraft.suggestion = e.target.value },
+        onkeydown: (e) => {
+          if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); saveNewComment() }
+          if (e.key === 'Escape') removeCommentInput()
+        }
+      }),
+      m('div._comments-input-actions',
+        m('button._comments-btn-save', {onclick: saveNewComment}, 'Save (⌘↵)'),
+        m('button._comments-btn-cancel', {onclick: removeCommentInput}, 'Cancel')
+      )
+    )
+  }
+
+  function saveNewComment () {
+    var body = inputDraft.body.trim()
+    if (!body || !pendingSelection) return
+
+    var comment = {
+      id: 'c_' + Date.now() + '_' + Math.random().toString(36).substring(2, 6),
+      anchor: {
+        text: pendingSelection.text,
+        prefix: pendingSelection.prefix,
+        suffix: pendingSelection.suffix,
+        heading: pendingSelection.heading
+      },
+      body: body,
+      author: authorName || null,
+      tag: sanitizeTag(inputDraft.tag) || null,
+      severity: sanitizeSeverity(inputDraft.severity) || null,
+      suggestion: inputDraft.suggestOn ? (inputDraft.suggestion.trim() || null) : null,
+      replies: [],
+      createdAt: new Date().toISOString(),
+      updatedAt: null,
+      resolved: false
+    }
+
+    comments.push(comment)
+    saveComments()
+    removeCommentInput()
+    renderHighlights()
+    redraw()
+    window.getSelection().removeAllRanges()
+  }
+
+  // ─── SIDEBAR ──────────────────────────────────────────────────
+
+  function toggleSidebar () { sidebarVisible ? hideSidebar() : showSidebar() }
+  function showSidebar () { sidebarVisible = true; redraw() }
+  function hideSidebar () { sidebarVisible = false; redraw() }
+
+  function createToggleButton () {
+    var btn = document.createElement('button')
+    btn.id = '_comments-toggle'
+    btn.title = 'Toggle Comments Panel (Ctrl+] / Ctrl+[ to navigate)'
+    btn.textContent = '💬'
+    btn.addEventListener('click', toggleSidebar)
+    // Same reasoning as mountUi(): append outside  since the main
+    // app's m.mount($('body'), ...) would otherwise strip this on redraw.
+    document.documentElement.appendChild(btn)
+  }
+
+  function getFilteredComments () {
+    return comments.filter((c) => {
+      if (filters.status === 'open' && c.resolved) return false
+      if (filters.status === 'resolved' && !c.resolved) return false
+      if (filters.tag !== 'all' && c.tag !== filters.tag) return false
+      if (filters.severity !== 'all' && c.severity !== filters.severity) return false
+      if (searchQuery) {
+        var hay = [c.body, c.anchor.text, c.tag || '', c.author || '', c.suggestion || ''].join(' ').toLowerCase()
+        if (!hay.includes(searchQuery)) return false
+      }
+      return true
+    })
+  }
+
+  function renderSidebarPanel () {
+    return m('div#_comments-sidebar', {class: sidebarVisible ? '_visible' : ''},
+      renderSidebarHeader(),
+      renderSidebarFilters(),
+      m('div._comments-sidebar-body', renderSidebarBody())
+    )
+  }
+
+  function renderSidebarHeader () {
+    var openCount = comments.filter((c) => !c.resolved).length
+    return m('div._comments-sidebar-header',
+      m('span._comments-title', 'Comments ',
+        m('span._comments-badge', {style: {display: openCount > 0 ? 'inline' : 'none'}}, String(openCount))
+      ),
+      m('div._comments-sidebar-actions',
+        writeEnabled && m('button._comments-btn-import', {title: 'Import comments from a JSON file', onclick: importComments}, '⬆'),
+        writeEnabled && m('button._comments-btn-resolve-all', {title: 'Resolve all comments', onclick: resolveAll}, '✓ All'),
+        writeEnabled && m('button._comments-btn-delete-all', {title: 'Delete all comments', onclick: deleteAll}, '🗑 All'),
+        m('button._comments-btn-export-json', {title: 'Download comments as structured JSON data file', onclick: exportCommentsJson}, '⬇ JSON'),
+        m('button._comments-btn-export-md', {title: 'Download markdown file with comments embedded as HTML comments', onclick: exportCommentsMd}, '⬇ MD'),
+        m('button._comments-btn-close', {title: 'Close comments panel', onclick: hideSidebar}, '✕')
+      )
+    )
+  }
+
+  function renderSidebarFilters () {
+    return m('div._comments-sidebar-filters',
+      m('input._comments-search[type=text]', {
+        placeholder: 'Search comments...',
+        title: 'Search comment text, tags, authors',
+        value: searchQuery,
+        oninput: (e) => { searchQuery = e.target.value.toLowerCase(); redraw() }
+      }),
+      m('div._comments-filter-row',
+        m('select._comments-filter-status', {
+          title: 'Filter by status',
+          value: filters.status,
+          onchange: (e) => { filters.status = e.target.value; redraw() }
+        },
+          m('option', {value: 'all'}, 'All'),
+          m('option', {value: 'open'}, 'Open'),
+          m('option', {value: 'resolved'}, 'Resolved')
+        ),
+        m('select._comments-filter-tag', {
+          title: 'Filter by tag',
+          value: filters.tag,
+          onchange: (e) => { filters.tag = e.target.value; redraw() }
+        },
+          m('option', {value: 'all'}, 'All tags'),
+          TAGS.map((t) => m('option', {value: t}, t))
+        ),
+        m('select._comments-filter-severity', {
+          title: 'Filter by severity',
+          value: filters.severity,
+          onchange: (e) => { filters.severity = e.target.value; redraw() }
+        },
+          m('option', {value: 'all'}, 'All severity'),
+          SEVERITIES.map((s) => m('option', {value: s}, s))
+        )
+      )
+    )
+  }
+
+  function renderSidebarBody () {
+    if (comments.length === 0) {
+      return m('div._comments-empty',
+        'No comments yet.', m('br'),
+        'Select text and click the 💬 tooltip,', m('br'),
+        'or press ', m('kbd', '⌘⇧K'), ' to add a comment.'
+      )
+    }
+
+    var filtered = getFilteredComments()
+    if (filtered.length === 0) {
+      return m('div._comments-empty', 'No comments match current filters.')
+    }
+
+    return filtered.map(renderCommentItem)
+  }
+
+  function renderCommentItem (c) {
+    var isOrphan = !c.resolved && !anchoredIds.has(c.id)
+    var isEditing = editingId === c.id
+    var isReplying = replyingId === c.id
+
+    return m('div._comments-item', {
+      key: c.id,
+      class: [c.resolved && '_resolved', isOrphan && '_orphan'].filter(Boolean).join(' '),
+      'data-id': c.id,
+      onclick: () => scrollToHighlight(c.id)
+    },
+      m('div._comments-item-pills',
+        isOrphan && m('span._pill._pill-orphan', {title: 'Anchor text not found in document'}, '⚠️ unanchored'),
+        c.tag && m('span._pill._pill-tag', {class: '_pill-' + c.tag}, c.tag),
+        c.severity && m('span._pill._pill-severity', {class: '_pill-' + c.severity}, c.severity)
+      ),
+      m('div._comments-item-anchor', {title: c.anchor.text},
+        '"' + c.anchor.text.substring(0, 60) + (c.anchor.text.length > 60 ? '…' : '') + '"'
+      ),
+      isOrphan && c.anchor.heading && m('div._comments-item-hint', 'Was near: ' + c.anchor.heading),
+      isEditing ? renderEditBox(c) : m('div._comments-item-body', renderLinkified(c.body)),
+      c.suggestion && m('div._comments-item-suggestion',
+        m('span._suggestion-label', 'Suggestion:'), ' ',
+        m('del', c.anchor.text.substring(0, 80)), ' → ', m('ins', c.suggestion.substring(0, 80))
+      ),
+      c.replies && c.replies.length > 0 && m('div._comments-replies',
+        c.replies.map((r) => m('div._comments-reply', {key: r.id},
+          m('strong', (r.author || 'Anonymous') + ':'), ' ',
+          renderLinkified(r.body), ' ',
+          m('span._comments-reply-date', formatDate(r.createdAt))
+        ))
+      ),
+      isReplying && renderReplyBox(c),
+      m('div._comments-item-meta',
+        m('span._comments-item-date', (c.author ? c.author + ' · ' : '') + formatDate(c.createdAt)),
+        writeEnabled && !isEditing && m('span._comments-item-actions',
+          m('button._comments-btn-reply', {title: 'Reply', onclick: (e) => { e.stopPropagation(); replyingId = replyingId === c.id ? null : c.id; redraw() }}, '↩'),
+          m('button._comments-btn-edit', {title: 'Edit', onclick: (e) => { e.stopPropagation(); editingId = c.id; redraw() }}, '✎'),
+          c.resolved
+            ? m('button._comments-btn-unresolve', {title: 'Reopen', onclick: (e) => { e.stopPropagation(); unresolveComment(c.id) }}, '↺')
+            : m('button._comments-btn-resolve', {title: 'Resolve', onclick: (e) => { e.stopPropagation(); resolveComment(c.id) }}, '✓'),
+          m('button._comments-btn-delete', {title: 'Delete', onclick: (e) => { e.stopPropagation(); deleteComment(c.id) }}, '✕')
+        )
+      )
+    )
+  }
+
+  // Renders text with bare https?:// URLs turned into clickable links.
+  // Operates on plain (unescaped) text and returns an array of Mithril
+  // vnodes/strings — escaping is handled by Mithril itself since none of
+  // the pieces are passed through m.trust().
+  function renderLinkified (text) {
+    var parts = String(text || '').split(/(https?:\/\/[^\s<]+)/g)
+    return parts.map((part, i) =>
+      /^https?:\/\//.test(part)
+        ? m('a', {key: i, href: encodeURI(part), target: '_blank', rel: 'noopener'}, part)
+        : part
+    )
+  }
+
+  function renderEditBox (c) {
+    if (c._draftBody === undefined) c._draftBody = c.body
+    return m('div._comments-item-body',
+      m('textarea._comments-edit-textarea', {
+        value: c._draftBody,
+        oncreate: (vnode) => {
+          vnode.dom.focus()
+          vnode.dom.setSelectionRange(vnode.dom.value.length, vnode.dom.value.length)
+        },
+        oninput: (e) => { c._draftBody = e.target.value },
+        onkeydown: (e) => {
+          if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); commitEdit(c) }
+          if (e.key === 'Escape') { delete c._draftBody; editingId = null; redraw() }
+        }
+      }),
+      m('div._comments-edit-actions',
+        m('button._comments-edit-save', {onclick: () => commitEdit(c)}, 'Save'),
+        m('button._comments-edit-cancel', {onclick: () => { delete c._draftBody; editingId = null; redraw() }}, 'Cancel')
+      )
+    )
+  }
+
+  function commitEdit (c) {
+    var v = (c._draftBody !== undefined ? c._draftBody : c.body).trim()
+    if (v) { c.body = v; c.updatedAt = new Date().toISOString(); saveComments() }
+    delete c._draftBody
+    editingId = null
+    redraw()
+  }
+
+  function renderReplyBox (c) {
+    if (c._draftReply === undefined) c._draftReply = ''
+    return m('div._comments-reply-input',
+      m('textarea._comments-reply-textarea', {
+        rows: 2,
+        placeholder: 'Reply...',
+        value: c._draftReply,
+        oncreate: (vnode) => vnode.dom.focus(),
+        oninput: (e) => { c._draftReply = e.target.value },
+        onkeydown: (e) => {
+          if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); commitReply(c) }
+          if (e.key === 'Escape') { delete c._draftReply; replyingId = null; redraw() }
+        }
+      }),
+      m('div._comments-edit-actions',
+        m('button._comments-reply-save', {onclick: () => commitReply(c)}, 'Reply'),
+        m('button._comments-reply-cancel', {onclick: () => { delete c._draftReply; replyingId = null; redraw() }}, 'Cancel')
+      )
+    )
+  }
+
+  function commitReply (c) {
+    var v = (c._draftReply || '').trim()
+    if (v) {
+      if (!c.replies) c.replies = []
+      c.replies.push({
+        id: 'r_' + Date.now() + '_' + Math.random().toString(36).substring(2, 6),
+        author: authorName || null,
+        body: v,
+        createdAt: new Date().toISOString()
+      })
+      saveComments()
+    }
+    delete c._draftReply
+    replyingId = null
+    redraw()
+  }
+
+  function scrollSidebarTo (commentId) {
+    // Wait a tick for Mithril to render the sidebar (e.g. after showSidebar())
+    requestAnimationFrame(() => {
+      var item = document.querySelector('._comments-item[data-id="' + commentId + '"]')
+      if (item) {
+        item.scrollIntoView({behavior: 'smooth', block: 'center'})
+        item.classList.add('_flash')
+        setTimeout(() => item.classList.remove('_flash'), 1000)
+      }
+    })
+  }
+
+  function scrollToHighlight (commentId) {
+    var mark = document.querySelector('mark[data-comment-id="' + commentId + '"]')
+    if (mark) {
+      // Force scroll by computing absolute position and scrolling directly
+      var rect = mark.getBoundingClientRect()
+      var scrollTop = window.pageYOffset || document.documentElement.scrollTop
+      var targetY = rect.top + scrollTop - (window.innerHeight / 2)
+      window.scrollTo({top: targetY, behavior: 'smooth'})
+      mark.classList.remove('_flash')
+      void mark.offsetWidth // force reflow to restart animation
+      mark.classList.add('_flash')
+      setTimeout(() => mark.classList.remove('_flash'), 1000)
+    }
+  }
+
+  // ─── COMMENT OPERATIONS ───────────────────────────────────────
+
+  function resolveComment (id) {
+    var c = comments.find((c) => c.id === id)
+    if (c) { c.resolved = true; saveComments(); renderHighlights(); redraw() }
+  }
+
+  function unresolveComment (id) {
+    var c = comments.find((c) => c.id === id)
+    if (c) { c.resolved = false; saveComments(); renderHighlights(); redraw() }
+  }
+
+  function deleteComment (id) {
+    comments = comments.filter((c) => c.id !== id)
+    saveComments(); renderHighlights(); redraw()
+  }
+
+  function resolveAll () {
+    var openCount = comments.filter((c) => !c.resolved).length
+    if (!openCount) return
+    if (!confirm('Resolve all ' + openCount + ' open comment(s)?')) return
+    comments.forEach((c) => { c.resolved = true })
+    saveComments(); renderHighlights(); redraw()
+  }
+
+  function deleteAll () {
+    if (!comments.length) return
+    if (!confirm('Delete all ' + comments.length + ' comment(s)?')) return
+    comments = []
+    saveComments(); renderHighlights(); redraw()
+  }
+
+  // ─── IMPORT / EXPORT ──────────────────────────────────────────
+
+  function importComments () {
+    var input = document.createElement('input')
+    input.type = 'file'
+    input.accept = '.json'
+    input.addEventListener('change', (e) => {
+      var file = e.target.files[0]
+      if (!file) return
+      var reader = new FileReader()
+      reader.onload = () => {
+        try {
+          var data = JSON.parse(reader.result)
+          var imported = data.comments || data
+          if (!Array.isArray(imported)) { alert('Invalid comments file'); return }
+
+          // All imported records pass through sanitizeComment() — untrusted
+          // JSON can otherwise carry crafted id/tag/severity/author values.
+          var sanitized = imported.map(sanitizeComment).filter(Boolean)
+
+          var mode = comments.length > 0
+            ? confirm('Merge with existing comments?\n\nOK = Merge\nCancel = Replace all')
+            : true
+
+          if (mode) {
+            // Merge — skip duplicates by ID
+            var existingIds = new Set(comments.map((c) => c.id))
+            var newOnes = sanitized.filter((c) => !existingIds.has(c.id))
+            comments = comments.concat(newOnes)
+          } else {
+            comments = sanitized
+          }
+
+          saveComments()
+          renderHighlights()
+          redraw()
+        } catch (err) {
+          alert('Failed to parse file: ' + err.message)
+        }
+      }
+      reader.readAsText(file)
+    })
+    input.click()
+  }
+
+  function exportCommentsJson () {
+    var filename = safeExportFilename('.comments.json')
+    chrome.runtime.sendMessage({
+      message: 'comments.export',
+      url: pageUrl,
+      filename: filename
+    })
+  }
+
+  function exportCommentsMd () {
+    var filename = safeExportFilename('.commented.md', true)
+    chrome.runtime.sendMessage({
+      message: 'comments.export-md',
+      url: pageUrl,
+      filename: filename,
+      comments: comments
+    })
+  }
+
+  function safeExportFilename (suffix, stripMd) {
+    var base = 'document'
+    try {
+      var pathParts = new URL(pageUrl).pathname.split('/')
+      base = pathParts[pathParts.length - 1] || 'document'
+    } catch (e) {
+      // Malformed/non-standard pageUrl (e.g. about:blank) — fall back
+      // to a generic filename rather than throwing and breaking export.
+    }
+    if (stripMd) base = base.replace(/\.md$/i, '')
+    return base + suffix
+  }
+
+  // ─── BADGE ────────────────────────────────────────────────────
+
+  function updateBadge () {
+    var count = comments.filter((c) => !c.resolved).length
+    var toggle = document.getElementById('_comments-toggle')
+    if (toggle) {
+      toggle.textContent = count > 0 ? `💬 ${count}` : '💬'
+    }
+    // Extension icon badge
+    chrome.runtime.sendMessage({message: 'comments.badge', count: count})
+  }
+
+  function formatDate (iso) {
+    if (!iso) return ''
+    var d = new Date(iso)
+    return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
+  }
+
+  // ─── BOOT ─────────────────────────────────────────────────────
+
+  var bootAttempts = 0
+  var bootInterval = setInterval(() => {
+    if (document.getElementById('_html') || document.getElementById('_markdown')) {
+      clearInterval(bootInterval)
+      init()
+      return
+    }
+    if (++bootAttempts > 50) { // ~5s cap
+      clearInterval(bootInterval)
+      console.warn('[comments] gave up waiting for #_html/#_markdown to render')
+    }
+  }, 100)
+})()
diff --git a/manifest.chrome.json b/manifest.chrome.json
index 7f52256..8e9f147 100644
--- a/manifest.chrome.json
+++ b/manifest.chrome.json
@@ -1,7 +1,7 @@
 {
   "manifest_version": 3,
   "name"            : "Markdown Viewer",
-  "version"         : "5.3",
+  "version"         : "5.4",
   "description"     : "Dark Mode • Themes • Autoreload • Mermaid Diagrams • MathJax • ToC • Syntax Highlighting",
 
   "homepage_url": "https://chromewebstore.google.com/detail/markdown-viewer/ckkdlimhmcjmikdlpkmbgfkaikojcbjk",
@@ -52,7 +52,9 @@
 
   "permissions": [
     "storage",
-    "scripting"
+    "scripting",
+    "contextMenus",
+    "downloads"
   ],
 
   "host_permissions": [
diff --git a/popup/index.js b/popup/index.js
index fe01bae..48ae176 100644
--- a/popup/index.js
+++ b/popup/index.js
@@ -65,11 +65,26 @@ var Popup = () => {
         mathjax: 'Render MathJax formulas',
         mermaid: 'Mermaid diagrams',
         syntax: 'Syntax highlighting for fenced code blocks',
+        comments: 'View comments: highlights + sidebar (read-only)',
+        commentsWrite: 'Create comments: ⌘⇧K + selection tooltip (adds on top of View comments)',
+      }
+    },
+    // Optional display-name overrides for content switches whose raw
+    // storage key alone isn't self-explanatory in the UI (e.g. two
+    // keys that only differ by a suffix). Falls back to the raw key
+    // for every other setting via labelFor() below.
+    labels: {
+      content: {
+        comments: 'Show comments',
+        commentsWrite: 'Create comments',
       }
     },
     settings: {}
   }
 
+  var labelFor = (tab, key) =>
+    (state.labels[tab] && state.labels[tab][key]) || key
+
   var events = {
     tab: (e) => {
       state.tab = e.target.hash.replace('#tab-', '')
@@ -290,7 +305,7 @@ var Popup = () => {
                 onchange: events.content
               }),
               m('.mdc-switch__background', m('.mdc-switch__knob')),
-              m('span.mdc-switch-label', key)
+              m('span.mdc-switch-label', labelFor('content', key))
             ))
           )
         )
@@ -402,7 +417,7 @@ var Popup = () => {
                 onchange: events.content
               }),
               m('.mdc-switch__background', m('.mdc-switch__knob')),
-              m('span.mdc-switch-label', key)
+              m('span.mdc-switch-label', labelFor('content', key))
             ))
           )
         ),