Index: src/assets/css/reply-editor.css
===================================================================
--- src/assets/css/reply-editor.css	(revision 33e1dbd11a4cb4638e01c26e3f3c98e352e8892e)
+++ src/assets/css/reply-editor.css	(revision 33e1dbd11a4cb4638e01c26e3f3c98e352e8892e)
@@ -0,0 +1,51 @@
+/* Rich reply editor (partials/reply-editor.ejs + js/reply-editor.js). */
+/* !important: site CSS gives forms inside comment threads `display: contents`,
+   which collapses the form to a 0x0 non-box — flex layout AND the fixed
+   full-screen overlay silently break. This component must own its box. */
+.re-form[data-re] { display: flex !important; flex-direction: column; gap: .45rem; }
+
+.re-toolbar { display: flex; gap: .25rem; flex-wrap: wrap; }
+.re-toolbar button {
+  border: 1px solid color-mix(in srgb, var(--ink, #000) 14%, transparent);
+  background: none; color: inherit; border-radius: 8px;
+  min-width: 2rem; height: 1.9rem; padding: 0 .45rem;
+  font-size: .85rem; line-height: 1; cursor: pointer;
+}
+.re-toolbar button:hover { background: color-mix(in srgb, var(--ink, #000) 8%, transparent); }
+
+.re-editor {
+  min-height: 4.2rem; padding: .55rem .7rem; border-radius: 10px;
+  border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent);
+  background: transparent; overflow-wrap: anywhere; outline: none;
+}
+.re-editor:focus { border-color: var(--accent, #06c); }
+.re-editor:empty::before { content: attr(data-ph); color: color-mix(in srgb, var(--ink, #000) 40%, transparent); pointer-events: none; }
+.re-editor blockquote { margin: .4rem 0; padding-left: .7rem; border-left: 3px solid color-mix(in srgb, var(--ink, #000) 20%, transparent); }
+
+.re-foot { display: flex; align-items: center; gap: .6rem; justify-content: flex-end; }
+.re-lang {
+  font: inherit; font-size: .82rem; padding: .3rem .45rem; border-radius: 8px;
+  border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent);
+  background: transparent; color: inherit; max-width: 10rem;
+}
+
+/* Full-screen compose (mobile). JS toggles .re-full on the form. */
+.re-head { display: flex; align-items: center; gap: .6rem; }
+.re-head-title { flex: 1; text-align: center; font-size: .95rem; }
+.re-cancel {
+  border: none; background: none; color: inherit; font-size: 1.5rem;
+  line-height: 1; padding: .25rem .5rem; cursor: pointer;
+}
+.re-form.re-full {
+  /* Above the bottom tab bar (1050) and the profile/audio sheets (1100). */
+  position: fixed; inset: 0; z-index: 1200;
+  background: var(--paper, #fff); padding: .8rem .9rem;
+  padding-top: calc(.8rem + env(safe-area-inset-top));
+  padding-bottom: calc(.8rem + env(safe-area-inset-bottom));
+  gap: .6rem;
+}
+.re-form.re-full .re-editor { flex: 1; min-height: 0; overflow-y: auto; border: none; padding: .4rem .1rem; }
+.re-form.re-full .re-editor:focus { border: none; }
+.re-form.re-full .re-send { display: none; }         /* send lives in the top bar */
+.re-form.re-full .re-foot { justify-content: flex-start; }
+html.re-lock, html.re-lock body { overflow: hidden; }
Index: src/assets/js/reply-editor.js
===================================================================
--- src/assets/js/reply-editor.js	(revision 33e1dbd11a4cb4638e01c26e3f3c98e352e8892e)
+++ src/assets/js/reply-editor.js	(revision 33e1dbd11a4cb4638e01c26e3f3c98e352e8892e)
@@ -0,0 +1,93 @@
+// Rich reply editor — upgrades every form[data-re] (partials/reply-editor.ejs)
+// from a plain textarea to a contenteditable with a small toolbar and, on
+// narrow screens (≤700px), a full-screen compose overlay (the right pattern on
+// mobile). Progressive enhancement: without this file the textarea submits as
+// before. On submit: `content` = editor HTML (server sanitizes), `text` =
+// plain-text fallback.
+(function () {
+  'use strict';
+  if (window.__replyEditorInit) return;
+  window.__replyEditorInit = true;
+
+  var MOBILE = '(max-width: 700px)';
+
+  function init(form) {
+    if (form.__re) return;
+    form.__re = true;
+
+    var ta = form.querySelector('textarea[name="text"]');
+    var ed = form.querySelector('.re-editor');
+    var bar = form.querySelector('.re-toolbar');
+    var head = form.querySelector('.re-head');
+    var foot = form.querySelector('.re-foot');
+    var lang = form.querySelector('.re-lang');
+    var hidden = form.querySelector('input[name="content"]');
+    if (!ta || !ed || !hidden) return;
+
+    // Upgrade: swap the textarea for the editor.
+    ta.hidden = true;
+    ta.required = false;
+    ed.hidden = false;
+    bar.hidden = false;
+    if (lang) lang.hidden = false;
+
+    // Toolbar commands (execCommand is deprecated-but-universal; same approach
+    // as the post editor).
+    bar.addEventListener('click', function (e) {
+      var btn = e.target.closest('button[data-cmd]');
+      if (!btn) return;
+      e.preventDefault();
+      ed.focus();
+      var cmd = btn.getAttribute('data-cmd');
+      if (cmd === 'bold') document.execCommand('bold');
+      else if (cmd === 'italic') document.execCommand('italic');
+      else if (cmd === 'list') document.execCommand('insertUnorderedList');
+      else if (cmd === 'quote') document.execCommand('formatBlock', false, 'blockquote');
+      else if (cmd === 'link') {
+        var url = window.prompt(form.getAttribute('data-link-prompt') || 'URL');
+        if (url && /^https?:\/\//i.test(url.trim())) document.execCommand('createLink', false, url.trim());
+      }
+    });
+
+    // Paste as plain text (rich paste becomes messy HTML; formatting is what
+    // the toolbar is for). Media paste/drop lands in the media phase.
+    ed.addEventListener('paste', function (e) {
+      var txt = (e.clipboardData || window.clipboardData).getData('text/plain');
+      if (!txt) return;
+      e.preventDefault();
+      document.execCommand('insertText', false, txt);
+    });
+
+    // Full-screen compose on mobile: enter on focus, leave via ×.
+    function setFull(on) {
+      form.classList.toggle('re-full', on);
+      head.hidden = !on;
+      document.documentElement.classList.toggle('re-lock', on);
+      if (on) ed.focus();
+    }
+    ed.addEventListener('focus', function () {
+      if (window.matchMedia(MOBILE).matches && !form.classList.contains('re-full')) setFull(true);
+    });
+    var cancel = form.querySelector('.re-cancel');
+    if (cancel) cancel.addEventListener('click', function () { setFull(false); });
+
+    // Serialize on submit; block truly empty replies.
+    form.addEventListener('submit', function (e) {
+      var html = ed.innerHTML.trim();
+      var plain = (ed.innerText || '').replace(/ /g, ' ').trim();
+      if (!plain) { e.preventDefault(); ed.focus(); return; }
+      hidden.value = html;
+      ta.value = plain;
+      document.documentElement.classList.remove('re-lock');
+    });
+
+    // Open <details> parents (fedi-node) keep working: nothing special needed.
+    void foot;
+  }
+
+  function initAll() {
+    document.querySelectorAll('form[data-re]').forEach(init);
+  }
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initAll);
+  else initAll();
+})();
