You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Send hand-off from the chat composer into the feed #263
Add the Send hand-off to the chatroom. When a signed-in member sends a text message, the typed text lifts out of the composer. It lands on the new pending row. The feed scrolls to the tail at the same time. The composer clears on the same frame as today. Typing is never blocked.
The Send hand-off has two parts. The hand-off copy is a static, sanitized copy of the composer text. It moves on transform and opacity only, inside one layer that paints over the feed and the composer. That layer is a sibling of the composer and of the feed, never an ancestor of either. So the transform creates no containing block for a ProseMirror host, and it never touches the visualViewport shell. The landing row is the pending row the message list appends. Its body stays hidden while lifting, then fades in as the hand-off copy fades out.
Every send that cannot land plays the fallback rise instead. That rise is the existing msg-send-in keyframe, moved from a dead component onto the real row bodies. Today no own send moves at all. The documented rise lives in a body that no feed variant renders.
Chunked sends are messages the composer splits at 3000 HTML characters.
One name: Send hand-off, short form hand-off. Symbols use sendHandoff*. The DOM attribute is data-send-handoff. Do not use ghost, flight, or motion in symbols, class names, or file names.
Flow
The member types a message. The send button shows the send glyph.
The member presses the send button, or presses Enter. The send button plays the existing pressed recipe.
Stage, on one frame: the hand-off copy mounts over the composer text, the composer clears, and the placeholder is held hidden.
The landing row appends with its body hidden. One measurement batch runs. The hand-off copy starts moving. The feed starts scrolling.
The hand-off copy reaches the landing row's final position. It fades out. The landing row body and the placeholder fade in.
The landing row shows the pending clock at full opacity. The composer is empty and ready. It is focused only when it was focused before.
The echo arrives. The pending clock becomes the time. Nothing moves.
States, and what the user sees in each:
State
What the user sees
Idle
No layer content. The composer and the feed as today.
Pressed
The send button plays the existing pressed recipe: scale 0.95 for 100 ms. Enter sends skip it.
Stage
The typed text stays in place for one frame. The caret is gone. The placeholder is not visible yet.
Lifting
The hand-off copy moves to the landing row. The feed rises under it. On a phone the text reaches the owner bubble edge first, then drops.
Landed
The hand-off copy fades into the landing row. On a phone the owner bubble fill is already complete around it. The placeholder fades in.
Pending
The landing row at full opacity with the pending clock. No dim, no label.
Sent
The pending clock becomes the time. No motion.
Failed
The pending clock becomes the red alert. The failed row with Retry and Delete appears under the body.
Retry
The row plays the fallback rise once. The hand-off never replays.
Fallback
Every ineligible send: the row appends and plays the fallback rise. The composer clears as today.
Reduced motion
No hand-off copy, no hidden row, no placeholder hold, no rise. The clear, the scroll, and the pending clock stay.
Acceptance criteria
Desktop and phone: sending a text message shows the typed text lift from the composer and settle on the landing row. The hand-off copy fades out and the landing row body fades in, starting on the same frame. No frame shows both at full opacity.
The composer is empty on the same frame the send starts. A key pressed while lifting lands in the composer. Text typed then paints under the hand-off copy until the copy leaves the composer; accepted.
The feed reaches the tail when the hand-off copy lands, not later. The list scrolls over the 200 ms window, never in a single frame.
The hand-off copy and the landing row break lines at the same points. No visible seam between them, in light or dark.
The placeholder appears only as the hand-off copy lands, never under it.
Phone: the text reaches the owner bubble edge before it finishes dropping. The bubble fill is complete when the copy lands, not after.
The landing row shows the pending clock at full opacity. There is no dim and no "sending…" label.
The pending clock becomes the time on the echo. Nothing moves and nothing shifts.
A failed send shows the red alert and the failed row. Retry plays only the fallback rise. The hand-off never replays.
A second send while the first hand-off is still playing: the first landing row appears at once, and the second text travels. No stacked copies.
Media, reply, emoji-only, and chunked sends: no hand-off copy. The row plays the fallback rise.
Edit and comment: no hand-off copy, no rise. The composer clears as today.
Composer text scrolled inside its own box: no hand-off copy. The row plays the fallback rise.
First message in an empty channel: the text lands where the row ends after the bottom alignment.
Scrolled up in loaded history: the feed still scrolls to the tail. The text lands on the landing row, or the row appears with the fallback rise when it did not mount in time.
Tail not loaded: the window replaces first, then the row appears with the fallback rise. No hand-off copy is left behind.
Phone with the keyboard open: the keyboard stays up. The motion never moves the Chat pane and never opens or closes the keyboard.
The hidden landing row can never stay hidden. A missing landing, a host resize, a channel switch, or an unmount reveals it at once.
Reduced motion: no hand-off copy, no hidden row, no rise. The row appears with the pending clock. The scroll behaves as today.
Scrolling away and back during the pending window replays nothing, neither the hand-off nor the fallback rise.
Every animated duration and easing is an existing motion token. Guard timers are not motion and carry no token.
Every line that the Docs rule lists matches the shipped behavior. No document describes the deleted default body.
The card root carries data-status for every own optimistic row. All three tests in the send-and-retry spec pass locally with the E2E flag.
Light and dark on every surface above.
Agent Brief
Category: enhancement Summary: Sent text lifts from the composer and lands on its pending row while the feed scrolls to the tail.
Current behavior:
A send clears the composer and appends a pending row with a 14 px pending clock. Nothing connects the two events. The documented own-send rise, the dim, the "sending…" label, and the data-status stamp never paint. They live in a default body that the message card renders only when it has no children. Both feed bodies pass children. So the chatroom rules and the design system describe motion that does not exist. The send-and-retry spec selects [data-status], so it matches nothing today.
The own-send scroll is the message list's 'smooth' behavior. That is 50 animation frames of exponential ease-out, about 830 ms at 60 Hz. A 200 ms landing cannot end on a row that keeps moving for another 600 ms.
Native messengers (Telegram, iMessage) carry the same glyphs from the field into the bubble, bottom-anchored, with a strong ease-out and no overshoot.
The claims about the code rest on a code trace on 14ab7f9c1 and a read of the message list 1.17.3 source.
Desired behavior:
This replaces the own-send rise bullet in the chatroom rules and the pending recipe row in the design system. The body they describe is dead.
Seams, in glossary names:
Composer submit path — Stage runs on the line before the early composer clear. Read the composer text rect, build the hand-off copy, and mount it in the layer at that rect. Stamp data-send-handoff="lifting" on the [data-chat-composer-surface] element. Then the clear runs exactly as today. The refocus predicate, the draft discard, and the memory resets are untouched.
Send hook, in code order — when the tail is not loaded, discard the staged hand-off copy first, then replace the window. Then generate the client id and arm the hand-off with it. Pass the frame-locked scroll behavior instead of 'smooth' while armed. When the optimistic row is not shown in the feed, discard the staged hand-off copy.
The two feed bodies — the desktop body wrapper and the phone bubble read a per-id subscription. While lifting, the body is hidden at opacity zero. When not lifting and the row is pending, the body plays the fallback rise. The body reports its rect from a layout effect.
Layer host — one absolute layer per surface. It ignores pointer events, is hidden from assistive technology, and is inert. Desktop: an absolute child of the docked chat panel root, next to the hover-menu portal. That root is already positioned; do not change its placement. Phone: an absolute child of the Chat pane body, a sibling of the chatroom and the composer emoji panel. The Chat pane body becomes positioned and keeps its padding. Never inside the composer bar or the message feed, because both clip.
Layer tier — inside the docked band, so never above the chat panel's own tier. It paints over the feed rows and the composer. It paints below the hover menu and the Jump to present button.
Store — one module in the message highlight store shape. It holds a per-id subscription, a generation gate as in the message jump timing, and a consumed-id set. Only the one landing row and the layer re-render. No new state in the chatroom context or the composer context.
Eligibility, all required:
Send mode.
No reply.
No attachments.
Not chunked.
Not emoji-only.
Composer text fully visible in its box.
Reduced motion off.
Optimistic row shown in the feed.
Every other send plays the fallback rise. Edit and comment are the exception; they play nothing.
Choreography (60 Hz reference):
Phase
From ms
To ms
Element
Change
Token
Pressed
-100
0
Send button
Existing pressed recipe, scale 0.95. Unchanged.
existing 100 ms literal
Stage
0
0
Composer surface, layer
Hand-off copy mounts at the source rect, laid out at the landing width. Composer clears. Placeholder held at opacity 0.
none
Append
0
16
Landing row body
Mounts hidden. Layout effect reports its rect.
none
Measure
16
16
Layer controller
One batch: host rect, body rect, scroller rect, Footer height. Then no reads.
none
Travel X
16
136
Hand-off copy outer wrapper
translateX(dx) to 0, ease-out. Settles first.
--motion-overlay-in
Travel Y
16
216
Hand-off copy inner wrapper
translateY(dy) to 0, ease-out. Bottom edges are the anchor.
--motion-panel
Fill
16
136
Hand-off copy fill layer, phone only
Owner bubble fill opacity 0 to 1 under the text.
--motion-overlay-in
Scroll
16
216
Message list scroller
Frame-locked to the tail. Frame count from the measured frame interval.
--motion-panel
Fade out
216
296
Hand-off copy
Opacity 1 to 0, ease-in. Then removed.
--motion-overlay-out
Reveal
216
336
Landing row body, placeholder
Opacity 0 to 1, ease-out. Attribute flips to landed.
--motion-overlay-in
Cleanup
336
336
Store, attribute
Entry consumed. Attribute removed. Guard timer at panel + overlay-in + 50 ms; not motion.
none
Confirm
echo
echo
Pending clock
Becomes the time. No motion. Slot keeps its min width.
none
Measurement rule. Two reads, none inside an animation frame. First, the composer text rect at Stage, before the clear, with the host rect cached. Second, the landing row body rect from its layout effect, at most one frame after mount. That second read is one batch with the scroller rect and the Footer height. The final target is tail-anchored. Shift the body rect so the card bottom sits at the scroller bottom minus the Footer. Never use the message list's estimated offset. All coordinates are host-relative. position: fixed is never used, so the visual viewport never enters the math. When the row has not mounted after three frames, abort. When the host height changes between the two reads, abort. When a resize observer on the host fires while lifting, abort. Abort means: the hand-off copy fades out over --motion-overlay-out, the row reveals at once, and the row plays nothing.
Width rule. At Stage, lay the hand-off copy out at the landing width, not the composer width. The copy box carries the landing body's own classes and the feed column width, so lines break at the same points. The one re-break happens on the Stage frame, together with the composer clear; accepted. When the measured landing width differs from the copy width by more than 1 px, abort.
Scroll rule. The tail scroll stays unconditional. Only its curve and length change while a hand-off is armed. Pass the message list a custom behavior: the frame count is 200 ms divided by the measured frame interval, rounded, with the ease-out curve. Measure the frame interval once at chatroom mount from two animation frame timestamps, default 16.7 ms. Add one JS helper that solves cubic-bezier(0, 0, 0.58, 1), the curve the CSS keyword ease-out names. It mirrors an existing token; it is not a new value. Add no dependency for it, direct or transitive.
Reduced-motion rule. The reduced-motion check makes the Stage step return early. No hand-off copy, no hidden body, no placeholder hold, no custom behavior. The scroll stays 'smooth' as today. The fallback rise sits behind the motion-safe variant. The pending clock, the clear, the refocus, and the status flips stay, because they are status, not decoration.
One-shot rule. The store is keyed by the client id. An id is consumed at cleanup and never re-armed. The fallback rise is gated by the same consumed set, keyed by client id plus retry attempt. A row that remounts while still pending plays nothing again. Retry bumps the attempt, so the rise replays once. Re-render, status flip, and the echo merge replay nothing.
Rapid-send rule. One live hand-off at a time. Arming a second id finishes the first at once. Its animations finish, its row reveals, and its copy is removed. Its late callbacks are gated out. The second hand-off measures after the first append shifted the list.
Failed and retry rule. Failure flips the row to failed: the red alert replaces the pending clock and the failed row appears. Retry re-sets pending and replays only the fallback rise. The hand-off never replays, because the composer is empty and the id is consumed.
Edit-mode rule. Edit appends no row and clears after the network await. No hand-off copy, no hidden row, no rise. Comment mode appends no optimistic row either; nothing plays until the echo arrives.
Pending rule. The pending clock is the only pending cue. Delete the dim, the "sending…" label, and the default body. Move data-status to the card root, next to the message id attribute. The card root stamps it for every own optimistic row: pending, sent, and failed, from the row's status. Server rows carry no attribute. The spec's sent assertion is met by the duplicate-key path, which flips the row to sent. The spec's failed-send test also expects the composer to still hold the text. The early clear empties the composer before the send, and a failed send does not refill it. Change or drop that one assertion. Do not put a failed message's text back in the composer; the failed row keeps it for Retry. A failed comment has no failed row, so #281 restores its text in the composer.
Copy rule. Clone the composer editor node. Strip contenteditable, id, tabindex, and role. Sanitize with the same sanitizer the long-press highlight card uses. Use the feed text size, so the copy breaks lines where the landing row does. Phone copies carry a second layer under the text with the feed's own owner bubble recipe. That layer uses the same classes the landing row paints and adds nothing of its own. Desktop copies are text only, because desktop rows have no bubble.
Docs rule. Update in the same change:
the own-send scroll sentence in the chatroom rules
the own-send rise bullet in the chatroom rules
the two pending recipe rows in the design system
the msg-send-in comment
the glossary line in CONTEXT.md, under a new ## Chat send heading
No document may describe the deleted body again.
Key interfaces:
Send hand-off — the whole motion; one name in symbols, DOM, docs; short form hand-off
Hand-off copy — the static sanitized clone in the layer; transform and opacity only
Landing row — the pending row the message list appends; hidden while lifting, revealed on landing
Fallback rise — msg-send-in on the real row bodies; every ineligible send; replays once on retry
Chat pane — the phone host; the layer is an absolute child of its body
Docked chat panel — the desktop host; the layer is an absolute child of its root
Pending clock — the only pending cue; becomes the time on the echo
Motion tokens — --motion-panel, --motion-overlay-in, --motion-overlay-out, --motion-ease-enter, --motion-ease-exit and their JS mirrors; nothing new
send-and-retry spec — selects [data-status] on the card root; all three tests pass
Out of scope
Sounds, haptics, confetti, or any celebration
A new animation library, or a View Transition path for this motion
Delaying the composer clear, the draft discard, or the refocus predicate
Blocking input or the next send while the motion runs
Changing the unconditional tail scroll rule; only its curve and length change while armed
Animating message list item wrappers or the list container
Transforms on the composer surface or any ProseMirror host
Reply sends in the hand-off; they play the fallback rise until the optimistic row carries the reply preview
A send-glyph launch or a mic-glyph entrance on the send button
Folding the 100 ms pressed recipe into a token
Switching the reduced-motion scroll to 'instant'
A comment-mode acknowledgment with no landing
A keyed remount crossfade on the timestamp slot
A live region for "Message sent"; file it as a separate accessibility row
Deferring the notification prompt; check it on a prototype first
Scaling the text, or adding scale() to the fallback rise
Others' arrivals; the at-bottom rule for them is unchanged
Verification
Desktop Chrome, Firefox, and Safari: a plain send, a multi-line send, and a rapid double send.
Desktop: a failed send with Retry, an edit, a reply, and a media send.
Desktop: the first message in an empty channel, and a send while scrolled up.
iOS Safari on a real phone with the keyboard open: the keyboard stays up and the Chat pane does not move. The text lands inside the owner bubble.
Android Chrome on a real phone: the same paths. A 4x CPU throttle trace of one send shows no long task over 50 ms from the hand-off.
A 120 Hz phone: the list and the text settle together.
Light and dark on every surface above. No seam between the hand-off copy and the landing row. The placeholder never shows under the moving copy.
OS reduced motion on: no hand-off copy, no hidden row, no rise. The pending clock and the scroll still work.
Emoji-only, chunked, and comment sends: no hand-off copy, no hidden row.
Channel switch during a send: the row on return is visible with the pending clock or the time.
Run the E2E suite locally with NEXT_PUBLIC_E2E=true on the dev server. Observe the three send-and-retry tests green. CI does not run this spec.
Skeleton and layout unchanged: the Chat pane body padding still measures the same.
The issue is implementation-ready under these nine assumptions. Confirm or change them before an agent picks it up.
Tail scroll curve. The own-send tail scroll stays unconditional. While a Send hand-off is armed, its value changes from 'smooth' to a frame-locked 200 ms behavior. The chatroom rule sentence that quotes () => 'smooth' is reworded in the same change. If you decline, the travel cannot land on a settled row. Ship only the fallback rise, the data-status move, and the docs cleanup in that case.
Pending row is clock-only. The opacity-70 dim and the "sending…" label are deleted with the default body. Both are dead today. The dim is echo-gated, so a stalled realtime channel would dim every own row without end.
Reply sends are excluded from this slice. They play the fallback rise. The optimistic row stores no reply preview and grows when the echo hydrates it, so the landing height is not final. A follow-up can copy the preview from the reply memory and lift the exclusion.
Desktop and phone ship in one issue. Desktop rows have no bubble, so the copy is text only and the lift is short. Review the desktop feel on a prototype before merge. A phone-first flag is the fallback if the desktop lift reads as noise.
Naming. The concept is Send hand-off, filed under a new ## Chat send heading in CONTEXT.md. Symbols are sendHandoff*; the DOM attribute is data-send-handoff. No ghost, flight, or motion words in symbols.
The send-and-retry spec runs only locally. Production CI runs the five extension suites only. The spec cannot pass today, because it selects [data-status] on a body that never renders. This issue makes all three tests pass with NEXT_PUBLIC_E2E=true on the dev server.
No live region for "Message sent". That is accessibility baseline work, independent of motion. File it separately if wanted.
Notification prompt. It fires after the network await and can mount a z-[60] card during landing on a fast link. Do not pre-build a deferral. Check it on a prototype and file a follow-up only if it shows.
One JS helper. An ease-out bezier solver joins the motion utilities, because the message list's custom behavior takes a bezier function. It mirrors the CSS ease-out keyword; it is not a new easing value.
Design record: four designs were drafted from different angles, scored by three judges (UX parity, engineering risk, house fit), and merged. The travel design won on the maintainer's ask. The risk-first in-place rise survives as the fallback path inside this issue.
Two doc lines describe the own-send rise that this issue changes. Please update them in the same change:
apps/webapp/src/components/chatroom/CLAUDE.md:49 says msg-send-in "plays on DefaultMessageBody while status === 'pending'".
.cursor/docs/design-system.md:558, the "pending / failed (own send)" row, describes the same rise and the "sending…" label.
No feed body renders DefaultMessageBody on 14ab7f9c1, so both lines describe code that never runs today. The composer doc-drift issue, #286, leaves these two lines to this issue.
Closing. The static part shipped in 10edc70: an own optimistic row carries data-status (pending or failed) on the card root, and the default body that never rendered is gone.
The send motion was built, then rejected by the maintainer on 2026-09-21, so no motion ships. Reopen only for a new motion design that the maintainer approves.
What to build
Add the Send hand-off to the chatroom. When a signed-in member sends a text message, the typed text lifts out of the composer. It lands on the new pending row. The feed scrolls to the tail at the same time. The composer clears on the same frame as today. Typing is never blocked.
The Send hand-off has two parts. The hand-off copy is a static, sanitized copy of the composer text. It moves on transform and opacity only, inside one layer that paints over the feed and the composer. That layer is a sibling of the composer and of the feed, never an ancestor of either. So the transform creates no containing block for a ProseMirror host, and it never touches the visualViewport shell. The landing row is the pending row the message list appends. Its body stays hidden while lifting, then fades in as the hand-off copy fades out.
Every send that cannot land plays the fallback rise instead. That rise is the existing
msg-send-inkeyframe, moved from a dead component onto the real row bodies. Today no own send moves at all. The documented rise lives in a body that no feed variant renders.Chunked sends are messages the composer splits at 3000 HTML characters.
One name: Send hand-off, short form hand-off. Symbols use
sendHandoff*. The DOM attribute isdata-send-handoff. Do not use ghost, flight, or motion in symbols, class names, or file names.Flow
States, and what the user sees in each:
Acceptance criteria
data-statusfor every own optimistic row. All three tests in thesend-and-retryspec pass locally with the E2E flag.Agent Brief
Category: enhancement
Summary: Sent text lifts from the composer and lands on its pending row while the feed scrolls to the tail.
Current behavior:
A send clears the composer and appends a pending row with a 14 px pending clock. Nothing connects the two events. The documented own-send rise, the dim, the "sending…" label, and the
data-statusstamp never paint. They live in a default body that the message card renders only when it has no children. Both feed bodies pass children. So the chatroom rules and the design system describe motion that does not exist. Thesend-and-retryspec selects[data-status], so it matches nothing today.The own-send scroll is the message list's
'smooth'behavior. That is 50 animation frames of exponential ease-out, about 830 ms at 60 Hz. A 200 ms landing cannot end on a row that keeps moving for another 600 ms.Native messengers (Telegram, iMessage) carry the same glyphs from the field into the bubble, bottom-anchored, with a strong ease-out and no overshoot.
The claims about the code rest on a code trace on
14ab7f9c1and a read of the message list 1.17.3 source.Desired behavior:
This replaces the own-send rise bullet in the chatroom rules and the pending recipe row in the design system. The body they describe is dead.
Seams, in glossary names:
data-send-handoff="lifting"on the[data-chat-composer-surface]element. Then the clear runs exactly as today. The refocus predicate, the draft discard, and the memory resets are untouched.'smooth'while armed. When the optimistic row is not shown in the feed, discard the staged hand-off copy.Eligibility, all required:
Every other send plays the fallback rise. Edit and comment are the exception; they play nothing.
Choreography (60 Hz reference):
translateX(dx)to0, ease-out. Settles first.--motion-overlay-intranslateY(dy)to0, ease-out. Bottom edges are the anchor.--motion-panel--motion-overlay-in--motion-panel--motion-overlay-outlanded.--motion-overlay-inMeasurement rule. Two reads, none inside an animation frame. First, the composer text rect at Stage, before the clear, with the host rect cached. Second, the landing row body rect from its layout effect, at most one frame after mount. That second read is one batch with the scroller rect and the Footer height. The final target is tail-anchored. Shift the body rect so the card bottom sits at the scroller bottom minus the Footer. Never use the message list's estimated offset. All coordinates are host-relative.
position: fixedis never used, so the visual viewport never enters the math. When the row has not mounted after three frames, abort. When the host height changes between the two reads, abort. When a resize observer on the host fires while lifting, abort. Abort means: the hand-off copy fades out over--motion-overlay-out, the row reveals at once, and the row plays nothing.Width rule. At Stage, lay the hand-off copy out at the landing width, not the composer width. The copy box carries the landing body's own classes and the feed column width, so lines break at the same points. The one re-break happens on the Stage frame, together with the composer clear; accepted. When the measured landing width differs from the copy width by more than 1 px, abort.
Scroll rule. The tail scroll stays unconditional. Only its curve and length change while a hand-off is armed. Pass the message list a custom behavior: the frame count is 200 ms divided by the measured frame interval, rounded, with the ease-out curve. Measure the frame interval once at chatroom mount from two animation frame timestamps, default 16.7 ms. Add one JS helper that solves
cubic-bezier(0, 0, 0.58, 1), the curve the CSS keywordease-outnames. It mirrors an existing token; it is not a new value. Add no dependency for it, direct or transitive.Reduced-motion rule. The reduced-motion check makes the Stage step return early. No hand-off copy, no hidden body, no placeholder hold, no custom behavior. The scroll stays
'smooth'as today. The fallback rise sits behind the motion-safe variant. The pending clock, the clear, the refocus, and the status flips stay, because they are status, not decoration.One-shot rule. The store is keyed by the client id. An id is consumed at cleanup and never re-armed. The fallback rise is gated by the same consumed set, keyed by client id plus retry attempt. A row that remounts while still pending plays nothing again. Retry bumps the attempt, so the rise replays once. Re-render, status flip, and the echo merge replay nothing.
Rapid-send rule. One live hand-off at a time. Arming a second id finishes the first at once. Its animations finish, its row reveals, and its copy is removed. Its late callbacks are gated out. The second hand-off measures after the first append shifted the list.
Failed and retry rule. Failure flips the row to failed: the red alert replaces the pending clock and the failed row appears. Retry re-sets pending and replays only the fallback rise. The hand-off never replays, because the composer is empty and the id is consumed.
Edit-mode rule. Edit appends no row and clears after the network await. No hand-off copy, no hidden row, no rise. Comment mode appends no optimistic row either; nothing plays until the echo arrives.
Pending rule. The pending clock is the only pending cue. Delete the dim, the "sending…" label, and the default body. Move
data-statusto the card root, next to the message id attribute. The card root stamps it for every own optimistic row:pending,sent, andfailed, from the row's status. Server rows carry no attribute. The spec'ssentassertion is met by the duplicate-key path, which flips the row tosent. The spec's failed-send test also expects the composer to still hold the text. The early clear empties the composer before the send, and a failed send does not refill it. Change or drop that one assertion. Do not put a failed message's text back in the composer; the failed row keeps it for Retry. A failed comment has no failed row, so #281 restores its text in the composer.Copy rule. Clone the composer editor node. Strip
contenteditable,id,tabindex, androle. Sanitize with the same sanitizer the long-press highlight card uses. Use the feed text size, so the copy breaks lines where the landing row does. Phone copies carry a second layer under the text with the feed's own owner bubble recipe. That layer uses the same classes the landing row paints and adds nothing of its own. Desktop copies are text only, because desktop rows have no bubble.Docs rule. Update in the same change:
msg-send-incommentCONTEXT.md, under a new## Chat sendheadingNo document may describe the deleted body again.
Key interfaces:
msg-send-inon the real row bodies; every ineligible send; replays once on retry--motion-panel,--motion-overlay-in,--motion-overlay-out,--motion-ease-enter,--motion-ease-exitand their JS mirrors; nothing newsend-and-retryspec — selects[data-status]on the card root; all three tests passOut of scope
'instant'scale()to the fallback riseVerification
NEXT_PUBLIC_E2E=trueon the dev server. Observe the threesend-and-retrytests green. CI does not run this spec.