Skip to content

feat(client): wrap long todos in quick add instead of hiding them - #82

Merged
JackCuthbert merged 3 commits into
mainfrom
feat/quick-add-wrapping
Aug 20, 2026
Merged

feat(client): wrap long todos in quick add instead of hiding them#82
JackCuthbert merged 3 commits into
mainfrom
feat/quick-add-wrapping

Conversation

@JackCuthbert

@JackCuthbert JackCuthbert commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Quick add was a single-line <input> that scrolled sideways, so a long todo pushed its own beginning out of view with no scrollbar to say so and no way back but walking the caret through it. The pills made it acute: they rewrite the text as the single source of truth, so choosing a list could lengthen the line by more than it had to spare — words vanished without a keystroke.

The field is now a contenteditable that wraps.

Three commits: the parser fix stands on its own, the wrapping work is the bulk of it, and mise.toml is unrelated tooling.

Why a contenteditable and not a <textarea>

A textarea wraps perfectly well and would have been a fraction of the change. It also keeps the shadow layer, and the shadow layer is what makes the token marks unpaddable — the spec has carried a section explaining that constraint since August, measured at 34px of drift across three tokens.

I re-measured it against a wrapping textarea before choosing: 52px. The fault was never the <input> tag, it is the second layer, so it survives every variation of the two-layer approach. Wrapping and a padded mark are one change or neither.

So the marks are now real elements in the element you type into: 2px 5px padding, --radius-sm, and box-decoration-break: clone so a mark broken across a line wrap is drawn complete on both lines.

Undo

Writing innerHTML empties the browser undo stack. Measured: ⌘Z after a mark re-render does nothing at all, while ordinary typing, typing inside a mark, and direct text-node writes all leave undo intact. So the marks are redrawn only when the set of tokens changes, never per keystroke.

The first cut of that predicate compared token offsets, which looks equivalent and is not: typing anywhere before a token shifts every later one, so a keystroke mid-summary counted as a change and redrew. It now compares the marked text, and separately checks what the DOM actually holds — because the browser edits inside these spans, so typing at a mark's trailing edge extends it (p2 became p2 askdjnfas asdf ask) while the token still reads p2.

One honest regression: the single keystroke that completes or breaks a token loses its own undo entry. Narrow, deliberate, and written into the spec.

Placement

The field grows without bound; the modal rises to accommodate it, via a collapsible flex spacer rather than a measured offset. Verified on a 500px viewport: it holds at the launcher height (106px) while there is room, then moves up — 106 → 74 → 16 — and only scrolls once it is genuinely taller than the screen.

A max-height with an inner scrollbar was tried first and removed: it made the popup a scroll container, which clipped the # autocomplete; escaping that with fixed positioning detached the menu entirely.

The autocomplete now hangs off the caret, clamped against the viewport using its measured width — not against the modal using its min-width, which was wrong in both directions at once (nudging left when there was room, overflowing the screen when there wasn't). Verified at 380px, 560px and 1100px.

Performance

Every keystroke re-parses the whole line and can redraw every mark, so cost grew with length without bound — ~12ms per keystroke at 4,000 characters on this Mac, several times that on an ordinary machine, which is what holding ⌘V felt like. The parser itself was never the problem (3ms at 8,000 characters).

Summaries are capped at 500 characters, which makes ~14ms the worst case rather than a floor that keeps rising. A long paste truncates rather than being refused, and deleting still works at the cap.

Bugs found and fixed along the way

Six, all now covered by tests:

  • autoFocus is a form-control attribute and does nothing on a contenteditable — the modal opened with focus on the button that opened it.
  • React's onBeforeInput never fires for insertParagraph/insertLineBreak; the handler was dead code, now a native listener.
  • A pill-requested caret was gated on document.activeElement, but the closing menu still holds focus for a frame — the caret dropped to offset 0.
  • The closed modal kept its text in the DOM, so a summary was findable twice; this broke four unrelated spec files with strict mode violation.
  • Predating this branch: the Enter branch never checked shiftKey, so Shift+Enter from the summary submitted the todo, contradicting the footer that documents that key as belonging to notes. The old <input> hid it by clearing as the modal closed.
  • The autocomplete stayed open when the field lost focus, sitting over the pills and buttons.

Verification

The full e2e suite now runs locally — 72 passing. It could not before this branch: Playwright's webServer spawns bun through a fresh shell, and mise had no version set for it, so the shim failed outright. That is what the third commit fixes.

Running it immediately caught two things my browser testing had missed: an ambiguous locator in one of my own new tests, and a genuine regression where the new positioning layer registered as a third full-viewport layer in the overlay stack that the mobile test counts. The layer paints nothing and takes no clicks, so the test now distinguishes a scrim from a positioner by pointer-events.

Also green: 728 unit tests, typecheck across 7 workspaces, lint, knip, fmt:check.

Spec

docs/specs/quick-add.md is updated rather than appended to: the two paragraphs justifying an unpadded mark are gone, since the reason for them is gone. New sections cover the wrapping rule, the length cap and placement, and what a contenteditable costs — each with the measurement behind it.

@JackCuthbert
JackCuthbert force-pushed the feat/quick-add-wrapping branch 7 times, most recently from adc3e2e to 05454a2 Compare August 19, 2026 04:58
Typing "next week when 3pm" now sets both the day and the time. It used
to set the day and leave "3pm" in the title, describing a due date the
todo did not have.
A long line now wraps onto as many lines as it needs, so text can no
longer be pushed out of sight by choosing a list or a date. Recognised
words are marked with a little more room around them, the list
suggestions follow the cursor, and a tall modal moves up the screen
rather than pushing its own buttons out of view.

Summaries are capped at 500 characters, which keeps typing quick however
much text is pasted in.
`mise` had no version set for bun, so the shim failed for anything that
spawned it through a fresh shell — including Playwright's dev server,
which meant the e2e suite could not start locally.
@JackCuthbert
JackCuthbert force-pushed the feat/quick-add-wrapping branch from 05454a2 to c80722a Compare August 19, 2026 05:11
@JackCuthbert
JackCuthbert merged commit 493dc15 into main Aug 20, 2026
3 checks passed
@JackCuthbert
JackCuthbert deleted the feat/quick-add-wrapping branch August 20, 2026 00:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant