mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(desktop): add "New posts" chip with slide-from-top animation
Fixes the perceptual "stale feed on launch" bug: on cold launch the desktop feed paints with whatever local cache had (up to 7 days old) before relays catch up. The live updateFeedWith() path already prepends fresh events silently, but users had no signal that fresh content arrived unless they were already at the top of the feed (auto-snap via StickToTopOnPrepend). This adds a Twitter/Mastodon-style floating pill chip that slides down from above the search header when fresh events have prepended AND the user is scrolled below position 0. Tapping it smooth-scrolls to top and slides the chip back up off-screen. Scrolling to top manually also dismisses it. Implementation: - NewPostsChip + rememberNewPostsChipState in commons/commonMain so any future feed surface (incl. Android, iOS) can adopt it. Desktop wires it today; Android continues with the existing auto-stick + bottom-nav dot pattern. - Visibility predicate is pure-function and unit-tested (5 cases). - Predicate mirrors the inverse of StickToTopOnPrepend's "at top" check so the two systems are mutually exclusive — auto-snap when at top, chip when not. - Chip placement: floating Alignment.TopCenter inside FeedScreen's outer Box, offset by the animated headerSpacerHeight (60.dp normal, 300.dp when search is expanded) so it tracks the header card. - Hoisted lazyListState + headerSpacerHeight one level so the chip can share scroll state with the LazyColumn. Existing viewport-aware metadata loading is unchanged (same lazyListState reference). - Animation: slideInVertically(tween(280, FastOutSlowInEasing)) + fadeIn for enter; slideOutVertically(tween(220, FastOutLinearInEasing)) + fadeOut for exit. Initial/target offset of -fullHeight-16 guarantees the chip is fully off-screen above its rest position. - Per-column scope by construction: each FeedScreen instance has its own chip state (deck mode shows one chip per column). - Resets cleanly on feed mode switch (Following ↔ Global ↔ Custom) because rememberNewPostsChipState is keyed on FeedContentState, which is recreated when viewModel = remember(feedMode, activeFeedId) recomposes. Plan: docs/plans/2026-06-02-feat-new-posts-chip-desktop-feed-plan.md
This commit is contained in:
+117
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.feeds
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutLinearInEasing
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Twitter/Mastodon-style "New posts" floating pill chip. Slides down from above
|
||||
* its anchor when [NewPostsChipState.visible] flips true; slides back up on
|
||||
* dismissal. Tapping triggers an animated smooth-scroll to position 0 of the
|
||||
* associated [androidx.compose.foundation.lazy.LazyListState] and acknowledges
|
||||
* the new top so the chip exits.
|
||||
*
|
||||
* Pair with [rememberNewPostsChipState]. The caller is responsible for
|
||||
* placement — typically inside a [androidx.compose.foundation.layout.Box]
|
||||
* overlay aligned to the top of the feed area, offset below any sticky header.
|
||||
*/
|
||||
@Composable
|
||||
fun NewPostsChip(
|
||||
state: NewPostsChipState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val visible by state.visible
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
modifier = modifier,
|
||||
enter =
|
||||
slideInVertically(
|
||||
animationSpec = tween(durationMillis = 280, easing = FastOutSlowInEasing),
|
||||
initialOffsetY = { fullHeight -> -fullHeight - 16 },
|
||||
) + fadeIn(animationSpec = tween(durationMillis = 220)),
|
||||
exit =
|
||||
slideOutVertically(
|
||||
animationSpec = tween(durationMillis = 220, easing = FastOutLinearInEasing),
|
||||
targetOffsetY = { fullHeight -> -fullHeight - 16 },
|
||||
) + fadeOut(animationSpec = tween(durationMillis = 180)),
|
||||
) {
|
||||
Surface(
|
||||
onClick = { scope.launch { state.dismissAndScrollToTop() } },
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 4.dp,
|
||||
shadowElevation = 6.dp,
|
||||
modifier =
|
||||
Modifier
|
||||
.height(36.dp)
|
||||
.semantics {
|
||||
contentDescription = "New posts available, tap to scroll to top"
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.ArrowUpward,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = "New posts",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.feeds
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/**
|
||||
* Pure predicate for chip visibility. Extracted so it can be unit-tested
|
||||
* without spinning up Compose. Mirrors the inverse of `StickToTopOnPrepend`'s
|
||||
* "user is at top" check in `WatchScrollToTop.kt` so the two systems are
|
||||
* mutually exclusive: auto-snap when at top, chip when not.
|
||||
*/
|
||||
internal fun shouldShowNewPostsChip(
|
||||
isAtTop: Boolean,
|
||||
currentTopId: String?,
|
||||
lastSeenTopId: String?,
|
||||
): Boolean =
|
||||
!isAtTop &&
|
||||
currentTopId != null &&
|
||||
lastSeenTopId != null &&
|
||||
currentTopId != lastSeenTopId
|
||||
|
||||
@Stable
|
||||
class NewPostsChipState internal constructor(
|
||||
val visible: State<Boolean>,
|
||||
private val acknowledgeCurrentTop: () -> Unit,
|
||||
private val animateScrollToTop: suspend () -> Unit,
|
||||
) {
|
||||
/**
|
||||
* Tap handler: acknowledge the current top BEFORE the scroll begins so the
|
||||
* visibility predicate flips to false immediately and the chip's exit
|
||||
* animation runs in parallel with the scroll — visually smoother than
|
||||
* waiting until the scroll lands and the predicate updates via the
|
||||
* `isAtTop` observer.
|
||||
*/
|
||||
suspend fun dismissAndScrollToTop() {
|
||||
acknowledgeCurrentTop()
|
||||
animateScrollToTop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives chip visibility from a [FeedContentState] (live head of the feed)
|
||||
* and a [LazyListState] (the user's scroll position). The returned state is
|
||||
* keyed on both inputs so that switching feeds (e.g. Following → Global, which
|
||||
* recreates the view model and ContentState) resets the chip's "last seen
|
||||
* top" baseline.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberNewPostsChipState(
|
||||
feedContentState: FeedContentState,
|
||||
listState: LazyListState,
|
||||
): NewPostsChipState {
|
||||
val lastSeenTopId = remember(feedContentState) { mutableStateOf<String?>(null) }
|
||||
val currentTopId = rememberCurrentTopIdHex(feedContentState)
|
||||
val currentTopIdState = rememberUpdatedState(currentTopId)
|
||||
|
||||
val isAtTop by remember(listState) {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap the baseline on first paint, and re-acknowledge whenever the
|
||||
// user reaches the top by any means (manual scroll, StickToTopOnPrepend
|
||||
// auto-snap, or our own chip tap).
|
||||
LaunchedEffect(currentTopId, isAtTop) {
|
||||
if (lastSeenTopId.value == null && currentTopId != null) {
|
||||
lastSeenTopId.value = currentTopId
|
||||
} else if (isAtTop && currentTopId != null) {
|
||||
lastSeenTopId.value = currentTopId
|
||||
}
|
||||
}
|
||||
|
||||
val visible =
|
||||
remember(lastSeenTopId, currentTopIdState) {
|
||||
derivedStateOf {
|
||||
shouldShowNewPostsChip(
|
||||
isAtTop = isAtTop,
|
||||
currentTopId = currentTopIdState.value,
|
||||
lastSeenTopId = lastSeenTopId.value,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return remember(feedContentState, listState) {
|
||||
NewPostsChipState(
|
||||
visible = visible,
|
||||
acknowledgeCurrentTop = {
|
||||
lastSeenTopId.value = currentTopIdState.value
|
||||
},
|
||||
animateScrollToTop = {
|
||||
listState.animateScrollToItem(0)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
private fun rememberCurrentTopIdHex(feedContentState: FeedContentState): String? {
|
||||
val flow =
|
||||
remember(feedContentState) {
|
||||
feedContentState.feedContent.flatMapLatest { state ->
|
||||
when (state) {
|
||||
is FeedState.Loaded -> state.feed.map { it.list.firstOrNull()?.idHex }
|
||||
else -> flowOf(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
val key by flow.collectAsState(initial = null)
|
||||
return key
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.ui.feeds
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NewPostsChipStateTest {
|
||||
@Test
|
||||
fun `hides when user is at top`() {
|
||||
assertFalse(
|
||||
shouldShowNewPostsChip(
|
||||
isAtTop = true,
|
||||
currentTopId = "new",
|
||||
lastSeenTopId = "old",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hides when feed has no top item yet`() {
|
||||
assertFalse(
|
||||
shouldShowNewPostsChip(
|
||||
isAtTop = false,
|
||||
currentTopId = null,
|
||||
lastSeenTopId = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hides before first acknowledgement is recorded`() {
|
||||
// First paint: user has not yet seen any top; we initialize the baseline
|
||||
// synchronously in the LaunchedEffect, but the predicate must be false
|
||||
// when lastSeenTopId is null so we never show a chip during bootstrap.
|
||||
assertFalse(
|
||||
shouldShowNewPostsChip(
|
||||
isAtTop = false,
|
||||
currentTopId = "a",
|
||||
lastSeenTopId = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hides when current top equals last seen top`() {
|
||||
assertFalse(
|
||||
shouldShowNewPostsChip(
|
||||
isAtTop = false,
|
||||
currentTopId = "a",
|
||||
lastSeenTopId = "a",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shows when user is scrolled down and a new top has arrived`() {
|
||||
assertTrue(
|
||||
shouldShowNewPostsChip(
|
||||
isAtTop = false,
|
||||
currentTopId = "new",
|
||||
lastSeenTopId = "old",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -43,11 +43,13 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.FilterChip
|
||||
@@ -98,6 +100,8 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||
import com.vitorpamplona.amethyst.commons.ui.elements.BoostedMark
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.NewPostsChip
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.rememberNewPostsChipState
|
||||
import com.vitorpamplona.amethyst.commons.ui.layouts.GenericRepostLayout
|
||||
import com.vitorpamplona.amethyst.commons.util.toTimeAgo
|
||||
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||
@@ -670,16 +674,18 @@ fun FeedScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Hoisted so the floating "New posts" chip can share the same scroll
|
||||
// state as the LazyColumn and follow the animated header height.
|
||||
val homeFeedLazyListState = rememberLazyListState()
|
||||
val headerSpacerHeight by animateDpAsState(
|
||||
targetValue = if (searchActive) 300.dp else 60.dp,
|
||||
animationSpec = tween(200),
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Layer 1: Feed content (scrollable, behind scrim)
|
||||
ReadingColumn {
|
||||
// Reserve space for the header card that floats above
|
||||
// Reserve space for the header card that floats above.
|
||||
// When search is expanded, the card grows — add more margin.
|
||||
val headerSpacerHeight by animateDpAsState(
|
||||
targetValue = if (searchActive) 300.dp else 60.dp,
|
||||
animationSpec = tween(200),
|
||||
)
|
||||
// Reserve space for the header card that floats above the feed.
|
||||
Spacer(Modifier.height(headerSpacerHeight))
|
||||
|
||||
// Feed content based on FeedState
|
||||
@@ -720,9 +726,7 @@ fun FeedScreen(
|
||||
|
||||
is FeedState.Loaded -> {
|
||||
val loadedState by state.feed.collectAsState()
|
||||
val lazyListState =
|
||||
androidx.compose.foundation.lazy
|
||||
.rememberLazyListState()
|
||||
val lazyListState = homeFeedLazyListState
|
||||
|
||||
// Viewport-aware scroll observation: fetch metadata for newly visible notes
|
||||
LaunchedEffect(lazyListState, loadedState) {
|
||||
@@ -812,6 +816,25 @@ fun FeedScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 1.5: "New posts" chip — floats below the header card,
|
||||
// appears when fresh events prepend while the user is scrolled
|
||||
// away from the top of the feed. Below the scrim in z-order so
|
||||
// it dims along with the feed when search is expanded.
|
||||
if (feedState is FeedState.Loaded) {
|
||||
val chipState =
|
||||
rememberNewPostsChipState(
|
||||
feedContentState = viewModel.feedState,
|
||||
listState = homeFeedLazyListState,
|
||||
)
|
||||
NewPostsChip(
|
||||
state = chipState,
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = headerSpacerHeight + 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Reply dialog
|
||||
if (replyToEvent != null && account != null) {
|
||||
ComposeNoteDialog(
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
---
|
||||
title: "feat: New posts chip on desktop feed"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-06-02
|
||||
origin: docs/brainstorms/2026-06-02-stale-feed-on-launch-new-posts-chip-brainstorm.md
|
||||
---
|
||||
|
||||
# feat: New posts chip on desktop feed
|
||||
|
||||
## Overview
|
||||
|
||||
Add a Twitter/Mastodon-style "New posts" floating pill chip to the Amethyst Desktop home feed. The chip slides down from the top — anchored just below the `FeedTabsHeader` (the search bar / header card) — whenever new events arrive while the user is scrolled away from the top of the list. Tapping the chip smooth-scrolls to position 0 and slides the chip back up off-screen. Scrolling to the top manually dismisses it the same way.
|
||||
|
||||
This addresses the user-reported "stale feed on launch" perception bug (see brainstorm: `docs/brainstorms/2026-06-02-stale-feed-on-launch-new-posts-chip-brainstorm.md`). The bug is **perceptual**, not architectural — the live `updateFeedWith()` reactive path already works; new events silently prepend. Users don't notice because the auto-stick (`StickToTopOnPrepend`) only fires when already at position 0.
|
||||
|
||||
## Problem Statement / Motivation
|
||||
|
||||
On cold launch, three factors combine to make the feed feel "stuck on 4-5 day old items":
|
||||
|
||||
1. `LocalRelayStore` hydrates events up to **7 days old** (`LocalRelayStore.kt:143-153`)
|
||||
2. Feed subscription filters have **no `since` parameter** — relay returns its last 200 events regardless of age (`FeedSubscription.kt:41-74`)
|
||||
3. **No perceptual signal** when fresh events finally prepend silently (`FeedContentState.kt:63-68` + `WatchScrollToTop.kt`)
|
||||
|
||||
User confirmation: _"it loads eventually I think. One nice UX would be to show a quick chip or tooltip that animates and shows 'New items - Scroll to top' and tapping on it triggers the scroll."_
|
||||
|
||||
Per brainstorm: subscription `since` tuning and hydration window changes are explicitly **out of scope**. This plan addresses only the perceptual fix.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
A reusable Compose Multiplatform composable + state holder in `commons/`, integrated into Desktop `FeedScreen`. Three components:
|
||||
|
||||
1. **`NewPostsChip`** — a stateless visual pill (`Surface(shape = RoundedCornerShape(999.dp))`) wrapped in `AnimatedVisibility` with vertical slide-in/slide-out + fade.
|
||||
2. **`rememberNewPostsChipState(feedContentState, listState)`** — derives chip visibility by observing the feed's top-item id and the `LazyListState`. Exposes `visible: State<Boolean>` and `dismiss()`.
|
||||
3. **Integration** — `FeedScreen.kt` mounts the chip inside the existing outer `Box`, aligned top-center, with `offset(y = headerSpacerHeight + 8.dp)` so it sits just below the `FeedTabsHeader` (which expands to 300.dp when search is active and collapses to 60.dp otherwise — chip follows via the animated DP).
|
||||
|
||||
### Animation spec (per user request: "nice slide from top and slide out to top")
|
||||
|
||||
```kotlin
|
||||
AnimatedVisibility(
|
||||
visible = chipState.visible.value,
|
||||
enter = slideInVertically(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing),
|
||||
initialOffsetY = { fullHeight -> -fullHeight - 16 }, // start above the chip's resting position
|
||||
) + fadeIn(tween(220)),
|
||||
exit = slideOutVertically(
|
||||
animationSpec = tween(220, easing = FastOutLinearInEasing),
|
||||
targetOffsetY = { fullHeight -> -fullHeight - 16 }, // exit back upward off-screen
|
||||
) + fadeOut(tween(180)),
|
||||
)
|
||||
```
|
||||
|
||||
The `-fullHeight - 16` initial/target offset guarantees the chip is fully off-screen above its anchor point at the start of enter / end of exit, so it never half-appears clipped against the header card.
|
||||
|
||||
### Visibility predicate
|
||||
|
||||
Chip is `visible` when **all three** hold:
|
||||
- New events have arrived since the user last saw the top (`lastSeenTopId != currentTopId`)
|
||||
- The user is NOT at the top: `firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0`
|
||||
- The feed is in `FeedState.Loaded` with a non-empty list
|
||||
|
||||
Predicate matches the inverse of `StickToTopOnPrepend`'s "at top" check (`WatchScrollToTop.kt:141,145`), so the two systems are mutually exclusive — auto-snap when at top, chip when not.
|
||||
|
||||
## Technical Considerations
|
||||
|
||||
### Architecture impacts
|
||||
|
||||
- **Reuse, don't reinvent.** The chip subscribes to `FeedContentState.feedContent` (existing) and `LazyListState` (existing). No new reactive infrastructure.
|
||||
- **No changes to `FeedContentState`.** Top-item tracking lives in the state holder, not on ContentState — keeps ContentState focused on data.
|
||||
- **Common module placement.** Composable lives in `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChip.kt`. Android can adopt it later — out of scope today.
|
||||
- **Each deck column gets its own independent chip** because each column has its own `viewModel = remember(feedMode, activeFeedId)` and its own `lazyListState` (`FeedScreen.kt:433, 654`).
|
||||
|
||||
### Performance implications
|
||||
|
||||
- `snapshotFlow { listState.firstVisibleItemIndex + listState.firstVisibleItemScrollOffset }` is cheap — already used elsewhere in `FeedScreen.kt:664`.
|
||||
- One additional `StateFlow<Boolean>` observation per visible feed. Negligible.
|
||||
- No new subscriptions to relays.
|
||||
|
||||
### Security considerations
|
||||
|
||||
None. This is presentational.
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Chip is a `Surface(onClick = ...)` — natural focus + click target.
|
||||
- `semantics { contentDescription = "New posts available, tap to scroll to top" }` on the Surface.
|
||||
- Slide animation respects `LocalDensity`; no fixed-pixel hacks.
|
||||
- Defer keyboard shortcut (e.g. Home key) to a follow-up — out of scope.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** New events from relays → `DesktopRelaySubscriptionsCoordinator.consumeEvent` → 250ms bundler → `cacheEventStream.emitNewNotes` → `FeedViewModel` collector → `FeedContentState.updateFeedWith` → `feedContent` StateFlow emits new `LoadedFeedState`. **Chip state holder observes the StateFlow** and re-evaluates visibility predicate. Existing `StickToTopOnPrepend` continues to observe `scrollToTop` counter unchanged.
|
||||
- **Error propagation:** None — chip only renders when `FeedState.Loaded`. `FeedState.FeedError` / `Loading` / `Empty` → chip hidden.
|
||||
- **State lifecycle:** Chip state is `remember`ed inside `FeedScreen`. When `feedMode` or `activeFeedId` changes, the parent composable's `remember(feedMode, activeFeedId)` causes ViewModel recreation, which resets ContentState, which resets chip state. Per-column scope verified — no cross-column leakage.
|
||||
- **API surface parity:** Composable is in `commons/` so Android could adopt later. Currently only Desktop wires it. Android continues to use existing `StickToTopOnPrepend` + bottom-nav dot pattern.
|
||||
- **Integration test scenarios:**
|
||||
1. Cold launch with stale cache: chip should appear when fresh events arrive after subscription EOSE, only if user has scrolled below position 0.
|
||||
2. User scrolls down → events arrive → chip appears → user scrolls back to top manually → chip disappears.
|
||||
3. User taps chip → list animates to top → chip exits upward → top-most note now matches `lastSeenTopId`.
|
||||
4. User switches Following → Global mid-chip → chip dismisses immediately (state holder resets with new ContentState).
|
||||
5. Deck mode: two feed columns side-by-side, only the column with new arrivals shows its chip.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional
|
||||
|
||||
- [ ] When `FeedContentState.feedContent` emits a new top-most note id AND `lazyListState.firstVisibleItemIndex > 0` (or `firstVisibleItemScrollOffset > 0`), the chip slides down from above the search header and becomes visible.
|
||||
- [ ] Chip text reads exactly **"New posts"** (no count — per brainstorm resolved decision).
|
||||
- [ ] Chip contains a `MaterialSymbols.ArrowUpward` icon to the left of the text.
|
||||
- [ ] Chip is anchored at `Alignment.TopCenter` of the outer `FeedScreen` Box, with `Modifier.offset(y = headerSpacerHeight + 8.dp)` so it tracks the animated header height (60.dp normal, 300.dp when search is expanded).
|
||||
- [ ] Slide-in animation: `slideInVertically(tween(280, FastOutSlowInEasing), initialOffsetY = { -it - 16 })` + `fadeIn(tween(220))`.
|
||||
- [ ] Slide-out animation: `slideOutVertically(tween(220, FastOutLinearInEasing), targetOffsetY = { -it - 16 })` + `fadeOut(tween(180))`.
|
||||
- [ ] Tapping the chip launches `lazyListState.animateScrollToItem(0)` and triggers exit animation. Chip dismisses and `lastSeenTopId` updates to the current top.
|
||||
- [ ] When the user reaches position 0 by any means (manual scroll, tap, `StickToTopOnPrepend` auto-snap), chip auto-dismisses with slide-out animation and `lastSeenTopId` updates.
|
||||
- [ ] Switching `feedMode` (Following ↔ Global ↔ Custom) resets chip state — chip is hidden on mount of the new mode.
|
||||
- [ ] Existing `StickToTopOnPrepend` behavior is unchanged: when user IS at position 0 and new events prepend, list still auto-snaps to top.
|
||||
- [ ] In desktop deck view, each column shows its own independent chip — no cross-column leakage.
|
||||
- [ ] Chip is hidden in `Loading`, `Empty`, and `FeedError` states.
|
||||
- [ ] Search-expanded state: chip remains visible if conditions hold but its `offset.y` follows the animated 300.dp header height so it stays just below the expanded search card.
|
||||
|
||||
### Non-functional
|
||||
|
||||
- [ ] No regression in feed scroll FPS — animations run at ≥ 60 fps in desktop.
|
||||
- [ ] Accessibility: chip has `contentDescription = "New posts available, tap to scroll to top"`.
|
||||
- [ ] Theme: chip uses `MaterialTheme.colorScheme.surfaceContainerHigh` for background, `colorScheme.onSurface` for text/icon. Respects dark/light theme automatically.
|
||||
- [ ] Elevation: `Surface(tonalElevation = 4.dp, shadowElevation = 6.dp)` — provides separation from feed content.
|
||||
|
||||
### Quality gates
|
||||
|
||||
- [x] `./gradlew :commons:compileKotlinJvm` passes.
|
||||
- [x] `./gradlew :desktopApp:compileKotlin` passes.
|
||||
- [x] `./gradlew spotlessApply` applied before commit.
|
||||
- [x] Unit test for `NewPostsChipState` visibility predicate (pure function over list-state inputs) — 5 cases pass.
|
||||
- [ ] Manual reproduction: cold launch, leave home feed open, scroll down a few items, wait for relay subscription to deliver fresh events → chip appears with slide-down animation. Tap → smooth scroll to top + slide-up. Verify with screen capture.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### File-level changes
|
||||
|
||||
```
|
||||
commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/
|
||||
├── NewPostsChip.kt # NEW — stateless visual composable
|
||||
└── NewPostsChipState.kt # NEW — state holder + rememberNewPostsChipState()
|
||||
|
||||
commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/
|
||||
└── NewPostsChipStateTest.kt # NEW — unit tests for visibility predicate
|
||||
|
||||
desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/
|
||||
└── FeedScreen.kt # MODIFY — wire chip into outer Box
|
||||
```
|
||||
|
||||
### Step 1 — `NewPostsChipState.kt`
|
||||
|
||||
```kotlin
|
||||
// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChipState.kt
|
||||
|
||||
class NewPostsChipState internal constructor(
|
||||
private val lastSeenTopId: MutableState<String?>,
|
||||
val visible: State<Boolean>,
|
||||
val onTap: suspend () -> Unit,
|
||||
) {
|
||||
fun acknowledgeTop(newTopId: String?) {
|
||||
lastSeenTopId.value = newTopId
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberNewPostsChipState(
|
||||
feedContentState: FeedContentState,
|
||||
listState: LazyListState,
|
||||
): NewPostsChipState {
|
||||
val lastSeenTopId = remember(feedContentState) { mutableStateOf<String?>(null) }
|
||||
val feedState by feedContentState.feedContent.collectAsState()
|
||||
|
||||
val currentTopId by remember(feedState) {
|
||||
derivedStateOf {
|
||||
(feedState as? FeedState.Loaded)?.feed?.value?.list?.firstOrNull()?.idHex
|
||||
}
|
||||
}
|
||||
|
||||
val isAtTop by remember(listState) {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize lastSeenTopId on first non-null top
|
||||
LaunchedEffect(currentTopId) {
|
||||
if (lastSeenTopId.value == null && currentTopId != null) {
|
||||
lastSeenTopId.value = currentTopId
|
||||
}
|
||||
}
|
||||
|
||||
// Acknowledge top when user reaches it
|
||||
LaunchedEffect(isAtTop, currentTopId) {
|
||||
if (isAtTop) lastSeenTopId.value = currentTopId
|
||||
}
|
||||
|
||||
val visible = remember {
|
||||
derivedStateOf {
|
||||
!isAtTop &&
|
||||
currentTopId != null &&
|
||||
lastSeenTopId.value != null &&
|
||||
lastSeenTopId.value != currentTopId
|
||||
}
|
||||
}
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
return remember(feedContentState, listState) {
|
||||
NewPostsChipState(
|
||||
lastSeenTopId = lastSeenTopId,
|
||||
visible = visible,
|
||||
onTap = {
|
||||
listState.animateScrollToItem(0)
|
||||
lastSeenTopId.value = currentTopId
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2 — `NewPostsChip.kt`
|
||||
|
||||
```kotlin
|
||||
// commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/NewPostsChip.kt
|
||||
|
||||
@Composable
|
||||
fun NewPostsChip(
|
||||
state: NewPostsChipState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
AnimatedVisibility(
|
||||
visible = state.visible.value,
|
||||
modifier = modifier,
|
||||
enter = slideInVertically(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing),
|
||||
initialOffsetY = { -it - 16 },
|
||||
) + fadeIn(tween(220)),
|
||||
exit = slideOutVertically(
|
||||
animationSpec = tween(220, easing = FastOutLinearInEasing),
|
||||
targetOffsetY = { -it - 16 },
|
||||
) + fadeOut(tween(180)),
|
||||
) {
|
||||
Surface(
|
||||
onClick = { scope.launch { state.onTap() } },
|
||||
shape = RoundedCornerShape(999.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
tonalElevation = 4.dp,
|
||||
shadowElevation = 6.dp,
|
||||
modifier = Modifier
|
||||
.height(36.dp)
|
||||
.semantics {
|
||||
contentDescription = "New posts available, tap to scroll to top"
|
||||
},
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Icon(symbol = MaterialSymbols.ArrowUpward, size = 16.dp)
|
||||
Text(
|
||||
text = "New posts",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3 — `FeedScreen.kt` integration
|
||||
|
||||
Inside `FeedScreen`'s outer `Box(Modifier.fillMaxSize())` (line 604):
|
||||
|
||||
- Move `lazyListState` creation up one level so it's accessible to both the LazyColumn (inside `ReadingColumn`) and the chip overlay.
|
||||
- Capture the `headerSpacerHeight` animated DP so the chip can follow it.
|
||||
- Add the chip as a new layer between Layer 1 (feed content) and Layer 2 (scrim) — or after Layer 3 (header) so it visually sits above the feed but below the header. Z-order matters: chip should NOT cover the header when search is expanded; placing it BELOW the header in the Box's child order, but with offset that puts it under the header, is correct.
|
||||
|
||||
```kotlin
|
||||
// Inside FeedScreen, before `Box(modifier = Modifier.fillMaxSize())` at line 604
|
||||
val lazyListState = androidx.compose.foundation.lazy.rememberLazyListState()
|
||||
val headerSpacerHeight by animateDpAsState(
|
||||
targetValue = if (searchActive) 300.dp else 60.dp,
|
||||
animationSpec = tween(200),
|
||||
label = "headerSpacer",
|
||||
)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Layer 1: Feed content
|
||||
ReadingColumn {
|
||||
Spacer(Modifier.height(headerSpacerHeight))
|
||||
when (val state = feedState) {
|
||||
// ... existing branches ...
|
||||
is FeedState.Loaded -> {
|
||||
val loadedState by state.feed.collectAsState()
|
||||
// ... use lazyListState from outer scope ...
|
||||
LazyColumn(state = lazyListState, ...) { /* unchanged */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 1.5: New-posts chip (NEW) — anchored just below the header card
|
||||
val loadedFeedState = (feedState as? FeedState.Loaded)?.feed?.collectAsState()?.value
|
||||
if (loadedFeedState != null && loadedFeedState.list.isNotEmpty()) {
|
||||
val chipState = rememberNewPostsChipState(
|
||||
feedContentState = viewModel.feedState,
|
||||
listState = lazyListState,
|
||||
)
|
||||
NewPostsChip(
|
||||
state = chipState,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.offset(y = headerSpacerHeight + 8.dp)
|
||||
.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Layer 2: Search scrim (unchanged)
|
||||
// Layer 3: FeedTabsHeader (unchanged)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4 — Test plan
|
||||
|
||||
**Unit test** (`commons/src/jvmTest/kotlin/.../NewPostsChipStateTest.kt`):
|
||||
|
||||
Extract the pure predicate into a testable helper:
|
||||
|
||||
```kotlin
|
||||
internal fun shouldShowNewPostsChip(
|
||||
isAtTop: Boolean,
|
||||
currentTopId: String?,
|
||||
lastSeenTopId: String?,
|
||||
): Boolean = !isAtTop && currentTopId != null && lastSeenTopId != null && currentTopId != lastSeenTopId
|
||||
```
|
||||
|
||||
Cases to cover:
|
||||
- `isAtTop = true` → false
|
||||
- `currentTopId = null` (empty feed) → false
|
||||
- `lastSeenTopId = null` (first paint, no acknowledgement yet) → false
|
||||
- `currentTopId == lastSeenTopId` (no new events) → false
|
||||
- All three valid + non-matching ids → true
|
||||
|
||||
**Manual repro**:
|
||||
|
||||
1. `./gradlew :desktopApp:run`
|
||||
2. Open home feed, wait for it to load.
|
||||
3. Scroll down 5–10 items.
|
||||
4. Wait ~5s for relay subscription to deliver fresh events (or trigger via posting from another client).
|
||||
5. Observe chip slides down from above the search header.
|
||||
6. Tap chip → list smooth-scrolls to top, chip slides up off-screen.
|
||||
7. Switch to Global → chip immediately hidden.
|
||||
8. Repeat with search expanded — chip appears below the expanded 300.dp header.
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- Subjective: user (you) confirms feed no longer "feels stuck" on launch.
|
||||
- Functional: chip appears reliably within 250ms (one bundler cycle) of a fresh event arriving while scrolled.
|
||||
- No regressions: existing `StickToTopOnPrepend` auto-snap still fires when user is at position 0.
|
||||
|
||||
## Dependencies & Risks
|
||||
|
||||
| Item | Risk | Mitigation |
|
||||
|------|------|------------|
|
||||
| `lazyListState` hoisting from inside `FeedState.Loaded` branch up to outer scope | Existing viewport-aware metadata loading (`LaunchedEffect` at line 659) and side-padding logic must continue to work | Keep `lazyListState` reference identical; only its declaration point moves. Verify the `LaunchedEffect(lazyListState, loadedState)` block still composes correctly. |
|
||||
| Chip overlaps a future floating action button or overlay | Layout collision | Use `Modifier.zIndex(1f)` if z-order issues arise; defer to PR review. |
|
||||
| Recomposition thrash from `firstVisibleItemScrollOffset` changing on every pixel | Performance | Wrap in `derivedStateOf` (already in plan); only `isAtTop: Boolean` flips trigger downstream recomposition. |
|
||||
| `MaterialSymbols.ArrowUpward` codepoint not in the subset font | Tofu glyph | `ArrowUpward = MaterialSymbol("")` is already declared in `MaterialSymbols.kt:36` — no font regeneration needed. Verified per research. |
|
||||
| Animation feels too fast / too slow | Subjective | 280ms in, 220ms out are conservative defaults; tune during manual test. |
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **Brainstorm document:** [`docs/brainstorms/2026-06-02-stale-feed-on-launch-new-posts-chip-brainstorm.md`](../brainstorms/2026-06-02-stale-feed-on-launch-new-posts-chip-brainstorm.md)
|
||||
- Key decisions carried forward:
|
||||
- Floating overlay placement (not sticky list item)
|
||||
- No count — text always reads "New posts"
|
||||
- Reset on feed mode change
|
||||
- Scope limited to Desktop; commons composable available for future Android adoption
|
||||
- Subscription `since` parameter and hydration window changes explicitly out of scope
|
||||
|
||||
### Internal references
|
||||
|
||||
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedContentState.kt:44-231` — feed state holder, `scrollToTop` counter, `updateFeedWith` entry point
|
||||
- `amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/WatchScrollToTop.kt:45-52,141,145` — existing scroll-to-top pattern and "at top" predicate to mirror
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt:604,610-614,654,683,770` — outer `Box`, animated `headerSpacerHeight`, `lazyListState` creation, `LazyColumn`, `FeedTabsHeader`
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt:117-202,234-318` — per-column FeedScreen instances confirm per-column chip scope
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/search/SearchPill.kt:48-78` — existing pill pattern (`Surface(shape = RoundedCornerShape(999.dp), color = surfaceContainerHigh, height = 36.dp)`) — reuse the visual conventions
|
||||
- `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt:36` — `ArrowUpward = MaterialSymbol("")` (already in subset font)
|
||||
|
||||
### External references
|
||||
|
||||
None — pattern is standard Compose `AnimatedVisibility` + `slideInVertically` / `slideOutVertically`. Material 3 components in use.
|
||||
|
||||
---
|
||||
|
||||
## Unanswered Questions
|
||||
|
||||
- chip click target hit-area (36.dp tall pill — minimum touch target on touch displays?)
|
||||
- exact `surfaceContainerHigh` vs `primaryContainer` color choice — subjective, decide during manual test
|
||||
- whether to add a thin border/outline for contrast in light theme
|
||||
- should chip auto-dismiss after N seconds of no interaction, or persist until user acts? (current plan: persist)
|
||||
- accessibility: should chip announce on appearance via live region, or only on focus?
|
||||
- should Android adopt the chip in a follow-up plan, or stick with the existing dot + auto-stick pattern?
|
||||
Reference in New Issue
Block a user