Extract compactOverType() from duplicated sizing workaround
Both initNoteEditor and _initSeriesNote inlined identical logic to
counteract OverType's hardcoded 60px min-height and the app's global
textarea styles. A single documented function replaces both copies and
returns the resize handle callers need for manual re-measurement.

Assisted-by: Claude Opus 4.6 via Claude Code
change qnpmonnlzkpwtvqurpuqourvrrnprwkn
commit 620672c96f22ca773ff0bf20536cf47364ee593f
author Alpha Chen <alpha@kejadlen.dev>
date
parent ozqmxrym
diff --git a/.claude/skills/overtype/skill.md b/.claude/skills/overtype/skill.md
index 79b9ea1..79d532c 100644
--- a/.claude/skills/overtype/skill.md
+++ b/.claude/skills/overtype/skill.md
@@ -138,56 +138,48 @@ textarea, and preview.
 
 ## Known Issues in This Project
 
-### Wrapper min-height inflates small editors
+Three OverType behaviors compound to inflate small editors:
 
-The wrapper's `min-height: 60px !important` makes single-line editors too tall.
-The `minHeight` config only floors the auto-resize calculation; it does not
-override the CSS min-height.
+1. The wrapper's `min-height: 60px !important` makes single-line editors too
+   tall. The `minHeight` config only floors the auto-resize calculation; it
+   does not override the CSS rule.
+2. The app's global `textarea { padding; border }` applies to OverType's
+   internal textarea, inflating the `scrollHeight` that auto-resize reads.
+3. OverType's auto-resize fires on every keystroke, re-applying the inflated
+   height and undoing any corrections.
 
-Fix: override via inline style after init (inline `!important` beats stylesheet
-`!important`), then re-measure scrollHeight.
+### `compactOverType(el)` in `public/js/app.js`
 
-### Global textarea styles inflate scrollHeight
-
-The app's global `textarea { padding; border }` applies to OverType's internal
-textarea, inflating the `scrollHeight` that auto-resize measures. Override with
-inline styles on the textarea, then re-trigger sizing.
-
-### Auto-resize re-inflates on input
-
-OverType's auto-resize fires on every keystroke, undoing any height corrections.
-Listen for `input` on the textarea and re-apply the fix each time.
-
-### Pattern for compact OverType editors
+A helper at the top of `app.js` fixes all three. Call it on the container
+element after `new OverType(el, ...)`:
 
 ```javascript
 const [editor] = new OverType(el, {
   value: text,
   autoResize: true,
   minHeight: 14,
-  fontSize: "11px",
   padding: "0 4px",
 })
+const resize = compactOverType(el)
+```
 
-const wrapper = el.querySelector(".overtype-wrapper")
-const textarea = el.querySelector("textarea")
-const preview = el.querySelector(".overtype-preview")
-if (wrapper && textarea) {
-  wrapper.style.setProperty("min-height", "0", "important")
-  textarea.style.setProperty("padding", "0 4px", "important")
-  textarea.style.setProperty("border", "none", "important")
-  const resize = () => {
-    textarea.style.setProperty("height", "0", "important")
-    const h = textarea.scrollHeight + "px"
-    textarea.style.setProperty("height", h, "important")
-    wrapper.style.setProperty("height", h, "important")
-    if (preview) preview.style.setProperty("height", h, "important")
-  }
-  requestAnimationFrame(resize)
-  textarea.addEventListener("input", () => requestAnimationFrame(resize))
-}
+It zeros the wrapper min-height, strips textarea padding and border, measures
+true `scrollHeight` on the next frame, and hooks `input` to re-measure after
+each keystroke. Returns a resize function for manual re-measurement (e.g.
+after toggling `readOnly`), or `null` if the expected DOM nodes aren't found.
+
+The element must be visible when the next animation frame fires, or
+`scrollHeight` reads as 0. If the container is hidden behind an `x-show` that
+hasn't toggled yet, defer the call:
+
+```javascript
+requestAnimationFrame(() => compactOverType(el))
 ```
 
+Pass `padding: "0 4px"` in the OverType config to match the inline override on
+the textarea — otherwise the preview layer keeps OverType's default padding
+and the two layers render at different offsets.
+
 ## View Modes
 
 `editor.showPreviewMode()` renders content read-only with clickable links.
diff --git a/public/js/app.js b/public/js/app.js
index 04f0780..7b1e4bf 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -1,3 +1,53 @@
+// OverType hardcodes `min-height: 60px !important` on `.overtype-wrapper`
+// and runs auto-resize during construction — before callers can intervene —
+// locking in an inflated height. The app's global textarea styles (padding,
+// border) further inflate `scrollHeight`, which auto-resize reads.
+//
+// This function corrects both problems after `new OverType(el, ...)`:
+//
+//  1. Zeros the wrapper's min-height (inline `!important` beats the
+//     stylesheet's `!important` at equal specificity).
+//  2. Strips the textarea padding and border that inflate scrollHeight.
+//  3. On the next frame — once the style overrides have taken effect —
+//     collapses the textarea to height 0, reads scrollHeight for the true
+//     content height, and sets that height on the wrapper, textarea, and
+//     preview so all three layers agree.
+//  4. Hooks the textarea's `input` event to repeat step 3, because
+//     OverType's own auto-resize fires on every keystroke and re-inflates
+//     the height.
+//
+// The element must be visible (participating in layout) by the time the
+// next animation frame fires, or scrollHeight will read as 0. If the
+// container is hidden at creation time — for example behind an Alpine
+// x-show that hasn't toggled yet — defer the call with
+// requestAnimationFrame so the browser lays it out first.
+//
+// Returns a resize function for manual re-measurement (e.g. after toggling
+// readOnly or pointer-events), or null if the expected DOM isn't found.
+function compactOverType(el) {
+  const wrapper = el.querySelector(".overtype-wrapper")
+  const textarea = el.querySelector("textarea")
+  const preview = el.querySelector(".overtype-preview")
+  if (!wrapper || !textarea) return null
+
+  wrapper.style.setProperty("min-height", "0", "important")
+  textarea.style.setProperty("padding", "0 4px", "important")
+  textarea.style.setProperty("border", "none", "important")
+
+  const resize = () => {
+    textarea.style.setProperty("height", "0", "important")
+    const h = textarea.scrollHeight + "px"
+    textarea.style.setProperty("height", h, "important")
+    wrapper.style.setProperty("height", h, "important")
+    if (preview) preview.style.setProperty("height", h, "important")
+  }
+
+  requestAnimationFrame(resize)
+  textarea.addEventListener("input", () => requestAnimationFrame(resize))
+
+  return resize
+}
+
 document.addEventListener("alpine:init", () => {
   Alpine.data("sortable", () => ({
     sort: localStorage.getItem("sort") || "urgency",
@@ -123,28 +173,10 @@ document.addEventListener("alpine:init", () => {
         padding: "0 4px",
       })
 
-      // OverType's wrapper has min-height: 60px !important and its auto-resize
-      // runs during init — before we can intervene — locking in an inflated
-      // height. The global textarea styles (padding, border) also inflate
-      // scrollHeight. Fix: override those styles, then re-measure in the next
-      // frame once the overrides have taken effect.
-      const wrapper = el.querySelector(".overtype-wrapper")
+      compactOverType(el)
+
       const textarea = el.querySelector("textarea")
-      const preview = el.querySelector(".overtype-preview")
-      if (wrapper && textarea) {
-        wrapper.style.setProperty("min-height", "0", "important")
-        textarea.style.setProperty("padding", "0 4px", "important")
-        textarea.style.setProperty("border", "none", "important")
-        const resize = () => {
-          textarea.style.setProperty("height", "0", "important")
-          const h = textarea.scrollHeight + "px"
-          textarea.style.setProperty("height", h, "important")
-          wrapper.style.setProperty("height", h, "important")
-          if (preview) preview.style.setProperty("height", h, "important")
-        }
-        requestAnimationFrame(resize)
-        // OverType's auto-resize also fires on input, re-inflating the height.
-        textarea.addEventListener("input", () => requestAnimationFrame(resize))
+      if (textarea) {
         if (this.addingNoteId === taskId) textarea.focus()
         textarea.addEventListener("blur", () => {
           const note = editor.getValue().trim()
@@ -181,25 +213,12 @@ document.addEventListener("alpine:init", () => {
       })
       this._seriesNoteEditor = editor
 
-      const wrapper = el.querySelector(".overtype-wrapper")
-      const ta = el.querySelector("textarea")
-      const preview = el.querySelector(".overtype-preview")
-      if (wrapper && ta) {
-        wrapper.style.setProperty("min-height", "0", "important")
-        ta.style.setProperty("padding", "0 4px", "important")
-        ta.style.setProperty("border", "none", "important")
-        // Store resize so startEditing/stopEditing can re-measure after
-        // toggling pointer-events and readOnly, which can shift the textarea.
-        this._resizeSeriesNote = () => {
-          ta.style.setProperty("height", "0", "important")
-          const h = ta.scrollHeight + "px"
-          ta.style.setProperty("height", h, "important")
-          wrapper.style.setProperty("height", h, "important")
-          if (preview) preview.style.setProperty("height", h, "important")
-        }
-        requestAnimationFrame(this._resizeSeriesNote)
-        ta.addEventListener("input", () => requestAnimationFrame(this._resizeSeriesNote))
+      // Store resize so startEditing/stopEditing can re-measure after
+      // toggling pointer-events and readOnly.
+      this._resizeSeriesNote = compactOverType(el)
 
+      const ta = el.querySelector("textarea")
+      if (ta) {
         ta.style.pointerEvents = "none"
         ta.readOnly = true
         ta.addEventListener("blur", () => this._saveSeriesNote())