Skip to content

Commit 9eab056

Browse files
fix(exchange): scroll the composer to follow the caret
`prompt` is an editable slot in both templates, but `.comp-text` is a fixed-width single line with `white-space: pre` inside a composer that clips, and the packaged prompt already reaches the mic -- it has no headroom at all. A longer prompt therefore slid under the mic and the send button and lost its tail, with the caret hidden behind them: the remix read as having typed into nowhere, cut mid-word. Both now scroll the typed text left to keep the caret in view, the way a real single-line input does, on the very per-character times the reveal already uses. The limit is measured to the opaque round button rather than the mic, because the packaged prompt's caret already sits 2px past the mic's left edge in chatgpt-exchange -- measuring to the mic would scroll the packaged composition. Shrink-to-fit was the other option and it is worse: with no headroom it shrinks the packaged prompt too, and it still cannot absorb a prompt twice as long without unreadable type. It was tried, measured, and dropped. A prompt that fits emits no keyframes and the element is never handed to GSAP at all -- even a zero translate stamps a transform, which changes how the text rasterizes. claude-exchange puts this inside its existing `build()`, which already re-runs on `document.fonts.ready`, so it measures the final font. chatgpt-exchange builds once, so its scroll keyframes are kept in a list and rebuilt on the same event. Verified on the packaged defaults: claude-exchange SSIM 1.000000 across 1284 frames. chatgpt-exchange is not frame-deterministic -- rendering it twice unchanged gives 0.999959 with 249 frames under 0.9999, and before-vs-after gives 0.999967 with the same 249 -- so the change sits inside its own noise. Durations unchanged at 14.9s and 21.4s. `hyperframes check` reports byte-identical results to the unchanged templates.
1 parent 31da19c commit 9eab056

2 files changed

Lines changed: 105 additions & 0 deletions

File tree

registry/blocks/chatgpt-exchange/chatgpt-exchange.html

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,6 +1369,63 @@
13691369
tRead + T.readbackDur - 0.35,
13701370
);
13711371

1372+
/* ---- caret-following scroll in the composer.
1373+
`prompt` is an editable slot, but `.comp-text` is a fixed-width single line with
1374+
`white-space: pre` inside a composer that clips, and the packaged prompt already reaches
1375+
the mic. A longer one therefore slides under the mic and the send button and loses its
1376+
tail, with the caret hidden behind them -- the remix looks like it typed into nowhere.
1377+
A real single-line input scrolls its text left to keep the caret in view; this does the
1378+
same, on the very times the reveal already uses. Shrinking the type instead was the other
1379+
option and it is worse: it would shrink the packaged prompt too, since that prompt has no
1380+
headroom, and it still cannot absorb a prompt twice as long without becoming unreadable.
1381+
A prompt that fits emits no keyframes at all, so the packaged composition is untouched. */
1382+
const compTextEl = composer.querySelector("#cge-comp-text");
1383+
// The obstacle is the opaque round button, not the mic: the packaged prompt's caret already
1384+
// sits 2px past the mic's left edge, so measuring to the mic would scroll the packaged
1385+
// composition. The caret has 35px of clearance to the button, which is the real boundary.
1386+
const compStopEl = composer.querySelector("#cge-blue-btn");
1387+
const CARET_PAD = 8;
1388+
let caretScrollTweens = [];
1389+
const layoutCaretScroll = () => {
1390+
const hadTweens = caretScrollTweens.length > 0;
1391+
caretScrollTweens.forEach((tw) => tw.kill());
1392+
caretScrollTweens = [];
1393+
// Only ever touch typedEl when there is scrolling to do. A zero translate — or even a
1394+
// clearProps on an untouched element — stamps and removes a transform, which changes how
1395+
// the text rasterizes and makes the packaged render differ from before for no reason.
1396+
if (hadTweens) gsap.set(typedEl, { clearProps: "transform" });
1397+
if (!compTextEl || !compStopEl) return;
1398+
// Rects live in the scaled .screen space while offsets are layout px, so convert with the
1399+
// element's own ratio rather than reading --scale -- this then survives a change to it.
1400+
const box = compTextEl.getBoundingClientRect();
1401+
const ratio = compTextEl.offsetWidth ? box.width / compTextEl.offsetWidth : 1;
1402+
const limit =
1403+
(compStopEl.getBoundingClientRect().left - box.left) / (ratio || 1) - CARET_PAD;
1404+
if (!(limit > 0)) return;
1405+
// Collect the steps first, so a prompt that fits leaves typedEl alone entirely.
1406+
const steps = [];
1407+
let shift = 0;
1408+
chars.forEach((c, i) => {
1409+
const caret = curEls[i + 1];
1410+
if (!caret) return;
1411+
const want = Math.max(0, caret.offsetLeft - limit);
1412+
if (want > shift + 0.5) {
1413+
shift = want;
1414+
steps.push([charT[i], -shift]);
1415+
}
1416+
});
1417+
if (!steps.length) return;
1418+
steps.forEach(([at, x]) => {
1419+
caretScrollTweens.push(tl.to(typedEl, { x, duration: 0.08, ease: "none" }, at));
1420+
});
1421+
// Back to the start when the composer empties, so the collapse animates from x=0.
1422+
caretScrollTweens.push(tl.to(typedEl, { x: 0, duration: 0.01 }, tGo));
1423+
};
1424+
layoutCaretScroll();
1425+
// This composition builds its timeline once, so the first pass measures whatever font was
1426+
// live then. Re-measure when the embedded faces are ready.
1427+
document.fonts.ready.then(layoutCaretScroll);
1428+
13721429
window.__timelines["chatgpt-exchange"] = tl;
13731430
</script>
13741431
</body>

registry/blocks/claude-exchange/claude-exchange.html

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1582,6 +1582,54 @@
15821582
// the minute rolls over between the source frames — keep that
15831583
tl.to(document.getElementById("cle-clock-early"), { autoAlpha: 0, duration: 0.01 }, 8.4);
15841584
tl.to(document.getElementById("cle-clock-late"), { autoAlpha: 1, duration: 0.01 }, 8.4);
1585+
1586+
/* ---- caret-following scroll in the composer.
1587+
`prompt` is an editable slot, but `.comp-text` is a fixed-width single line with
1588+
`white-space: pre` inside a composer that clips, and the packaged prompt already
1589+
reaches the mic. A longer one therefore slides under the mic and the send button and
1590+
loses its tail, with the caret hidden behind them. A real single-line input scrolls its
1591+
text left to keep the caret in view; this does the same, on the very times the reveal
1592+
already uses. Shrinking the type instead would shrink the packaged prompt too, since
1593+
that prompt has no headroom, and still could not absorb a prompt twice as long without
1594+
becoming unreadable. A prompt that fits emits no keyframes, so the packaged
1595+
composition is untouched. Lives inside build(), which is re-run once the embedded
1596+
faces are ready, so the measurement is taken against the final font. */
1597+
const compTextEl = composer.querySelector("#cle-comp-text");
1598+
// The obstacle is the opaque round button, not the mic glyph the text may abut. Take the
1599+
// leftmost of the two that occupy that corner, since one swaps in for the other.
1600+
const compStops = ["#cle-voice-btn", "#cle-send-btn"]
1601+
.map((sel) => composer.querySelector(sel))
1602+
.filter(Boolean);
1603+
const CARET_PAD = 8;
1604+
if (compTextEl && compStops.length) {
1605+
// Rects live in the scaled .screen space while offsets are layout px, so convert with
1606+
// the element's own ratio rather than reading --scale.
1607+
const box = compTextEl.getBoundingClientRect();
1608+
const ratio = compTextEl.offsetWidth ? box.width / compTextEl.offsetWidth : 1;
1609+
const stopLeft = Math.min(...compStops.map((el) => el.getBoundingClientRect().left));
1610+
const limit = (stopLeft - box.left) / (ratio || 1) - CARET_PAD;
1611+
if (limit > 0) {
1612+
// Collect the steps first, so a prompt that fits leaves typedEl alone entirely: even a
1613+
// zero translate stamps a transform, which changes how the text rasterizes and would
1614+
// make the packaged render differ from before for no reason.
1615+
const steps = [];
1616+
let shift = 0;
1617+
chars.forEach((c, i) => {
1618+
const caret = curEls[i + 1];
1619+
if (!caret) return;
1620+
const want = Math.max(0, caret.offsetLeft - limit);
1621+
if (want > shift + 0.5) {
1622+
shift = want;
1623+
steps.push([charT[i], -shift]);
1624+
}
1625+
});
1626+
if (steps.length) {
1627+
steps.forEach(([at, x]) => tl.to(typedEl, { x, duration: 0.08, ease: "none" }, at));
1628+
// Back to the start when the composer empties, so the collapse animates from x=0.
1629+
tl.to(typedEl, { x: 0, duration: 0.01 }, tGo);
1630+
}
1631+
}
1632+
}
15851633
}
15861634

15871635
build();

0 commit comments

Comments
 (0)