Skip to content

Paywall: let an out-of-runs user write their prompt first - #127

Merged
sgardoll merged 13 commits into
mainfrom
fix/paywall-after-prompt
Oct 12, 2026
Merged

sgardoll merged 13 commits into
mainfrom
fix/paywall-after-prompt

Conversation

@sgardoll

@sgardoll sgardoll commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Stacked on #126: merge #126 first. This branch contains #126's commit plus one of its own (aa04bb6). Once #126 merges, this diff shrinks to just that commit. Both PRs rebuild dist/, so stacking avoids a dist merge conflict.

Problem

For a user who had already used all their generations, the page opened straight onto the "Free generations used" screen with an empty Your prompt panel. They never got to the composer.

Cause: updateUsageDisplay() surfaced the paywall on every usage update (the startup identity check, subscription refreshes). Load → identity resolves with usage_count ≥ limit → paywall.

Fix

  • The paywall only appears in response to a generation attempt.
    • updateUsageDisplay() no longer shows it. It only clears it once the limit no longer applies: an upgrade, a sign-in, or a new month.
    • canRunPipeline() still shows it when a prompt is submitted at the limit, unchanged.
  • The submitted prompt fills the "Your prompt" recap beside the upgrade path.
  • "Edit prompt" returns to that prompt. The prompt is also stored as pipelineState.submittedPrompt. Before, "Edit prompt" could restore an earlier run's prompt over the one just written.
  • hasActiveModal() is removed. It only existed to delay the auto-shown paywall while a dialog was open.

New flow for an out-of-runs user:

  1. The page opens on the composer.
  2. They write a prompt and press Generate.
  3. The "Free generations used" screen appears with their prompt in the recap, and pricing opens on top. Pricing opening on top is the existing submit behaviour, unchanged here.
  4. No generation request is sent.

Tests

  • npm test: 409/409

  • New stu382 spec covers the full flow:

    • startup reaches the limit, but the page shows the composer and the paywall stays hidden
    • fill a prompt and submit: the paywall appears with the "used all 2 free generations" text, the recap shows the prompt, and pricing opens
    • Escape closes pricing, then Edit prompt returns to the composer still holding the prompt
    • zero pipeline requests are sent
  • stu391's startup spec previously asserted that the paywall appears on reload. It now asserts the opposite, plus the prompt recap after a run attempt. The reload step clears the stored usage count first, so the test checks the reloaded page's own result rather than the value left from before.

  • Mutation checks, each reverted after the run:

    • Restoring the auto-show fails both specs.
    • Dropping the prompt from the recap fails both specs.
  • Full npx playwright test: 264–265 pass per run. 1–2 failures per run move between three load-sensitive timing specs:

    • stu376 chip-fill value
    • stu389 frame count
    • stu445 morph reveal

    These specs never reach the paywall path: they use the default guest with usage 0, where the old and new code do the same thing. They also flake on Remove composer autocomplete; hold the demo caret solid while keys land #126's tree without this change: 5/20 in a --repeat-each 10 batch. In isolation the chip spec passes 40/40. CI runs one worker with retries, which is why Remove composer autocomplete; hold the demo caret solid while keys land #126's CI stayed green.


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.
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.
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 11 commits October 11, 2026 05:02
…ection

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>
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
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).
…after-prompt

# Conflicts:
#	dist/assets/index-DGpKq1Hs.js
#	dist/index.html
…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.
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
Co-Authored-By: Stuart Gardoll <sgardoll@gmail.com>
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.
…ix/paywall-after-prompt

# Conflicts:
#	dist/assets/index-FNrAL4lj.js
#	dist/index.html
@sgardoll
sgardoll merged commit 9ac5cca 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