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
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())