fix(desktop): hover-state polish + Search width cap + reserve reaction-icon space

NoteCard hover (header+text inner clickable + avatar/name chip):
- Clip(RoundedCornerShape(8.dp)) before .clickable on the header+text
  Column so the ripple rounds instead of cutting a hard rectangle.
- Avatar+name chip gets a stadium clip (RoundedCornerShape(100.dp))
  before its clickable — matches how Slack / Notion / Linear render
  user-pill hover states.

Chat bubble hover (ChatBubbleLayout):
- combinedClickable was applied outside the Surface's shape clipping,
  so the ripple ignored ChatBubbleShapeMe/Them. Moved the shape into a
  named val and added Modifier.clip(bubbleShape) ahead of
  combinedClickable. Hover now rounds with the bubble.

Chat reaction icon (ChatPane detailRow):
- On hover the "add reaction" icon used to conditionally appear and
  shift every other element in the detail row, causing the whole list
  to reflow up or down. Wrapped in a Box with Modifier.alpha that
  fades in/out — the icon is always laid out so the row stays fixed.
  Button is disabled when not hovered so it's click-through when
  invisible.

Search text field:
- Now padded by LocalReadingSidePadding so it stays inside the 720dp
  reading column on wide displays (previously the search Row's
  fillMaxWidth spanned the whole window after the ReadingColumn
  refactor).
- Bumped height from 40dp → 44dp. M3 OutlinedTextField has ~16dp of
  internal vertical content padding that was clipping the bodyMedium
  placeholder at 40dp. Same change for the chat input.

https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo
This commit is contained in:
Claude
2026-04-24 15:55:57 +00:00
parent 3b9ef980a6
commit 7da69fbaee
4 changed files with 70 additions and 52 deletions
@@ -34,6 +34,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.compositeOver
import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleMaxSizeModifier import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleMaxSizeModifier
@@ -128,9 +129,14 @@ fun ChatBubbleLayout(
) )
} }
val bubbleShape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem
// Clip the hover/ripple to the bubble shape — combinedClickable otherwise
// paints a rectangular ripple that ignores the Surface's rounded corners.
val clickableModifier = val clickableModifier =
remember { remember(bubbleShape) {
Modifier.combinedClickable( Modifier
.clip(bubbleShape)
.combinedClickable(
onClick = { onClick = {
if (!onClick()) { if (!onClick()) {
if (!isComplete) { if (!isComplete) {
@@ -148,7 +154,7 @@ fun ChatBubbleLayout(
) { ) {
Surface( Surface(
color = bgColor.value, color = bgColor.value,
shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem, shape = bubbleShape,
modifier = clickableModifier, modifier = clickableModifier,
) { ) {
Column(modifier = MessageBubbleLimits, verticalArrangement = ChatRowColSpacing5dp) { Column(modifier = MessageBubbleLimits, verticalArrangement = ChatRowColSpacing5dp) {
@@ -358,9 +358,10 @@ fun SearchScreen(
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
// Search bar with advanced toggle // Search bar with advanced toggle — honors the reading width cap so it
// stays centered with the rest of the screen's content on wide windows.
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -370,10 +371,12 @@ fun SearchScreen(
textFieldValue = it textFieldValue = it
state.updateFromText(it.text) state.updateFromText(it.text)
}, },
// .height(40.dp) overrides M3's 56dp min-height (intended for // .height(44.dp) overrides M3's 56dp min-height (intended for
// mobile touch) — desktop inputs should feel closer to Slack / // mobile touch) — desktop inputs should feel closer to Slack /
// Raycast / VS Code at ~40dp. // Raycast / VS Code. 44dp (not 40) keeps the bodyMedium
modifier = Modifier.weight(1f).height(40.dp).focusRequester(focusRequester), // placeholder from clipping vertically inside M3's built-in
// content padding.
modifier = Modifier.weight(1f).height(44.dp).focusRequester(focusRequester),
textStyle = MaterialTheme.typography.bodyMedium, textStyle = MaterialTheme.typography.bodyMedium,
placeholder = { placeholder = {
Text( Text(
@@ -63,6 +63,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropEvent
import androidx.compose.ui.draganddrop.DragAndDropTarget import androidx.compose.ui.draganddrop.DragAndDropTarget
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed import androidx.compose.ui.input.key.isCtrlPressed
@@ -560,11 +561,13 @@ private fun MessageWithReactions(
} }
} }
// AddReaction icon on hover // AddReaction icon: always laid out to reserve space (so hover
if (showIcon) { // doesn't reflow the detail row and shift the whole list);
Box { // alpha fades in/out based on hover.
Box(modifier = Modifier.alpha(if (showIcon) 1f else 0f)) {
IconButton( IconButton(
onClick = { showPicker = !showPicker }, onClick = { showPicker = !showPicker },
enabled = showIcon,
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
) { ) {
Icon( Icon(
@@ -592,7 +595,6 @@ private fun MessageWithReactions(
} }
} }
} }
}
}, },
) { _ -> ) { _ ->
when (note.event) { when (note.event) {
@@ -719,13 +721,14 @@ private fun MessageInput(
OutlinedTextField( OutlinedTextField(
value = messageText, value = messageText,
onValueChange = onMessageChange, onValueChange = onMessageChange,
// heightIn(min = 40.dp) overrides M3's 56dp mobile-touch min so the // heightIn(min = 44.dp) overrides M3's 56dp mobile-touch min so the
// chat input sits at a desktop-sensible 40dp when empty; still grows // chat input sits at a desktop-sensible ~44dp when empty; still grows
// to up to ~120dp with content via maxLines = 4. // to up to ~120dp with content via maxLines = 4. 44 (not 40) keeps the
// bodyMedium placeholder from clipping inside M3's internal padding.
modifier = modifier =
Modifier Modifier
.weight(1f) .weight(1f)
.heightIn(min = 40.dp) .heightIn(min = 44.dp)
.onPreviewKeyEvent { event -> .onPreviewKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
// Cmd+Enter (Mac) or Ctrl+Enter to send // Cmd+Enter (Mac) or Ctrl+Enter to send
@@ -165,11 +165,14 @@ fun NoteCard(
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp),
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
// Header + text area — clickable to navigate to thread // Header + text area — clickable to navigate to thread. Clip BEFORE
// clickable so the ripple/hover fill is rounded, not a hard rectangle.
Column( Column(
modifier = modifier =
if (onClick != null) { if (onClick != null) {
Modifier.clickable { onClick() } Modifier
.clip(RoundedCornerShape(8.dp))
.clickable { onClick() }
} else { } else {
Modifier Modifier
}, },
@@ -179,12 +182,15 @@ fun NoteCard(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
// Author with avatar // Author with avatar — stadium-shaped hover to match the
// avatar+name chip's visual shape.
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = modifier =
if (onAuthorClick != null) { if (onAuthorClick != null) {
Modifier.clickable { onAuthorClick(note.pubKeyHex) } Modifier
.clip(RoundedCornerShape(100.dp))
.clickable { onAuthorClick(note.pubKeyHex) }
} else { } else {
Modifier Modifier
}, },