Skip to content

Remove composer autocomplete; hold the demo caret solid while keys land - #126

Merged
sgardoll merged 3 commits into
mainfrom
feat/remove-autocomplete-solid-caret
Oct 12, 2026
Merged

sgardoll merged 3 commits into
mainfrom
feat/remove-autocomplete-solid-caret

Conversation

@sgardoll

@sgardoll sgardoll commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to #125. This was pushed straight to main by mistake as b6ab54b, reverted in 9c0dd93, and re-lands here unchanged for review. The branch tree matches b6ab54b exactly. Nothing was deployed: the site only ships via scripts/deploy_ftp.py.

1. Autocomplete removed (re-scoped in an upcoming milestone)

Typing no longer produces a ghost completion. Removed:

  • the 13 local patterns, the suggestion session, and the debounce
  • Tab-to-accept and Escape-to-dismiss (both keys now keep their native browser behaviour)
  • the Tab-hint button and its CSS
  • the #suggest-status live region and the textarea's aria-describedby pointing at it
  • the .ghost .suggest span and its CSS

The ghost layer itself stays, because it positions the demo caret. The input handler now mirrors typed text into it directly; the deleted suggestion helpers used to do that. A spec caught this regression during the change.

2. The demo caret now behaves like a real text cursor

Before, the caret blinked on a 1.05s cycle while text was typed or erased, so it was invisible in about half the frames of the backspace. Now:

  • A new is-demo-keying class holds the caret solid on every character the hero demo types or erases, and on every character a chip types.
  • The caret blinks only while the field is idle: during the hold before typing, and during the rest on the full example.

Frame strip from a real Chromium recording (5fps): the caret is visible on every typed and erased frame, blinks only during the 1.6s rest on …gradient stroke, then the demo ends on the grey hint.

Tests

  • npm test: 409/409 pass. The 14 suggestion unit tests went with the code; the canSubmit test remains.
  • npx playwright test: 265 passed, 0 failed. Net count: 8 suggestion specs removed and 5 added.
    • Replaced by "typing makes no suggestion": no ghost text, Tab hint, or live region, even after waiting past the old 220ms debounce, and no completion requests.
    • Replaced by "Tab moves focus and never changes the prompt".
    • The hero spec now checks the caret's computed state on every sampled frame:
      • caret-blink during the hold and the rest
      • animation: none with opacity: 1 on each typed or erased character
    • New spec: a chip fills its prompt behind a solid caret.
    • The touch spec drops its Tab-hint step.
  • Mutation checks, each reverted after the run:
    • Deleting the is-demo-keying CSS rule fails both caret specs.
    • Holding the caret solid through the rest fails the hero spec (Expected "caret-blink", Received "none").

Devin Review

…keys land

Autocomplete is out until it is re-scoped in its own milestone. Typing
no longer produces a ghost completion: the 13 local patterns, the
suggestion session, the debounce, Tab-to-accept, Escape-to-dismiss, the
Tab hint button and the polite live region are all removed. Tab and
Escape keep their native behaviour. The ghost layer stays because it
positions the demo caret, and the input handler now mirrors into it
directly (the suggestion helpers used to do that).

The orange demo caret now behaves like a real text cursor:
is-demo-keying holds it solid on every character the hero demo types
or erases, and on every character a chip types. It blinks only while
the field is idle: the hold before typing and the rest on the full
example. Before, it blinked through the backspace, so it was invisible
in about half the frames.

Tests: suggestion unit and e2e specs replaced by "typing makes no
suggestion" and "Tab moves focus and never changes the prompt". The
hero spec now asserts the caret's computed state on every sampled
frame (blinking at the hold and the rest; animation none and opacity 1
on each typed or erased character). New spec: a chip fills behind a
solid caret.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

Applies the animation-skill timing model (GSAP timelines, Remotion's
typewriter): every frame is derived from elapsed time, built by string
slicing, rather than advanced one step per timer callback. Chained
timers stretch the animation when the main thread is busy. That is what
made the chip-fill spec time out under parallel test load, and it would
drag the demo on a slow phone. Now a late tick lands everything already
due: frames drop, the timing holds.

- heroDemoFrame(elapsed, length) is the demo as a pure timeline:
  hold, then type at 73ms/char, rest 1.6s, then erase at 35ms/char.
  For any moment it returns the characters showing, whether the caret
  is keying (solid) or idle (blinking), whether the demo is done, and
  the ms until the next change. The runtime renders the frame due now
  and sleeps until the next one.
- The chip fill reads its character count off the clock the same way.

Timing is unchanged when nothing runs late. The timeline is now unit
tested without a browser: exact phase boundaries, a late tick catching
up, and a 1ms sweep showing the count rises and falls by exactly one
character at a time.
Under parallel local runs these failed intermittently. Baseline on #126's
tree, before this change: 5/20 in one --repeat-each batch.
- stu445 morph: a fixed 1250ms sleep read the reveal before a slow
  morph had handed off. It now polls for the reveal, and waits for the
  1400ms motion sample to exist before reading it. Same expected values.
- stu389 field: counted frames over 500ms against a >5 bar, and read 5
  at about 10fps. It now counts over 1500ms with the same bar.
- stu390 logo loop: two re-entry checks compared counts from separate
  windows (framesB < framesA * c). The file's own comment says this
  trips when a slow runner stalls the baseline. They now use the file's
  existing framesPerDisplayFrame same-window ratio (< 1.6x), which
  still catches a doubled loop.

After: 60/60 and 135/135 across repeat batches, and 3 consecutive clean
full runs (265/265).
sgardoll added a commit that referenced this pull request Oct 12, 2026
* feat(composer): remove autocomplete; hold the demo caret solid while keys land

Autocomplete is out until it is re-scoped in its own milestone. Typing
no longer produces a ghost completion: the 13 local patterns, the
suggestion session, the debounce, Tab-to-accept, Escape-to-dismiss, the
Tab hint button and the polite live region are all removed. Tab and
Escape keep their native behaviour. The ghost layer stays because it
positions the demo caret, and the input handler now mirrors into it
directly (the suggestion helpers used to do that).

The orange demo caret now behaves like a real text cursor:
is-demo-keying holds it solid on every character the hero demo types
or erases, and on every character a chip types. It blinks only while
the field is idle: the hold before typing and the rest on the full
example. Before, it blinked through the backspace, so it was invisible
in about half the frames.

Tests: suggestion unit and e2e specs replaced by "typing makes no
suggestion" and "Tab moves focus and never changes the prompt". The
hero spec now asserts the caret's computed state on every sampled
frame (blinking at the hold and the rest; animation none and opacity 1
on each typed or erased character). New spec: a chip fills behind a
solid caret.

* fix(paywall): an out-of-runs user writes their prompt before the paywall

The "Free generations used" screen used to replace the composer the
moment the page loaded for anyone already at their limit:
updateUsageDisplay() surfaced it on every usage update (startup identity,
subscription refresh), so the user never got to write a prompt.

Now the paywall only answers a generation attempt. updateUsageDisplay()
only clears it once the limit no longer applies (upgrade, sign-in, new
month); canRunPipeline() shows it when a prompt is submitted at the
limit, as before. That submitted prompt now fills the "Your prompt"
recap beside the upgrade path, and becomes pipelineState.submittedPrompt,
so Edit prompt returns to it instead of an earlier run's prompt.

hasActiveModal() only existed to defer the auto-surfaced paywall while
a dialog was open, so it is removed.

Tests: stu391's startup spec now asserts that the exhausted startup
leaves the user on the composer (it previously asserted the paywall
appeared on reload), and that a run attempt shows the prompt in the
recap. New stu382 spec covers the full flow: land on the composer, the
paywall appears on submit with the prompt and pricing open, no pipeline
request is sent, and Edit prompt returns to the prompt.

* fix(paywall): keep Home on the composer after editing or a quota correction

Two Devin Review findings on #127:

- A usage update that dropped the count below the limit while the paywall
  was up hid the paywall but left the generation stage owning Home, so the
  user faced an empty panel instead of the composer. updateUsageDisplay()
  now also restores the composer when the paywall it hides was the live
  panel — covering both the count correction and the unresolved-
  subscription paths, and leaving progress/results panels alone.
- Edit prompt hid the stage but left the paywall live inside it and the
  main-stage latch set, so a Plans trip and back reopened the paywall with
  no new submit. Editing now dismisses the paywall too, and switchView only
  restores the stage while a panel inside it is still live (progress,
  results, paywall or ready state) via generationStageHasLivePanel().

Two new stu382 specs cover both: a held identity response correcting the
count mid-paywall hands the composer back with the draft, and a Plans
round-trip after Edit prompt keeps Home on the composer. Both fail on the
pre-change app.js.

Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>

* build: rebuild dist

Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>

* refactor(composer): drive the hero demo and chip fill from the clock

Applies the animation-skill timing model (GSAP timelines, Remotion's
typewriter): every frame is derived from elapsed time, built by string
slicing, rather than advanced one step per timer callback. Chained
timers stretch the animation when the main thread is busy. That is what
made the chip-fill spec time out under parallel test load, and it would
drag the demo on a slow phone. Now a late tick lands everything already
due: frames drop, the timing holds.

- heroDemoFrame(elapsed, length) is the demo as a pure timeline:
  hold, then type at 73ms/char, rest 1.6s, then erase at 35ms/char.
  For any moment it returns the characters showing, whether the caret
  is keying (solid) or idle (blinking), whether the demo is done, and
  the ms until the next change. The runtime renders the frame due now
  and sleeps until the next one.
- The chip fill reads its character count off the clock the same way.

Timing is unchanged when nothing runs late. The timeline is now unit
tested without a browser: exact phase boundaries, a late tick catching
up, and a 1ms sweep showing the count rises and falls by exactly one
character at a time.

* test: stop three animation specs racing the wall clock under load

Under parallel local runs these failed intermittently. Baseline on #126's
tree, before this change: 5/20 in one --repeat-each batch.
- stu445 morph: a fixed 1250ms sleep read the reveal before a slow
  morph had handed off. It now polls for the reveal, and waits for the
  1400ms motion sample to exist before reading it. Same expected values.
- stu389 field: counted frames over 500ms against a >5 bar, and read 5
  at about 10fps. It now counts over 1500ms with the same bar.
- stu390 logo loop: two re-entry checks compared counts from separate
  windows (framesB < framesA * c). The file's own comment says this
  trips when a slow runner stalls the baseline. They now use the file's
  existing framesPerDisplayFrame same-window ratio (< 1.6x), which
  still catches a doubled loop.

After: 60/60 and 135/135 across repeat batches, and 3 consecutive clean
full runs (265/265).

* fix(paywall): show only the screen on submit; pricing waits for View Plans

Submitting a prompt at the limit used to open the pricing dialog on top
of the "Free generations used" screen. Now it shows only the screen, and
its View Plans button opens pricing when the user chooses. The openModal
option had no other caller, so it is removed.

Specs: stu382 asserts pricing stays closed on submit and opens from
View Plans. stu391 asserts the run attempt leaves pricing closed.
Mutation-checked: re-opening pricing on submit fails both.

* fix(paywall): require the paywall element to exist for the visible check

Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>

* build: rebuild dist

Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>

* test: stop the animation specs measuring the machine instead of the loop

The three flaky specs asserted absolute frame counts in fixed wall-clock
windows. A count is frames per second, so those floors measured how busy
the runner was, not whether the animation worked: at 12 workers they failed
5 times per 128 runs with the loops running perfectly.

Measured first. A running logo loop paints exactly one frame per display
frame (3-11 frames under load, always equal); a stopped one paints none
while display frames run 3-36. So the honest claim is expressed against
display frames from the same window, in one shared helper:

- stu390: replace every `frames > 5` floor with expectRunning(), which
  asserts the loop painted (logo > 0), the window contained real display
  frames, and painted at most once per display frame. steadyFramesIn and
  its fixed 250ms warm-up go with it. 256/256 at 12 workers, from 5
  failures per 128.
- stu389: the field is the only rAF driver on that page (framesOff is
  asserted to be exactly 0), so the count is the field's own. The >5 floor
  only added "the runner does 4fps"; rendering at all, then stopping to 0,
  is the claim.
- stu445: six geometry tests run in parallel and each drives a whole gated
  pipeline, ~5.3s of authored-pacing sleeps plus ten geometry reads, so
  they exceeded the 30s default on the sleeps rather than on a mismatch.
  Give them a 60s budget.

Also rebuilt the morph spec to record transient state from inside the page
instead of racing the 1120ms morph from outside. open() now captures the
ghost, the hidden destination and the reveal as they happen, and the
handoff is recorded the frame the ghost leaves rather than at a fixed
1400ms. Checked from outside, a loaded machine finishes the morph before
the check runs.

Verified by mutation: deleting the data-morphing CSS rule fails the
"destination hidden during the morph" assertion, and removing both clears
of data-morphing fails the handoff assertion. Restoring the never-painting
loop mutation fails expectRunning. Full suite 268/268 twice, 413 unit.

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@sgardoll
sgardoll merged commit 34e2a46 into main Oct 12, 2026
7 checks passed
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