mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
fix(desktop): avatar ripple + P3 hardcoded colors + inline shapes
Avatar/Account switcher: - Rewrite SidebarAccountHeader with same shape/hover as other nav items - Entire row (avatar + display name) is clickable with rounded clip - Inline DropdownMenu replaces overlaid AccountSwitcherDropdown - Collapsed: compact avatar with same rounded hover treatment P3 #004 — Hardcoded status colors: - Add StatusGreen/StatusRed/StatusAmber to commons Colors.kt - Replace 32 inline Color() values across 10 files with theme tokens - Color.Red → MaterialTheme.colorScheme.error where appropriate - Color.Green/Gray → StatusGreen/onSurfaceVariant P3 #005 — Inline shapes: - RoundedCornerShape(8.dp) → MaterialTheme.shapes.small (~20 files) - RoundedCornerShape(12.dp) → MaterialTheme.shapes.medium - RoundedCornerShape(16.dp) → MaterialTheme.shapes.large - Pill shapes (100dp/999dp) kept as-is Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -75,6 +75,15 @@ val DarkWarningColorOnSecondSurface = Color(0xFFE1C419)
|
||||
val LightAllGoodColor = Color(0xFF339900)
|
||||
val DarkAllGoodColor = Color(0xFF99cc33)
|
||||
|
||||
// Semantic status colors for desktop
|
||||
val StatusGreen = Color(0xFF4CAF50)
|
||||
val StatusGreenDark = Color(0xFF81C784)
|
||||
val StatusRed = Color(0xFFF44336)
|
||||
val StatusRedDark = Color(0xFFEF9A9A)
|
||||
val StatusAmber = Color(0xFFFFB300)
|
||||
val StatusAmberDark = Color(0xFFFFD54F)
|
||||
val StatusBlue = Color(0xFF2196F3)
|
||||
|
||||
// Fundraiser colors
|
||||
val LightFundraiserProgressColor = Color(0xFF3DB601)
|
||||
val DarkFundraiserProgressColor = Color(0xFF61A229)
|
||||
|
||||
@@ -36,7 +36,6 @@ 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.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -220,7 +219,7 @@ fun ComposeNoteDialog(
|
||||
.dragAndDropTarget(shouldStartDragAndDrop = { true }, target = dropTarget)
|
||||
.then(
|
||||
if (isDragOver) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(12.dp))
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.medium)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
|
||||
@@ -43,12 +43,14 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusAmber
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import java.awt.Toolkit
|
||||
import java.awt.datatransfer.StringSelection
|
||||
@@ -70,11 +72,11 @@ fun DevSettingsSection(
|
||||
.fillMaxWidth()
|
||||
.border(
|
||||
width = 2.dp,
|
||||
color = Color(0xFFFF9800), // Orange warning color
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = StatusAmber,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
).background(
|
||||
color = Color(0xFF332200), // Dark orange tint
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
).padding(16.dp),
|
||||
) {
|
||||
// Warning header
|
||||
@@ -85,13 +87,13 @@ fun DevSettingsSection(
|
||||
Icon(
|
||||
MaterialSymbols.Warning,
|
||||
contentDescription = "Warning",
|
||||
tint = Color(0xFFFF9800),
|
||||
tint = StatusAmber,
|
||||
)
|
||||
Text(
|
||||
"Developer Settings",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFFFF9800),
|
||||
color = StatusAmber,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -105,7 +107,7 @@ fun DevSettingsSection(
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
HorizontalDivider(color = Color(0xFFFF9800).copy(alpha = 0.3f))
|
||||
HorizontalDivider(color = StatusAmber.copy(alpha = 0.3f))
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
@@ -114,7 +116,7 @@ fun DevSettingsSection(
|
||||
onClick = { showKeys = !showKeys },
|
||||
colors =
|
||||
ButtonDefaults.outlinedButtonColors(
|
||||
contentColor = Color(0xFFFF9800),
|
||||
contentColor = StatusAmber,
|
||||
),
|
||||
) {
|
||||
Text(if (showKeys) "Hide Keys" else "Show Keys")
|
||||
@@ -180,7 +182,7 @@ private fun KeyRow(
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color =
|
||||
if (isSensitive) {
|
||||
Color(0xFFFF5252)
|
||||
StatusRed
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
@@ -196,7 +198,7 @@ private fun KeyRow(
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor =
|
||||
if (copiedRecently) {
|
||||
Color(0xFF4CAF50)
|
||||
StatusGreen
|
||||
} else {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
},
|
||||
@@ -222,7 +224,7 @@ private fun KeyRow(
|
||||
),
|
||||
color =
|
||||
if (isSensitive) {
|
||||
Color(0xFFFF5252)
|
||||
StatusRed
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
@@ -230,7 +232,7 @@ private fun KeyRow(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = Color(0xFF1A1A1A),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
).padding(8.dp),
|
||||
)
|
||||
|
||||
@@ -44,13 +44,13 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.login_subtitle_desktop
|
||||
import com.vitorpamplona.amethyst.commons.resources.login_title
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
|
||||
@@ -203,7 +203,7 @@ fun ConnectingRelaysScreen(
|
||||
Icon(
|
||||
MaterialSymbols.Check,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF4CAF50),
|
||||
tint = StatusGreen,
|
||||
modifier = Modifier.size(14.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
@@ -107,7 +106,7 @@ fun LoginCard(
|
||||
@Suppress("DEPRECATION")
|
||||
PrimaryTabRow(
|
||||
selectedTabIndex = selectedTab,
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)),
|
||||
modifier = Modifier.fillMaxWidth().clip(MaterialTheme.shapes.small),
|
||||
) {
|
||||
tabs.forEachIndexed { index, title ->
|
||||
Tab(
|
||||
|
||||
@@ -34,10 +34,10 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
|
||||
import com.vitorpamplona.amethyst.desktop.account.LoginProgress
|
||||
import com.vitorpamplona.amethyst.desktop.account.RelayLoginStatus
|
||||
|
||||
@@ -111,7 +111,7 @@ private fun StepRow(step: StepInfo) {
|
||||
Icon(
|
||||
MaterialSymbols.Check,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF4CAF50),
|
||||
tint = StatusGreen,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
}
|
||||
@@ -133,7 +133,7 @@ private fun StepRow(step: StepInfo) {
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color =
|
||||
when (step.state) {
|
||||
StepState.DONE -> Color(0xFF4CAF50)
|
||||
StepState.DONE -> StatusGreen
|
||||
StepState.ACTIVE -> MaterialTheme.colorScheme.onSurface
|
||||
StepState.PENDING -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
|
||||
},
|
||||
@@ -155,7 +155,7 @@ private fun RelayRow(
|
||||
Icon(
|
||||
MaterialSymbols.Check,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF2196F3),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
@@ -164,7 +164,7 @@ private fun RelayRow(
|
||||
Icon(
|
||||
MaterialSymbols.Check,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFF4CAF50),
|
||||
tint = StatusGreen,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
@@ -206,7 +206,7 @@ private fun RelayRow(
|
||||
}
|
||||
|
||||
RelayLoginStatus.EVENT_SENT -> {
|
||||
Color(0xFF2196F3)
|
||||
MaterialTheme.colorScheme.primary
|
||||
}
|
||||
|
||||
else -> {
|
||||
|
||||
@@ -33,7 +33,6 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -76,7 +75,7 @@ fun NewKeyWarningCard(
|
||||
Text(
|
||||
stringResource(Res.string.new_key_warning_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.Red,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
@@ -102,7 +101,7 @@ fun NewKeyWarningCard(
|
||||
Text(
|
||||
stringResource(Res.string.new_key_secret_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.Red,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
SelectableKeyText(secretKey)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -170,7 +169,7 @@ fun ChatFileAttachment(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
contentScale = ContentScale.FillWidth,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -203,7 +203,7 @@ fun ChatPane(
|
||||
target = dropTarget,
|
||||
).then(
|
||||
if (isDragOver) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(8.dp))
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.small)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
@@ -657,7 +657,7 @@ private suspend fun sendWrappedReaction(
|
||||
@Composable
|
||||
private fun ReactionBar(onReaction: (String) -> Unit) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
shadowElevation = 2.dp,
|
||||
tonalElevation = 2.dp,
|
||||
|
||||
@@ -398,7 +398,7 @@ fun AppDrawer(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
) { /* consume click — prevent propagation to scrim */ },
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
tonalElevation = 8.dp,
|
||||
) {
|
||||
Column {
|
||||
@@ -579,7 +579,7 @@ private fun DrawerScreenCard(
|
||||
showMenu = true
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
tonalElevation = if (isSelected) 8.dp else 2.dp,
|
||||
color =
|
||||
if (isSelected) {
|
||||
@@ -792,7 +792,7 @@ private fun WorkspaceCard(
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clickable(onClick = onSwitch),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
tonalElevation = if (isActive) 8.dp else 2.dp,
|
||||
color =
|
||||
if (isActive) {
|
||||
@@ -869,7 +869,7 @@ private fun AddWorkspaceCard(
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
.clickable(enabled = enabled, onClick = onClick),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
tonalElevation = 1.dp,
|
||||
) {
|
||||
Row(
|
||||
@@ -935,7 +935,7 @@ private fun WorkspaceEditorDialog(
|
||||
WorkspaceIcons.availableNames.forEach { iName ->
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp).clickable { iconName = iName },
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color =
|
||||
if (isSelected(iName)) {
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
|
||||
@@ -42,6 +42,8 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -56,10 +58,12 @@ import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.PointerEventType
|
||||
import androidx.compose.ui.input.pointer.onPointerEvent
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.domain.nip46.SignerConnectionState
|
||||
import com.vitorpamplona.amethyst.commons.feeds.custom.FeedDefinition
|
||||
@@ -73,7 +77,6 @@ import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
|
||||
import com.vitorpamplona.amethyst.desktop.DesktopPreferences
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop
|
||||
import com.vitorpamplona.amethyst.desktop.ui.account.AccountSwitcherDropdown
|
||||
import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
@@ -275,6 +278,7 @@ fun MainSidebar(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun SidebarAccountHeader(
|
||||
activeNpub: String?,
|
||||
@@ -288,59 +292,58 @@ private fun SidebarAccountHeader(
|
||||
onAddAccount: () -> Unit,
|
||||
onRemoveAccount: (String) -> Unit,
|
||||
) {
|
||||
if (!expanded) {
|
||||
// Collapsed: just the account switcher dropdown (icon-only)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AccountSwitcherDropdown(
|
||||
activeNpub = activeNpub,
|
||||
allAccounts = allAccounts,
|
||||
localCache = localCache,
|
||||
onSwitchAccount = onSwitchAccount,
|
||||
onAddAccount = onAddAccount,
|
||||
onRemoveAccount = onRemoveAccount,
|
||||
)
|
||||
var showDropdown by remember { mutableStateOf(false) }
|
||||
var isHovered by remember { mutableStateOf(false) }
|
||||
|
||||
val hoverBg =
|
||||
if (isHovered) {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
} else {
|
||||
// Expanded: avatar + name + npub, tappable to open account switcher
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Avatar
|
||||
if (pubkeyHex != null) {
|
||||
UserAvatar(
|
||||
userHex = pubkeyHex,
|
||||
pictureUrl = avatarUrl,
|
||||
size = 40.dp,
|
||||
contentDescription = "Account avatar",
|
||||
|
||||
Box {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.clickable { showDropdown = true }
|
||||
.background(hoverBg)
|
||||
.onPointerEvent(PointerEventType.Enter) { isHovered = true }
|
||||
.onPointerEvent(PointerEventType.Exit) { isHovered = false }
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Avatar
|
||||
if (pubkeyHex != null) {
|
||||
UserAvatar(
|
||||
userHex = pubkeyHex,
|
||||
pictureUrl = avatarUrl,
|
||||
size = if (expanded) 32.dp else 28.dp,
|
||||
contentDescription = "Account avatar",
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(if (expanded) 32.dp else 28.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.Person,
|
||||
contentDescription = "Account",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
MaterialSymbols.Person,
|
||||
contentDescription = "Account",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
Spacer(Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = displayName ?: "Account",
|
||||
@@ -360,16 +363,42 @@ private fun SidebarAccountHeader(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay the dropdown so clicking opens it
|
||||
AccountSwitcherDropdown(
|
||||
activeNpub = activeNpub,
|
||||
allAccounts = allAccounts,
|
||||
localCache = localCache,
|
||||
onSwitchAccount = onSwitchAccount,
|
||||
onAddAccount = onAddAccount,
|
||||
onRemoveAccount = onRemoveAccount,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
// Account switcher dropdown menu
|
||||
DropdownMenu(
|
||||
expanded = showDropdown,
|
||||
onDismissRequest = { showDropdown = false },
|
||||
offset = DpOffset(x = if (expanded) 240.dp else 56.dp, y = 0.dp),
|
||||
) {
|
||||
allAccounts.forEach { account ->
|
||||
val isActive = account.npub == activeNpub
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = resolveDisplayNameForSidebar(account.npub, localCache) ?: npubShortForSidebar(account.npub),
|
||||
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
showDropdown = false
|
||||
onSwitchAccount(account.npub)
|
||||
},
|
||||
trailingIcon = {
|
||||
if (isActive) {
|
||||
Icon(MaterialSymbols.Check, contentDescription = "Active", modifier = Modifier.size(16.dp))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("Add Account") },
|
||||
onClick = {
|
||||
showDropdown = false
|
||||
onAddAccount()
|
||||
},
|
||||
leadingIcon = { Icon(MaterialSymbols.Add, contentDescription = null, modifier = Modifier.size(16.dp)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -532,3 +561,16 @@ private fun npubShortForSidebar(npub: String): String {
|
||||
if (npub.length <= 20) return npub
|
||||
return "${npub.take(10)}...${npub.takeLast(6)}"
|
||||
}
|
||||
|
||||
private fun resolveDisplayNameForSidebar(
|
||||
npub: String,
|
||||
localCache: DesktopLocalCache?,
|
||||
): String? {
|
||||
if (localCache == null) return null
|
||||
val pubkeyHex =
|
||||
com.vitorpamplona.quartz.nip19Bech32
|
||||
.decodePublicKeyAsHexOrNull(npub) ?: return null
|
||||
val user = localCache.getUserIfExists(pubkeyHex) ?: return null
|
||||
val name = user.toBestDisplayName()
|
||||
return name.takeIf { it != user.pubkeyDisplayHex() }
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ 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.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -302,7 +301,7 @@ private fun FeedRow(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onSelect),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
tonalElevation = 1.dp,
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Slider
|
||||
@@ -59,7 +58,7 @@ fun AudioPlayer(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -111,7 +111,7 @@ fun DesktopVideoPlayer(
|
||||
.height(constrainedHeight)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
RoundedCornerShape(8.dp),
|
||||
MaterialTheme.shapes.small,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -123,7 +123,7 @@ fun DesktopVideoPlayer(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -421,7 +420,7 @@ fun LightboxOverlay(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(16.dp)
|
||||
.background(Color.Black.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
|
||||
.background(Color.Black.copy(alpha = 0.5f), MaterialTheme.shapes.large)
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ fun PictureDisplay(
|
||||
.heightIn(max = 500.dp)
|
||||
.clip(
|
||||
if (index == 0 && title == null && description.isBlank()) {
|
||||
RoundedCornerShape(8.dp)
|
||||
MaterialTheme.shapes.small
|
||||
} else if (index == 0) {
|
||||
RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
|
||||
} else {
|
||||
|
||||
@@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -171,7 +170,7 @@ fun DesktopRichTextViewer(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.heightIn(max = 300.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.then(
|
||||
if (callbacks.onImageClick != null) {
|
||||
Modifier.clickable { callbacks.onImageClick.invoke(urls, index) }
|
||||
@@ -349,7 +348,7 @@ private fun RenderSegment(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
@@ -362,7 +361,7 @@ private fun RenderSegment(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
@@ -528,7 +527,7 @@ fun EditProfileContent(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.border(
|
||||
width = if (isBannerDragOver) 2.dp else 1.dp,
|
||||
color =
|
||||
@@ -537,7 +536,7 @@ fun EditProfileContent(
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
shape = MaterialTheme.shapes.small,
|
||||
).clickable { onPickBanner() }
|
||||
.dragAndDropTarget(
|
||||
shouldStartDragAndDrop = { true },
|
||||
|
||||
@@ -31,8 +31,8 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusAmber
|
||||
|
||||
/**
|
||||
* Card displaying Nostr account key information.
|
||||
@@ -57,7 +57,7 @@ fun ProfileInfoCard(
|
||||
Text(
|
||||
"Read-only mode",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.Yellow,
|
||||
color = StatusAmber,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
|
||||
@@ -35,11 +35,12 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed
|
||||
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
|
||||
import com.vitorpamplona.amethyst.desktop.ui.tor.LocalTorState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
|
||||
@@ -73,9 +74,9 @@ fun RelayStatusCard(
|
||||
) {
|
||||
val statusColor =
|
||||
when {
|
||||
status.connected -> Color.Green
|
||||
status.error != null -> Color.Red
|
||||
else -> Color.Gray
|
||||
status.connected -> StatusGreen
|
||||
status.error != null -> StatusRed
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
|
||||
if (status.connected) {
|
||||
@@ -114,9 +115,9 @@ fun RelayStatusCard(
|
||||
val torState = LocalTorState.current
|
||||
val badgeColor =
|
||||
if (torState.status is TorServiceStatus.Off) {
|
||||
Color(0xFFF44336) // Red — Tor required but off
|
||||
StatusRed // Tor required but off
|
||||
} else {
|
||||
Color(0xFF4CAF50) // Green — routed via Tor
|
||||
StatusGreen // routed via Tor
|
||||
}
|
||||
val badgeText =
|
||||
if (torState.status is TorServiceStatus.Off) "Requires Tor" else "via Tor"
|
||||
@@ -138,7 +139,7 @@ fun RelayStatusCard(
|
||||
Text(
|
||||
error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = Color.Red.copy(alpha = 0.8f),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.AssistChip
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -79,7 +78,7 @@ fun AdvancedSearchPanel(
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
|
||||
@@ -57,10 +57,11 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.ServerHealthCheck
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -263,9 +264,9 @@ private fun ServerRow(
|
||||
shape = CircleShape,
|
||||
color =
|
||||
when (status) {
|
||||
ServerHealthCheck.ServerStatus.ONLINE -> Color(0xFF4CAF50)
|
||||
ServerHealthCheck.ServerStatus.OFFLINE -> Color(0xFFF44336)
|
||||
ServerHealthCheck.ServerStatus.UNKNOWN -> Color(0xFF9E9E9E)
|
||||
ServerHealthCheck.ServerStatus.ONLINE -> StatusGreen
|
||||
ServerHealthCheck.ServerStatus.OFFLINE -> StatusRed
|
||||
ServerHealthCheck.ServerStatus.UNKNOWN -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -33,12 +33,14 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorServiceStatus
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusAmber
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusGreen
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.StatusRed
|
||||
|
||||
/**
|
||||
* Small shield icon showing Tor connection status in the sidebar footer.
|
||||
@@ -54,19 +56,19 @@ fun TorStatusIndicator(
|
||||
val (icon, tint, tooltip) =
|
||||
when (status) {
|
||||
is TorServiceStatus.Off -> {
|
||||
Triple(MaterialSymbols.Shield, Color.Gray, "Tor: Off")
|
||||
Triple(MaterialSymbols.Shield, MaterialTheme.colorScheme.onSurfaceVariant, "Tor: Off")
|
||||
}
|
||||
|
||||
is TorServiceStatus.Connecting -> {
|
||||
Triple(MaterialSymbols.Shield, Color(0xFFFFB300), "Tor: Connecting...")
|
||||
Triple(MaterialSymbols.Shield, StatusAmber, "Tor: Connecting...")
|
||||
}
|
||||
|
||||
is TorServiceStatus.Active -> {
|
||||
Triple(MaterialSymbols.Shield, Color(0xFF4CAF50), "Tor: Connected")
|
||||
Triple(MaterialSymbols.Shield, StatusGreen, "Tor: Connected")
|
||||
}
|
||||
|
||||
is TorServiceStatus.Error -> {
|
||||
Triple(MaterialSymbols.Shield, Color(0xFFF44336), "Tor: ${status.message}")
|
||||
Triple(MaterialSymbols.Shield, StatusRed, "Tor: ${status.message}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -631,7 +630,7 @@ private fun SendDialog(
|
||||
Dialog(onDismissRequest = { if (!isLoading) onDismiss() }) {
|
||||
Card(
|
||||
modifier = Modifier.width(480.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp)) {
|
||||
// Header
|
||||
@@ -849,7 +848,7 @@ private fun ReceiveDialog(
|
||||
Dialog(onDismissRequest = { if (!isGenerating) onDismiss() }) {
|
||||
Card(
|
||||
modifier = Modifier.width(400.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(24.dp)) {
|
||||
// Header: title + close X
|
||||
|
||||
220
docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md
Normal file
220
docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md
Normal file
@@ -0,0 +1,220 @@
|
||||
---
|
||||
title: "fix: macOS bundled VLC discovery broken by versioned setenv symbol"
|
||||
type: fix
|
||||
status: active
|
||||
date: 2026-05-18
|
||||
deepened: 2026-05-18
|
||||
origin: docs/brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md
|
||||
---
|
||||
|
||||
# fix: macOS bundled VLC discovery broken by versioned setenv symbol
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-05-18
|
||||
**Research agents used:** VLC --plugin-path audit, macOS JNA symbol research, VlcjPlayerPool init flow audit
|
||||
|
||||
### Key Improvements from Research
|
||||
1. `--plugin-path` factory arg is unreliable — VLC needs `VLC_PLUGIN_PATH` env var set before `libvlc_new()`
|
||||
2. `--reset-plugins-cache` is VLC CLI-only, not available via libvlc/vlcj factory args
|
||||
3. Simpler fix: replace `LibC.INSTANCE.setenv()` with JNA-free env var setting
|
||||
|
||||
## Overview
|
||||
|
||||
Bundled VLC video playback is broken on macOS release builds.
|
||||
`MacOsVlcDiscoverer.setPluginPath()` calls `LibC.INSTANCE.setenv()` via JNA,
|
||||
but macOS 13+ uses versioned C symbols (`setenv$3b99ba0d`) that JNA's `dlsym`
|
||||
cannot resolve. Without system VLC installed, video is completely unavailable.
|
||||
|
||||
(See brainstorm: `docs/brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md`)
|
||||
|
||||
## Problem Statement
|
||||
|
||||
```
|
||||
VLC: bundled discovery threw Error looking up function 'setenv$3b99ba0d':
|
||||
dlsym(0x..., setenv$3b99ba0d): symbol not found
|
||||
VLC: init failed — ...DiscoveryDirectoryProvider: Provider ...not found
|
||||
```
|
||||
|
||||
- Affects all macOS DMG users without system VLC.app installed
|
||||
- Audio playback works (separate factory, no plugin path needed for audio codecs)
|
||||
- Graceful degradation exists (shows "Install VLC" message) but shouldn't be needed
|
||||
|
||||
## Proposed Solution (Updated from Research)
|
||||
|
||||
**Replace `LibC.INSTANCE.setenv()` with a JNA-free approach to set the env var.**
|
||||
|
||||
VLC requires `VLC_PLUGIN_PATH` as a process-level environment variable BEFORE
|
||||
`libvlc_new()` (called by `MediaPlayerFactory`). The `--plugin-path` factory arg
|
||||
is NOT reliably forwarded to the plugin scanner. So we must set the actual env var,
|
||||
just without using JNA's broken `LibC.setenv` binding.
|
||||
|
||||
### Approach: Use JNA's lower-level Native.getLibrary to call setenv directly
|
||||
|
||||
Instead of `LibC.INSTANCE.setenv()` which goes through vlcj's `LibC` interface
|
||||
binding (which triggers the versioned symbol lookup), use JNA's `Function.getFunction`
|
||||
to call `setenv` from libc directly without the problematic interface mapping:
|
||||
|
||||
```kotlin
|
||||
override fun setPluginPath(pluginPath: String?): Boolean {
|
||||
if (pluginPath == null) return false
|
||||
return try {
|
||||
// Call setenv directly via JNA Function API, bypassing LibC interface
|
||||
// which fails on macOS 13+ due to versioned symbol lookup
|
||||
val setenv = com.sun.jna.Function.getFunction("c", "setenv")
|
||||
setenv.invokeInt(arrayOf(PLUGIN_ENV_NAME, pluginPath, 1)) == 0
|
||||
} catch (e: Throwable) {
|
||||
// Fallback: set as JVM system property for factory arg approach
|
||||
System.setProperty("vlc.plugin.path", pluginPath)
|
||||
true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**If that still hits the symbol issue**, the alternative fallback is:
|
||||
|
||||
```kotlin
|
||||
override fun setPluginPath(pluginPath: String?): Boolean {
|
||||
if (pluginPath == null) return false
|
||||
// Store for VlcjPlayerPool to pass as --plugin-path factory arg
|
||||
discoveredPluginPath = pluginPath
|
||||
return true
|
||||
}
|
||||
```
|
||||
|
||||
Then in `VlcjPlayerPool.init()`, pass `--plugin-path` to both factories as belt-and-suspenders.
|
||||
|
||||
### Plugin Cache Fix
|
||||
|
||||
Since `--reset-plugins-cache` is VLC CLI-only (not available via libvlc), fix stale
|
||||
cache by deleting the cache file before factory creation:
|
||||
|
||||
```kotlin
|
||||
// Delete stale VLC plugin cache before factory init
|
||||
val cacheDir = File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc")
|
||||
cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() }
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### File 1: `MacOsVlcDiscoverer.kt`
|
||||
|
||||
**Current** (line 55):
|
||||
```kotlin
|
||||
override fun setPluginPath(pluginPath: String?): Boolean =
|
||||
LibC.INSTANCE.setenv(PLUGIN_ENV_NAME, pluginPath, 1) == 0
|
||||
```
|
||||
|
||||
**Change to:**
|
||||
```kotlin
|
||||
var discoveredPluginPath: String? = null
|
||||
private set
|
||||
|
||||
override fun setPluginPath(pluginPath: String?): Boolean {
|
||||
if (pluginPath == null) return false
|
||||
discoveredPluginPath = pluginPath
|
||||
return try {
|
||||
// Direct JNA Function call bypasses vlcj's LibC interface binding
|
||||
// which fails on macOS 13+ (versioned symbol setenv$3b99ba0d)
|
||||
val setenv = com.sun.jna.Function.getFunction("c", "setenv")
|
||||
setenv.invokeInt(arrayOf(PLUGIN_ENV_NAME, pluginPath, 1)) == 0
|
||||
} catch (_: Throwable) {
|
||||
// If JNA call fails, store path for --plugin-path fallback
|
||||
false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Remove import: `uk.co.caprica.vlcj.binding.lib.LibC`
|
||||
|
||||
### File 2: `VlcjPlayerPool.kt`
|
||||
|
||||
**Changes to `init()` (lines 77-113):**
|
||||
|
||||
1. Hold reference to `MacOsVlcDiscoverer` for plugin path access:
|
||||
```kotlin
|
||||
val macOsDiscoverer = MacOsVlcDiscoverer()
|
||||
val nd = NativeDiscovery(BundledVlcDiscoverer(), macOsDiscoverer)
|
||||
```
|
||||
|
||||
2. Build factory args with plugin path fallback:
|
||||
```kotlin
|
||||
val factoryArgs = mutableListOf("--no-xlib")
|
||||
|
||||
// If setenv failed, pass --plugin-path as fallback
|
||||
val pluginPath = macOsDiscoverer.discoveredPluginPath
|
||||
?: System.getProperty("vlc.plugin.path")
|
||||
?: VlcResourceResolver.findVlcDir()?.let { "${it.absolutePath}/plugins" }
|
||||
|
||||
if (pluginPath != null && !envVarSetSuccessfully) {
|
||||
factoryArgs += "--plugin-path=$pluginPath"
|
||||
}
|
||||
|
||||
val f = MediaPlayerFactory(*factoryArgs.toTypedArray())
|
||||
```
|
||||
|
||||
3. Delete stale plugin cache before factory creation (macOS only):
|
||||
```kotlin
|
||||
if ("mac" in System.getProperty("os.name").lowercase()) {
|
||||
val cacheDir = File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc")
|
||||
cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() }
|
||||
}
|
||||
```
|
||||
|
||||
4. Audio factory also gets plugin path if env var wasn't set:
|
||||
```kotlin
|
||||
val audioArgs = mutableListOf("--no-video", "--no-xlib")
|
||||
if (pluginPath != null && !envVarSetSuccessfully) {
|
||||
audioArgs += "--plugin-path=$pluginPath"
|
||||
}
|
||||
MediaPlayerFactory(*audioArgs.toTypedArray()).also { audioFactory = it }
|
||||
```
|
||||
|
||||
### File 3: `desktopApp/build.gradle.kts`
|
||||
|
||||
Add JVM property as ultimate fallback:
|
||||
```kotlin
|
||||
jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins"
|
||||
```
|
||||
|
||||
## Edge Cases from Research
|
||||
|
||||
| Edge Case | Handling |
|
||||
|-----------|----------|
|
||||
| `Function.getFunction("c", "setenv")` also fails on macOS | Caught by try/catch, falls back to `--plugin-path` factory arg |
|
||||
| `--plugin-path` not honored by VLC 3.0.20 | JVM property `-Dvlc.plugin.path` as ultimate fallback |
|
||||
| Audio factory created later without plugin path | Audio factory also receives `--plugin-path` if env var wasn't set |
|
||||
| `findVlcDir()` returns root, not plugins dir | Append `/plugins` when building `--plugin-path` value |
|
||||
| VLC plugin cache stale after VLC update | Cache deleted on startup before factory creation |
|
||||
| NativeDiscovery swallows MacOsVlcDiscoverer ref | Restructured to hold ref before passing to NativeDiscovery |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Video plays in release DMG on macOS without system VLC installed
|
||||
- [ ] No `setenv` symbol errors in console output
|
||||
- [ ] No stale plugin cache warnings on launch
|
||||
- [ ] Audio playback still works
|
||||
- [ ] Linux/Windows builds unaffected
|
||||
- [ ] `./gradlew :desktopApp:run` (debug) still works
|
||||
- [ ] Fallback chain works: direct setenv → --plugin-path → JVM property
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| `Function.getFunction("c", "setenv")` may also hit versioned symbol | Medium | Triple fallback: direct call → --plugin-path → JVM property |
|
||||
| `--plugin-path` ignored by some VLC builds | Low | Env var approach is primary, --plugin-path is fallback only |
|
||||
| Plugin cache deletion too aggressive | Low | Only deletes `plugins*` files in VLC cache dir, not other data |
|
||||
|
||||
## Sources
|
||||
|
||||
- **Origin brainstorm:** [docs/brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md](../brainstorms/2026-05-18-fix-macos-vlc-bundling-brainstorm.md)
|
||||
- **Research:** VLC requires `VLC_PLUGIN_PATH` env var before `libvlc_new()` — `--plugin-path` not reliably forwarded
|
||||
- **Research:** `--reset-plugins-cache` is CLI-only, not available via libvlc/vlcj
|
||||
- **Research:** `setPluginPath()` called DURING `nd.discover()`, before factory creation
|
||||
- **Research:** macOS 13+ versioned symbols affect JNA's `LibC` interface, but `Function.getFunction` may bypass it
|
||||
- `MacOsVlcDiscoverer.kt:55` — failing `setenv` call
|
||||
- `VlcjPlayerPool.kt:68-114` — init flow
|
||||
- `VlcResourceResolver.kt` — returns VLC root dir (not plugins subdir)
|
||||
- [JNA Issue #1423](https://github.com/java-native-access/jna/issues/1423) — macOS symbol resolution changes
|
||||
- [Guardsquare/proguard#460](https://github.com/Guardsquare/proguard/issues/460) — related ProGuard bytecode rewriting
|
||||
299
docs/plans/2026-05-22-feat-desktop-note-action-bar-ux-plan.md
Normal file
299
docs/plans/2026-05-22-feat-desktop-note-action-bar-ux-plan.md
Normal file
@@ -0,0 +1,299 @@
|
||||
---
|
||||
title: "feat: Desktop note action bar — long-press details + right-click customize"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-05-22
|
||||
origin: docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md
|
||||
deepened: 2026-05-22
|
||||
---
|
||||
|
||||
# Desktop Note Action Bar — Long-Press Details + Right-Click Customize
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-05-22
|
||||
**Sections enhanced:** 4
|
||||
**Research agents used:** compose-expert, desktop-expert, compose-modifier-and-layout-style, compose-side-effects
|
||||
|
||||
### Key Improvements from Research
|
||||
1. Use `sealed class ActivePopup` for mutually exclusive popup state
|
||||
2. Use `DropdownMenu` for simple option lists (emoji picker, repost), `Popup` for rich content (zap/reaction details)
|
||||
3. Preserve ripple by explicitly passing `indication = ripple(bounded = false, radius = 16.dp)` when replacing `IconButton`
|
||||
4. Mark `ZapReceipt` as `@Immutable`, consider `ImmutableList` for list params
|
||||
|
||||
### New Considerations Discovered
|
||||
- `Popup` creates a separate AWT window on JVM — always pass `PopupProperties(focusable = true)` for dismiss-on-click-outside
|
||||
- Modifier chain order: `combinedClickable` before `onPointerEvent` (ripple wraps full area)
|
||||
- Skip popup animations on desktop — instant feels right, `fadeIn(tween(100))` at most
|
||||
- Metadata loading in popup: key `LaunchedEffect` on note ID, not popup visibility
|
||||
|
||||
## Overview
|
||||
|
||||
Add long-press popups and right-click customization to the desktop note action bar. Currently click = action and right-click = custom zap dialog. After this change, every action icon supports three gestures: click (action), long-press (view details), right-click (customize).
|
||||
|
||||
(see brainstorm: docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md)
|
||||
|
||||
## Interaction Model
|
||||
|
||||
| Action | Click | Long Press (~500ms) | Right-Click |
|
||||
|--------|-------|---------------------|-------------|
|
||||
| Reply | Open reply composer | Open thread | — |
|
||||
| Like | React with + | Floating popup: who reacted + emoji | Emoji picker (DropdownMenu) |
|
||||
| Repost | Repost | — | Quote/Fork (DropdownMenu) |
|
||||
| Zap | Quick zap 21 sats | Floating popup: zap receipts | Custom zap dialog (existing) |
|
||||
| Bookmark | Public/private dialog | — | — |
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Phase 1: Long-Press Detection + Zap Receipts Popup
|
||||
|
||||
**Goal:** Add long-press to zap icon showing floating zap receipts popup.
|
||||
|
||||
#### Step 1.1: Pass `Note` to NoteActionsRow
|
||||
|
||||
Currently `NoteActionsRow` receives `zapReceipts: List<ZapReceipt> = emptyList()` — callers pass empty lists. The actual zap/reaction data lives on the `Note` object.
|
||||
|
||||
**Change:** Add `note: Note? = null` parameter to `NoteActionsRow`.
|
||||
|
||||
**Files:**
|
||||
- `desktopApp/src/jvmMain/.../ui/NoteActions.kt` — add `note: Note? = null` param
|
||||
- `desktopApp/src/jvmMain/.../ui/FeedScreen.kt` — pass `note` (already available)
|
||||
- Others keep default null (BookmarksScreen, ReadsScreen, ArticleReaderScreen)
|
||||
|
||||
#### Step 1.2: Popup State as Sealed Class
|
||||
|
||||
Replace individual boolean states with a single sealed class to ensure mutual exclusivity:
|
||||
|
||||
```kotlin
|
||||
sealed class ActivePopup {
|
||||
data object None : ActivePopup()
|
||||
data object ZapReceipts : ActivePopup()
|
||||
data object Reactions : ActivePopup()
|
||||
data object EmojiPicker : ActivePopup()
|
||||
data object RepostOptions : ActivePopup()
|
||||
}
|
||||
var activePopup by remember { mutableStateOf<ActivePopup>(ActivePopup.None) }
|
||||
```
|
||||
|
||||
#### Step 1.3: Replace `IconButton` with `combinedClickable` Box
|
||||
|
||||
Replace the zap `IconButton` with `Box` + `combinedClickable`. Preserve ripple explicitly.
|
||||
|
||||
```kotlin
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.combinedClickable(
|
||||
onClick = { /* quick zap */ },
|
||||
onLongClick = { activePopup = ActivePopup.ZapReceipts },
|
||||
indication = ripple(bounded = false, radius = 16.dp),
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
)
|
||||
.onPointerEvent(PointerEventType.Press) { pointerEvent ->
|
||||
if (pointerEvent.buttons.isSecondaryPressed) {
|
||||
showZapDialog = true
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(Zap, ...)
|
||||
}
|
||||
```
|
||||
|
||||
**Research insight:** `combinedClickable` before `onPointerEvent` in chain — ripple wraps full area, right-click handler sits inside.
|
||||
|
||||
#### Step 1.4: Zap Receipts Floating Popup
|
||||
|
||||
New composable using `Popup` + `ElevatedCard` (rich scrollable content):
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ZapReceiptsPopup(
|
||||
note: Note,
|
||||
localCache: DesktopLocalCache,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
Popup(
|
||||
alignment = Alignment.TopStart,
|
||||
offset = IntOffset(0, -popupHeightPx),
|
||||
onDismissRequest = onDismiss,
|
||||
properties = PopupProperties(focusable = true), // required for desktop dismiss
|
||||
) {
|
||||
ElevatedCard(
|
||||
modifier = Modifier.widthIn(max = 280.dp),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.heightIn(max = 300.dp)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
// Header: total sats
|
||||
// Sorted receipts: sender name + amount + message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Data access from Note:**
|
||||
```kotlin
|
||||
val zapEntries = note.zaps.mapNotNull { (request, receipt) ->
|
||||
val sender = request.author?.toBestDisplayName()
|
||||
?: request.event?.pubKey?.take(8)
|
||||
?: return@mapNotNull null
|
||||
val amount = (receipt?.event as? LnZapEvent)?.amount?.toLong()
|
||||
?: return@mapNotNull null
|
||||
Triple(sender, amount, request.event?.content?.ifBlank { null })
|
||||
}.sortedByDescending { it.second }
|
||||
```
|
||||
|
||||
**Metadata loading:** Use `LaunchedEffect(note.idHex)` to fetch unknown sender metadata. Coroutine auto-cancels when popup leaves composition.
|
||||
|
||||
**Empty state:** If `zapEntries` is empty, show "No zaps yet" text.
|
||||
|
||||
### Phase 2: Reactions Popup
|
||||
|
||||
#### Step 2.1: `combinedClickable` for Like Icon
|
||||
|
||||
Same pattern as zap — click = react, long-press = `activePopup = ActivePopup.Reactions`.
|
||||
|
||||
#### Step 2.2: Reactions Floating Popup
|
||||
|
||||
Same `Popup` + `ElevatedCard` pattern. Content grouped by emoji:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ReactionsPopup(
|
||||
note: Note,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
// note.reactions: Map<String, List<Note>>
|
||||
Popup(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = PopupProperties(focusable = true),
|
||||
) {
|
||||
ElevatedCard {
|
||||
Column(Modifier.verticalScroll(rememberScrollState()).heightIn(max = 300.dp)) {
|
||||
// Header: total count
|
||||
note.reactions.forEach { (emoji, reactionNotes) ->
|
||||
// Section: emoji + list of sender names
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Research insight:** Use `derivedStateOf` for the grouped reaction view to avoid recomposition on every upstream emission.
|
||||
|
||||
### Phase 3: Right-Click Emoji Picker
|
||||
|
||||
#### Step 3.1: Add right-click to Like Icon
|
||||
|
||||
Add `onPointerEvent` secondary press → `activePopup = ActivePopup.EmojiPicker`.
|
||||
|
||||
#### Step 3.2: Emoji Picker as DropdownMenu
|
||||
|
||||
Use `DropdownMenu` (not raw `Popup`) — matches existing `AccountSwitcherDropdown` pattern. Simple option list.
|
||||
|
||||
```kotlin
|
||||
DropdownMenu(
|
||||
expanded = activePopup is ActivePopup.EmojiPicker,
|
||||
onDismissRequest = { activePopup = ActivePopup.None },
|
||||
) {
|
||||
listOf("+", "\u2764\ufe0f", "\ud83e\udd19", "\ud83d\udd25", "\ud83d\udc40", "\ud83d\ude02").forEach { emoji ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(emoji, fontSize = 20.sp) },
|
||||
onClick = {
|
||||
activePopup = ActivePopup.None
|
||||
onReact(emoji)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Right-Click Repost Options
|
||||
|
||||
#### Step 4.1: Repost DropdownMenu
|
||||
|
||||
Right-click on repost icon → `DropdownMenu` with Quote / Fork options:
|
||||
|
||||
```kotlin
|
||||
DropdownMenu(
|
||||
expanded = activePopup is ActivePopup.RepostOptions,
|
||||
onDismissRequest = { activePopup = ActivePopup.None },
|
||||
) {
|
||||
DropdownMenuItem(text = { Text("Repost") }, onClick = { /* repost */ })
|
||||
DropdownMenuItem(text = { Text("Quote") }, onClick = { /* quote */ })
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Changes Summary
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `NoteActions.kt` | Add `note: Note?` param, `ActivePopup` sealed class, replace `IconButton` with `combinedClickable` for zap/like/repost, add `ZapReceiptsPopup`, `ReactionsPopup`, emoji picker, repost options. Mark `ZapReceipt` as `@Immutable`. |
|
||||
| `FeedScreen.kt` | Pass `note` to `NoteActionsRow` |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Long-press (~500ms) on zap icon shows floating popup with zap receipts (sender, amount, message)
|
||||
- [ ] Long-press on like icon shows floating popup with reactions grouped by emoji
|
||||
- [ ] Long-press on reply icon opens thread (same as click)
|
||||
- [ ] Right-click on zap icon opens custom zap dialog (existing behavior preserved)
|
||||
- [ ] Right-click on like icon opens emoji picker (DropdownMenu)
|
||||
- [ ] Right-click on repost icon shows Quote/Fork options (DropdownMenu)
|
||||
- [ ] Single click still works for all actions (zap, react, repost, reply, bookmark)
|
||||
- [ ] Popups are mutually exclusive (opening one closes others)
|
||||
- [ ] Popups dismiss on click outside (`PopupProperties(focusable = true)`)
|
||||
- [ ] Popups are scrollable when content exceeds 300dp
|
||||
- [ ] No crash when long-pressing on notes with 0 zaps/reactions (empty state)
|
||||
- [ ] Ripple preserved on all action icons
|
||||
- [ ] Compiles, spotless clean, tests pass
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Phase 1** — Zap long-press popup (highest value, proves the pattern)
|
||||
2. **Phase 2** — Reactions popup (same pattern, different data)
|
||||
3. **Phase 3** — Emoji picker (small addition)
|
||||
4. **Phase 4** — Repost options (nice-to-have)
|
||||
|
||||
## Technical Notes from Research
|
||||
|
||||
### Modifier Chain Order
|
||||
```
|
||||
Modifier
|
||||
.size(32.dp) // identity: fixed touch target
|
||||
.combinedClickable(...) // identity: click + long-press
|
||||
.onPointerEvent(Press) { ... } // identity: right-click
|
||||
```
|
||||
`combinedClickable` first (provides ripple/indication), `onPointerEvent` after.
|
||||
|
||||
### Popup vs DropdownMenu Decision
|
||||
| Content Type | API |
|
||||
|-------------|-----|
|
||||
| Rich scrollable content (zap receipts, reactions) | `Popup` + `ElevatedCard` |
|
||||
| Simple option list (emoji picker, repost options) | `DropdownMenu` + `DropdownMenuItem` |
|
||||
|
||||
### Desktop-Specific
|
||||
- `PopupProperties(focusable = true)` is **required** on JVM desktop for click-outside dismiss
|
||||
- `Popup` creates separate AWT window — can extend beyond parent window bounds (good)
|
||||
- Skip animations or use `fadeIn(tween(100))` at most — desktop users expect crisp/instant
|
||||
|
||||
### Side Effects in Popups
|
||||
- Metadata loading: `LaunchedEffect(note.idHex)` — auto-cancels when popup leaves composition
|
||||
- No `DisposableEffect` needed unless opening relay subscriptions
|
||||
- Use `derivedStateOf` for grouped reaction view
|
||||
|
||||
## Sources
|
||||
|
||||
- **Origin brainstorm:** [docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md](docs/brainstorms/2026-05-21-note-action-bar-ux-brainstorm.md) — interaction model, popup style, per-action behavior
|
||||
- Android `ReactionsRow.kt` — `combinedClickable`, `Popup`, animation patterns
|
||||
- Android `Note.kt` — `reactions: Map<String, List<Note>>`, `zaps: Map<Note, Note?>`
|
||||
- Desktop `NoteActions.kt` — `onPointerEvent` right-click, `ZapReceiptsDialog`
|
||||
- Desktop `AccountSwitcherDropdown.kt` — `DropdownMenu` scroll/height pattern
|
||||
- Desktop `ChatBubbleLayout.kt:139` — `combinedClickable` proven on JVM desktop
|
||||
@@ -0,0 +1,136 @@
|
||||
---
|
||||
title: "fix: Desktop note action counters, quote boost, and boost detail popup"
|
||||
type: fix
|
||||
status: active
|
||||
date: 2026-05-22
|
||||
---
|
||||
|
||||
# Fix Desktop Note Action Counters, Quote Boost, and Boost Detail Popup
|
||||
|
||||
## Problems
|
||||
|
||||
### 1. Counters show zero for reactions, zaps, replies, reposts
|
||||
|
||||
Counts are passed as **static Int/Long parameters** to `NoteActionsRow`. They're read once at render time from `note.countReactions()`, `note.zaps.size`, etc. When new events arrive via relay subscriptions, the FlowSet invalidates — triggering recomposition of FeedNoteCard — but the counts are re-read from the same snapshot. The actual issue is **timing**: interaction subscriptions (`requestInteractions()`) fire for visible notes, but events may arrive after the initial render.
|
||||
|
||||
Additionally, **reply subscriptions are missing** — `DesktopRelaySubscriptionsCoordinator.requestInteractions()` subscribes for kinds 7, 9735, 6 but NOT kind 1 (replies).
|
||||
|
||||
### 2. Quote boost does nothing
|
||||
|
||||
The "Quote" `DropdownMenuItem` onClick just copies a note link to clipboard. It doesn't open a compose dialog. `ComposeNoteDialog` has no `quote` parameter.
|
||||
|
||||
### 3. Boost long-press has no popup
|
||||
|
||||
Repost icon has no `onLongClick` handler — long-press does nothing. Should show who boosted.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Phase 1: Fix counters — make them reactive
|
||||
|
||||
**Root cause:** FeedNoteCard reads counts once as vals, passes them as params. Even though FlowSet observations are collected, the count vals are re-read from Note's mutable properties which DO update — but only if the recomposition actually re-reads them.
|
||||
|
||||
**Fix:** The FlowSet observation pattern is actually correct — collecting `flowSet.reactions.stateFlow` triggers recomposition which re-reads `note.countReactions()`. The problem may be that interaction events haven't arrived yet.
|
||||
|
||||
**Step 1.1: Add kind 1 (replies) to interaction subscriptions**
|
||||
|
||||
File: `desktopApp/src/jvmMain/.../subscriptions/DesktopRelaySubscriptionsCoordinator.kt`
|
||||
|
||||
In `requestInteractions()`, add kind 1 to the filter list:
|
||||
```kotlin
|
||||
Filter(kinds = listOf(1), tags = mapOf("e" to noteIds)), // Replies
|
||||
```
|
||||
|
||||
**Step 1.2: Verify flow collection triggers recomposition**
|
||||
|
||||
In `FeedScreen.kt`, the pattern is:
|
||||
```kotlin
|
||||
val reactionsState by flowSet.reactions.stateFlow.collectAsState()
|
||||
// ...
|
||||
val reactionCount = note.countReactions()
|
||||
```
|
||||
|
||||
This should work — `collectAsState()` triggers recomposition, which re-reads `countReactions()`. If it's not working, add `reactionsState` as a key to `remember`:
|
||||
```kotlin
|
||||
val reactionCount = remember(reactionsState) { note.countReactions() }
|
||||
```
|
||||
|
||||
### Phase 2: Fix quote boost
|
||||
|
||||
**Step 2.1: Add `quoteOf` parameter to ComposeNoteDialog**
|
||||
|
||||
File: `desktopApp/src/jvmMain/.../ui/ComposeNoteDialog.kt`
|
||||
|
||||
Add `quoteOf: Event? = null` parameter. When set, embed `nostr:${NEvent.create(event.id, event.pubKey, event.kind, relays)}` in the initial text and add "q" tag.
|
||||
|
||||
**Step 2.2: Wire Quote menu item to compose dialog**
|
||||
|
||||
File: `desktopApp/src/jvmMain/.../ui/NoteActions.kt`
|
||||
|
||||
Replace clipboard copy with opening ComposeNoteDialog:
|
||||
```kotlin
|
||||
var quoteEvent by remember { mutableStateOf<Event?>(null) }
|
||||
|
||||
// In Quote DropdownMenuItem onClick:
|
||||
quoteEvent = event
|
||||
activePopup = ActivePopup.None
|
||||
|
||||
// Render dialog:
|
||||
if (quoteEvent != null) {
|
||||
ComposeNoteDialog(quoteOf = quoteEvent, onDismiss = { quoteEvent = null }, ...)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2.3: Build quote event with "q" tag**
|
||||
|
||||
When composing, use TextNoteEvent builder with "q" tag:
|
||||
```kotlin
|
||||
TextNoteEvent.build(
|
||||
message = "$userMessage\nnostr:${NEvent.create(quotedEvent.id, ...)}",
|
||||
tags = arrayOf(arrayOf("q", quotedEvent.id, relayHint ?: "", quotedEvent.pubKey)),
|
||||
signer = signer,
|
||||
)
|
||||
```
|
||||
|
||||
### Phase 3: Add boost detail popup
|
||||
|
||||
**Step 3.1: Add long-press to repost icon**
|
||||
|
||||
Same `combinedClickable` pattern. Long-press sets `activePopup = ActivePopup.Boosts`.
|
||||
|
||||
**Step 3.2: Create BoostsPopup**
|
||||
|
||||
New composable showing who boosted:
|
||||
```kotlin
|
||||
@Composable
|
||||
fun BoostsPopup(note: Note, onDismiss: () -> Unit) {
|
||||
// note.boosts: List<Note>
|
||||
// Each boost Note has .author → display name
|
||||
Popup(properties = PopupProperties(focusable = true)) {
|
||||
ElevatedCard { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add `Boosts` to the `ActivePopup` sealed class.
|
||||
|
||||
## File Changes
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `DesktopRelaySubscriptionsCoordinator.kt` | Add kind 1 to `requestInteractions()` filter |
|
||||
| `FeedScreen.kt` | Ensure count reads are keyed on flow state |
|
||||
| `NoteActions.kt` | Wire quote to compose dialog, add boost long-press popup, add `ActivePopup.Boosts` |
|
||||
| `ComposeNoteDialog.kt` | Add `quoteOf: Event?` param, embed quote in text + "q" tag |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Reaction count updates when new reactions arrive via relay
|
||||
- [ ] Zap count/amount updates when new zaps arrive
|
||||
- [ ] Reply count shows and updates
|
||||
- [ ] Repost count shows and updates
|
||||
- [ ] Kind 1 replies subscribed in interaction filters
|
||||
- [ ] Quote menu item opens ComposeNoteDialog with quoted note
|
||||
- [ ] Quote posts include "q" tag and embedded nostr: URI
|
||||
- [ ] Long-press repost icon shows popup with who boosted
|
||||
- [ ] Empty state for boosts popup: "No reposts yet"
|
||||
- [ ] Compiles, spotless, tests pass
|
||||
246
docs/plans/2026-05-28-feat-desktop-search-spotlight-plan.md
Normal file
246
docs/plans/2026-05-28-feat-desktop-search-spotlight-plan.md
Normal file
@@ -0,0 +1,246 @@
|
||||
---
|
||||
title: "feat: Desktop Search Spotlight + Unified Feed Header Bar"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-05-28
|
||||
origin: docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md
|
||||
---
|
||||
|
||||
# feat: Desktop Search Spotlight + Unified Feed Header Bar
|
||||
|
||||
## Overview
|
||||
|
||||
Add a global search spotlight overlay (Cmd+F) and a unified feed header bar that combines feed tabs with a search pill. Also fix the bug where "+ Add more" feeds only works from the Home feed by moving it to the sidebar.
|
||||
|
||||
Three components:
|
||||
1. **SearchSpotlight** — global overlay with scrim, auto-focus, recent/saved searches, live results
|
||||
2. **FeedHeaderBar** — unified feed tabs (My Feed | Global | pinned custom) + search pill, used on every feed column
|
||||
3. **Sidebar "+ Add Feed"** — move from broken feed header to always-accessible sidebar
|
||||
|
||||
(see brainstorm: docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md)
|
||||
|
||||
## Problem Statement
|
||||
|
||||
1. **No quick search** — searching requires opening a full Search column. No spotlight/command-palette UX.
|
||||
2. **Feed tabs disconnected from search** — the screenshot reference shows tabs + search in a unified bar, but current feed columns have separate headers with no search pill.
|
||||
3. **"+ Add more" bug** — the button to add custom feeds only works on the Home feed; from Global or custom feeds it's broken/invisible.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### Search Spotlight (Cmd+F)
|
||||
|
||||
Global overlay that dims content (50% scrim), shows a centered search card (~600dp wide, 20% from top):
|
||||
- **Empty state:** Recent searches + saved searches from `SearchHistoryStore`
|
||||
- **Typing:** Live people + note results (reuse `AdvancedSearchBarState` with 300ms debounce)
|
||||
- **Result selection:** Opens new column (deck) or navigates (single-pane)
|
||||
- **"Open full search"** link at bottom → opens Search column with current query
|
||||
- **Keyboard:** Escape closes, arrow keys navigate results, Enter selects
|
||||
|
||||
### Unified Feed Header Bar
|
||||
|
||||
Replaces the current `ColumnHeader` on feed-type columns:
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ [My Feed] [Global] [Custom1] 🔍 Search.. ⌘F │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
- Left: feed tabs (Following + Global always, up to 3 pinned custom feeds)
|
||||
- Right: compact SearchPill that opens the spotlight
|
||||
- Active tab: `primary` color indicator
|
||||
- 48dp height, `surfaceContainer` background
|
||||
|
||||
### Sidebar "+ Add Feed"
|
||||
|
||||
Move from feed column header to MainSidebar's FEEDS section — always accessible.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 1: SearchSpotlight Composable
|
||||
|
||||
New file: `desktopApp/.../ui/search/SearchSpotlight.kt`
|
||||
|
||||
**Architecture:**
|
||||
- `Dialog` with custom `Surface` (not `AlertDialog` — need full layout control)
|
||||
- Scrim: `Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f)).clickable { onDismiss() })`
|
||||
- Search card: `Surface(shape = shapes.large, color = surface)` centered with `600.dp` max width
|
||||
- Input: `BasicTextField` with pill decoration (matching SearchScreen pattern), auto-focused via `FocusRequester`
|
||||
- State: Create `AdvancedSearchBarState(scope)` scoped to spotlight lifecycle. `DisposableEffect` stops subscriptions on close.
|
||||
- Results: `LazyColumn` with sections (People max 5, Notes max 5), each item clickable
|
||||
- History: Read from `SearchHistoryStore` on open
|
||||
|
||||
**Key composables:**
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SearchSpotlight(
|
||||
localCache: DesktopLocalCache,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator,
|
||||
account: AccountState.LoggedIn,
|
||||
searchHistoryStore: SearchHistoryStore,
|
||||
onSelectProfile: (String) -> Unit, // pubkey hex
|
||||
onSelectNote: (String) -> Unit, // note id hex
|
||||
onSelectHashtag: (String) -> Unit, // tag
|
||||
onOpenFullSearch: (String) -> Unit, // query text
|
||||
onDismiss: () -> Unit,
|
||||
)
|
||||
```
|
||||
|
||||
**Success criteria:**
|
||||
- [ ] Spotlight opens centered with scrim dimming background
|
||||
- [ ] Input auto-focused on open
|
||||
- [ ] Recent + saved searches shown before typing
|
||||
- [ ] Live people/note results appear while typing (300ms debounce)
|
||||
- [ ] Selecting result calls appropriate callback and closes
|
||||
- [ ] Escape closes spotlight
|
||||
- [ ] Arrow key navigation through results
|
||||
- [ ] "Open full search" opens Search column with query
|
||||
|
||||
#### Phase 2: SearchPill Composable
|
||||
|
||||
New file: `desktopApp/.../ui/search/SearchPill.kt`
|
||||
|
||||
Small reusable pill:
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SearchPill(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
```
|
||||
- Pill shape (999dp corners), `surfaceContainerHigh` background
|
||||
- Search icon + "Search..." + "⌘F" hint
|
||||
- Height: 36dp
|
||||
- Hover highlight via `hoverHighlight()` modifier
|
||||
|
||||
**Success criteria:**
|
||||
- [ ] Renders as compact pill with search icon and shortcut hint
|
||||
- [ ] Click opens spotlight
|
||||
- [ ] Hover highlights
|
||||
|
||||
#### Phase 3: FeedHeaderBar Composable
|
||||
|
||||
New file: `desktopApp/.../ui/search/FeedHeaderBar.kt`
|
||||
|
||||
Unified header for feed columns:
|
||||
```kotlin
|
||||
@Composable
|
||||
fun FeedHeaderBar(
|
||||
feedTabs: ImmutableList<FeedTab>,
|
||||
activeFeedId: String,
|
||||
onTabClick: (String) -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
|
||||
data class FeedTab(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val isBuiltIn: Boolean = false, // Following/Global are built-in
|
||||
)
|
||||
```
|
||||
|
||||
**Layout:**
|
||||
- `Row` with `surfaceContainer` background, 48dp height
|
||||
- Left: scrollable `Row` of tab chips/buttons
|
||||
- Right: `SearchPill` with fixed width
|
||||
- Active tab: filled with `primary`, others `onSurfaceVariant`
|
||||
- Divider at bottom: 1dp `outlineVariant`
|
||||
|
||||
**Feed tabs source:**
|
||||
- "My Feed" (Following) — always first, `id = "following"`
|
||||
- "Global" — always second, `id = "global"`
|
||||
- Pinned custom feeds from `LocalFeedRepository.current` (max 3)
|
||||
|
||||
**Integration:** Replace `ColumnHeader` for `DeckColumnType.HomeFeed`, `DeckColumnType.GlobalFeed`, and `DeckColumnType.CustomFeed` in `DeckColumnContainer.kt`.
|
||||
|
||||
**Success criteria:**
|
||||
- [ ] Feed tabs render with Following + Global + up to 3 pinned
|
||||
- [ ] Active tab highlighted with primary color
|
||||
- [ ] Tab click switches feed
|
||||
- [ ] Search pill visible on right side
|
||||
- [ ] Shown on all feed-type columns
|
||||
|
||||
#### Phase 4: Sidebar "+ Add Feed" + Keyboard Shortcut
|
||||
|
||||
**MainSidebar (DeckSidebar.kt):**
|
||||
- Add "+ Add Feed" item at bottom of FEEDS section
|
||||
- Click opens feed builder/drawer (existing `onOpenFeedsDrawer` callback)
|
||||
- Styled as subtle text link with `+` icon
|
||||
|
||||
**Main.kt:**
|
||||
- Add `Cmd+F` keyboard shortcut in MenuBar
|
||||
- Add `showSearchSpotlight` state
|
||||
- Render `SearchSpotlight` when `showSearchSpotlight` is true
|
||||
- Wire result callbacks to `deckState.addColumn()` / `singlePaneState.navigate()`
|
||||
|
||||
**FeedScreen.kt:**
|
||||
- Remove inline "+ Add more" button (now in sidebar)
|
||||
|
||||
**Success criteria:**
|
||||
- [ ] Cmd+F opens spotlight from anywhere in the app
|
||||
- [ ] "+ Add Feed" visible in sidebar FEEDS section
|
||||
- [ ] "+ Add Feed" works regardless of current feed view
|
||||
- [ ] Old "+ Add more" button removed from feed headers
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- [ ] Cmd+F opens search spotlight overlay with scrim
|
||||
- [ ] Spotlight shows recent + saved searches on open
|
||||
- [ ] Live search results (people + notes) with 300ms debounce
|
||||
- [ ] Selecting a profile opens it (new column in deck, navigate in single-pane)
|
||||
- [ ] Selecting a note opens thread
|
||||
- [ ] "Open full search" opens Search column with query
|
||||
- [ ] Escape closes spotlight
|
||||
- [ ] FeedHeaderBar shows feed tabs + search pill on all feed columns
|
||||
- [ ] Tab switching works (Following ↔ Global ↔ Custom feeds)
|
||||
- [ ] "+ Add Feed" in sidebar works from any screen
|
||||
- [ ] Keyboard arrow navigation through spotlight results
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- [ ] Spotlight opens in <100ms (no relay calls until user types)
|
||||
- [ ] Scrim renders at 60fps
|
||||
- [ ] Search results appear within 300ms of typing pause
|
||||
- [ ] Compiles on all platforms (macOS, Windows, Linux)
|
||||
- [ ] Spotless clean
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `AdvancedSearchBarState` (commons/) — reuse, no changes needed
|
||||
- `SearchHistoryStore` (desktop) — reuse, no changes needed
|
||||
- `SearchFilterFactory` (desktop) — reuse for relay subscriptions
|
||||
- `FeedDefinitionRepository` (commons/) — read pinned feeds for tabs
|
||||
- `MainSidebar` — add "+ Add Feed" item
|
||||
- `hoverHighlight()` modifier — already created in visual personality PR
|
||||
|
||||
## Files Affected
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| New: `desktopApp/.../ui/search/SearchSpotlight.kt` | Spotlight overlay |
|
||||
| New: `desktopApp/.../ui/search/SearchPill.kt` | Compact pill component |
|
||||
| New: `desktopApp/.../ui/search/FeedHeaderBar.kt` | Unified feed tabs + search |
|
||||
| `desktopApp/.../Main.kt` | Cmd+F shortcut, spotlight state, render, result callbacks |
|
||||
| `desktopApp/.../ui/deck/DeckColumnContainer.kt` | Use FeedHeaderBar for feed columns |
|
||||
| `desktopApp/.../ui/deck/DeckSidebar.kt` | Add "+ Add Feed" to FEEDS section |
|
||||
| `desktopApp/.../ui/FeedScreen.kt` | Remove "+ Add more" if present, adapt header |
|
||||
| `desktopApp/.../ui/deck/ColumnHeader.kt` | May need trailing slot for non-feed columns |
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **Brainstorm:** [docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md](docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md) — Key decisions: Cmd+F spotlight, unified FeedHeaderBar, sidebar "+ Add Feed"
|
||||
|
||||
### Internal References
|
||||
|
||||
- `SearchScreen.kt` — existing full search column (keep as-is)
|
||||
- `AdvancedSearchBarState.kt` — reusable search state management
|
||||
- `SearchHistoryStore.kt` — recent/saved search persistence
|
||||
- `SearchFilterFactory.kt` — NIP-50 relay filter construction
|
||||
- `FeedDefinitionRepository.kt` — pinned custom feeds source
|
||||
- `MainSidebar (DeckSidebar.kt)` — sidebar where "+ Add Feed" moves to
|
||||
751
docs/plans/2026-05-28-feat-desktop-visual-personality-plan.md
Normal file
751
docs/plans/2026-05-28-feat-desktop-visual-personality-plan.md
Normal file
@@ -0,0 +1,751 @@
|
||||
---
|
||||
title: "feat: Desktop Visual Personality Overhaul"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-05-28
|
||||
origin: docs/brainstorms/2026-05-27-desktop-visual-personality-brainstorm.md
|
||||
---
|
||||
|
||||
# feat: Desktop Visual Personality Overhaul
|
||||
|
||||
## Enhancement Summary
|
||||
|
||||
**Deepened on:** 2026-05-28
|
||||
**Research agents used:** compose-expert, desktop-expert, compose-stability-diagnostics, compose-modifier-and-layout-style, best-practices-researcher, color-audit-explorer
|
||||
|
||||
### Critical Fixes (from research)
|
||||
1. **`ColorScheme.isLight` doesn't exist in M3** — use `LocalIsDarkTheme` CompositionLocal instead
|
||||
2. **Hover modifier won't compile** — `@Composable` lambda can't invoke in `drawBehind`; `shape.topStart` invalid on generic Shape. Rewrite using `onPointerEvent` (codebase convention)
|
||||
3. **Sidebar animation thrashes column widths** — `fitColumnsToWidth()` fires every frame during 240→56dp transition. Add 300ms debounce
|
||||
4. **NoteCard/NoteCardSkeleton missing `modifier` parameter** — required for reusable composables
|
||||
5. **AccountSwitcher dropdown offset hardcoded to 48dp** — needs dynamic offset based on sidebar width
|
||||
|
||||
### Key Improvements
|
||||
- Use `onPointerEvent` for hover (project convention, not `composed{}`)
|
||||
- Add `clipToBounds()` on sidebar during animation
|
||||
- Add keyboard shortcuts: `Cmd/Ctrl+B` (sidebar toggle), `Cmd/Ctrl+K` (search focus)
|
||||
- Gate SegmentedButton on column width > 400dp
|
||||
- `remember` BorderStroke to avoid instance churn defeating skip optimization
|
||||
- ShimmerPlaceholder in `commons/commonMain` (pure M3, no platform APIs)
|
||||
- Use `LocalScrollbarStyle` for scrollbar customization (built-in API)
|
||||
- Add `LocalIsDarkTheme` CompositionLocal alongside `LocalSpacing`
|
||||
- SegmentedButton confirmed available and stable (already used in AppDrawer.kt)
|
||||
- 32 inline `Color()` literals to migrate across 10 files
|
||||
|
||||
### Color Audit Results
|
||||
- **32 inline Color() constructors** across 10 desktop files (status indicators: green/red/amber)
|
||||
- **48 `.copy(alpha=)` patterns** across 18 files (standardize with alpha constants)
|
||||
- **Key files:** DevSettingsSection (10), LoginProgressSteps (5), NamecoinSettingsSection (4), TorStatusIndicator (3)
|
||||
|
||||
## Overview
|
||||
|
||||
Transform Amethyst Desktop from an OS-native-adaptive look into a distinctly branded experience. Replace per-OS color schemes with a unified Amethyst identity (cyan/blue accent), redesign the sidebar to 240dp with labels, restyle cards to flat+border, add hover effects, skeleton loading, and polish all UI components.
|
||||
|
||||
Desktop-only scope — Android unchanged. (see brainstorm: Decision #5)
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current desktop theme adapts to each OS (macOS, GNOME, KDE, Windows) with per-OS colors, shapes, and fonts. While technically impressive, this makes the app visually neutral — it doesn't feel like "Amethyst." The 56dp icon-only sidebar wastes desktop screen space and hurts discoverability. Cards use shadow elevation that renders inconsistently on desktop JVM/Skia.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
A 6-phase implementation that progresses from theme foundation → spacing → sidebar → cards → column headers → polish. Each phase builds on the previous, and the app remains functional throughout.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture
|
||||
|
||||
The overhaul touches 3 layers:
|
||||
|
||||
1. **Theme layer** (`desktopApp/.../platform/`) — ColorScheme, Typography, Shapes
|
||||
2. **Layout layer** (`desktopApp/.../ui/deck/`) — Sidebar, ColumnHeader, DeckLayout
|
||||
3. **Component layer** (`desktopApp/.../ui/note/`, shared composables) — NoteCard, action bar, dialogs
|
||||
|
||||
New additions:
|
||||
- `AmethystSpacing` CompositionLocal for design tokens
|
||||
- `Modifier.hoverHighlight()` shared hover utility
|
||||
- `ShimmerPlaceholder` composable for loading states
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
---
|
||||
|
||||
#### Phase 1: Theme Foundation
|
||||
|
||||
**Goal:** Replace all per-OS color schemes with unified Amethyst brand. Unified shapes and typography weights.
|
||||
|
||||
**Files:**
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `desktopApp/.../platform/PlatformColorScheme.kt` | Replace 10 per-OS functions with `amethystLight()` + `amethystDark()` |
|
||||
| `desktopApp/.../platform/PlatformShapes.kt` | Replace per-OS shapes with unified tokens |
|
||||
| `desktopApp/.../platform/PlatformTypography.kt` | Standardize weight scale across all OS |
|
||||
| `commons/.../ui/theme/Colors.kt` | Add cyan/blue brand constants |
|
||||
|
||||
**Color Scheme (see brainstorm: Layer 1):**
|
||||
|
||||
```kotlin
|
||||
// commons/ui/theme/Colors.kt — new constants
|
||||
val AmethystBlue = Color(0xFF0096FF) // light primary
|
||||
val AmethystBlueDark = Color(0xFF4DB8FF) // dark primary
|
||||
val AmethystPurple = Color(0xFF9A82DB) // tertiary (heritage)
|
||||
|
||||
// PlatformColorScheme.kt — amethystLight()
|
||||
fun amethystLight() = lightColorScheme(
|
||||
primary = AmethystBlue, // #0096FF
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = AmethystBlue.copy(alpha = 0.12f).compositeOver(Color.White),
|
||||
onPrimaryContainer = AmethystBlue,
|
||||
secondary = Color(0xFF5E8FAD), // desaturated blue
|
||||
onSecondary = Color.White,
|
||||
tertiary = AmethystPurple, // heritage purple
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFFF2F2F7), // light gray
|
||||
onBackground = Color(0xFF1C1C1E),
|
||||
surface = Color.White,
|
||||
onSurface = Color(0xFF1C1C1E),
|
||||
surfaceVariant = Color(0xFFF0F0F5),
|
||||
onSurfaceVariant = Color(0xFF6E6E73),
|
||||
surfaceContainer = Color(0xFFF7F7FA),
|
||||
surfaceContainerHigh = Color(0xFFEEEEF2),
|
||||
surfaceContainerHighest = Color(0xFFE5E5EA),
|
||||
surfaceContainerLow = Color(0xFFFAFAFC),
|
||||
outline = Color(0xFFE0E0E0),
|
||||
outlineVariant = Color(0xFFEBEBEB),
|
||||
error = Color(0xFFBA1A1A),
|
||||
onError = Color.White,
|
||||
errorContainer = Color(0xFFFFDAD6),
|
||||
onErrorContainer = Color(0xFF410002),
|
||||
)
|
||||
|
||||
// amethystDark() — same structure with dark values
|
||||
fun amethystDark() = darkColorScheme(
|
||||
primary = AmethystBlueDark, // #4DB8FF
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = AmethystBlueDark.copy(alpha = 0.16f).compositeOver(Color(0xFF1E1E1E)),
|
||||
onPrimaryContainer = AmethystBlueDark,
|
||||
secondary = Color(0xFF7EAEC8),
|
||||
onSecondary = Color.White,
|
||||
tertiary = Color(0xFFB6A0E0), // lighter purple
|
||||
onTertiary = Color.White,
|
||||
background = Color(0xFF121212),
|
||||
onBackground = Color(0xFFE5E5EA),
|
||||
surface = Color(0xFF1E1E1E),
|
||||
onSurface = Color(0xFFE5E5EA),
|
||||
surfaceVariant = Color(0xFF2A2A2A),
|
||||
onSurfaceVariant = Color(0xFF9E9EA3),
|
||||
surfaceContainer = Color(0xFF252525),
|
||||
surfaceContainerHigh = Color(0xFF2E2E2E),
|
||||
surfaceContainerHighest = Color(0xFF383838),
|
||||
surfaceContainerLow = Color(0xFF1A1A1A),
|
||||
outline = Color(0xFF3A3A3A),
|
||||
outlineVariant = Color(0xFF2E2E2E),
|
||||
error = Color(0xFFFFB4AB),
|
||||
onError = Color(0xFF690005),
|
||||
errorContainer = Color(0xFF93000A),
|
||||
onErrorContainer = Color(0xFFFFDAD6),
|
||||
)
|
||||
```
|
||||
|
||||
**`resolve()` simplification:**
|
||||
```kotlin
|
||||
fun resolve(isDark: Boolean): ColorScheme =
|
||||
if (isDark) amethystDark() else amethystLight()
|
||||
```
|
||||
|
||||
Remove: `macOsLight()`, `macOsDark()`, `gnomeLight()`, `gnomeDark()`, `kdeLight()`, `kdeDark()`, `windowsLight()`, `windowsDark()`, `genericLight()`, `genericDark()`, `darkenForLight()`, accent parameter from `resolve()`. Keep `onAccent()` only if still needed elsewhere.
|
||||
|
||||
**Shapes (unified):**
|
||||
```kotlin
|
||||
// PlatformShapes.kt
|
||||
val current = Shapes(
|
||||
extraSmall = RoundedCornerShape(6.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp),
|
||||
extraLarge = RoundedCornerShape(24.dp),
|
||||
)
|
||||
```
|
||||
|
||||
Remove all per-OS shape functions.
|
||||
|
||||
**Typography weights:**
|
||||
- Display: `FontWeight.Light` (300)
|
||||
- Headline: `FontWeight.SemiBold` (600) — keep
|
||||
- Title: `FontWeight.SemiBold` / `FontWeight.Medium` — keep
|
||||
- Body: `FontWeight.Normal` — keep
|
||||
- Label: `FontWeight.Medium` — keep
|
||||
- Standardize letter spacing to `-0.3sp` for display/headline across all OS
|
||||
|
||||
Keep per-OS font family detection — fonts ARE platform-specific.
|
||||
|
||||
**PlatformTheme.kt changes:**
|
||||
- Remove accent resolution (`PlatformAccent.systemAccent()`)
|
||||
- Simplify to `PlatformColorScheme.resolve(isDark)` (no accent param)
|
||||
- Keep `titleBarInsetTop` and `applyNativeWindowChrome()`
|
||||
|
||||
**Migration:** Grep `desktopApp/` for inline `Color(0xFF...)` constructors and replace with `MaterialTheme.colorScheme.*` references where possible.
|
||||
|
||||
**Success criteria:**
|
||||
- [ ] App compiles with unified color scheme on all OS
|
||||
- [ ] Dark/light mode toggle works correctly
|
||||
- [ ] No per-OS color/shape variance remains
|
||||
- [ ] Typography weights follow standardized scale
|
||||
|
||||
---
|
||||
|
||||
#### Phase 2: Spacing System
|
||||
|
||||
**Goal:** Create a design token system for consistent spacing across all desktop UI.
|
||||
|
||||
**Files:**
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| New: `desktopApp/.../ui/theme/AmethystSpacing.kt` | Create spacing tokens + CompositionLocal |
|
||||
| `desktopApp/.../platform/PlatformTheme.kt` | Provide `LocalSpacing` |
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```kotlin
|
||||
// AmethystSpacing.kt
|
||||
@Immutable
|
||||
data class AmethystSpacing(
|
||||
val xxs: Dp = 2.dp,
|
||||
val xs: Dp = 4.dp,
|
||||
val sm: Dp = 8.dp,
|
||||
val md: Dp = 12.dp,
|
||||
val lg: Dp = 16.dp,
|
||||
val xl: Dp = 24.dp,
|
||||
val xxl: Dp = 32.dp,
|
||||
val cardPadding: Dp = 16.dp,
|
||||
val cardGap: Dp = 8.dp,
|
||||
val sidebarExpandedWidth: Dp = 240.dp,
|
||||
val sidebarCollapsedWidth: Dp = 56.dp,
|
||||
val columnHeaderHeight: Dp = 48.dp,
|
||||
)
|
||||
|
||||
val LocalSpacing = staticCompositionLocalOf { AmethystSpacing() }
|
||||
|
||||
val MaterialTheme.spacing: AmethystSpacing
|
||||
@Composable @ReadOnlyComposable
|
||||
get() = LocalSpacing.current
|
||||
```
|
||||
|
||||
**In PlatformTheme.kt:**
|
||||
```kotlin
|
||||
val LocalIsDarkTheme = staticCompositionLocalOf { false }
|
||||
|
||||
// In PlatformMaterialTheme:
|
||||
CompositionLocalProvider(
|
||||
LocalSpacing provides AmethystSpacing(),
|
||||
LocalIsDarkTheme provides isDark,
|
||||
) {
|
||||
MaterialTheme(colorScheme, typography, shapes, content)
|
||||
}
|
||||
```
|
||||
|
||||
> **Research insight:** `ColorScheme.isLight` does not exist in Material3 Compose (M2 only). Provide `LocalIsDarkTheme` alongside `LocalSpacing` so dialogs/components can detect dark mode. `staticCompositionLocalOf` is correct — matches `LocalDesktopCache`, `LocalRelayManager` pattern in Main.kt.
|
||||
|
||||
**Success criteria:**
|
||||
- [ ] `MaterialTheme.spacing.*` accessible throughout desktop app
|
||||
- [ ] `LocalIsDarkTheme.current` available for dark mode detection
|
||||
- [ ] No functional changes — this is infrastructure for subsequent phases
|
||||
|
||||
---
|
||||
|
||||
#### Phase 3: Sidebar Redesign
|
||||
|
||||
**Goal:** Transform the 56dp icon-only sidebar into a 240dp labeled navigation with avatar, custom feeds, and animated collapse.
|
||||
|
||||
**Files:**
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `desktopApp/.../ui/deck/DeckSidebar.kt` | Major rewrite — wide layout, labels, sections |
|
||||
| `desktopApp/.../ui/account/AccountSwitcherDropdown.kt` | Move avatar to sidebar top, restyle |
|
||||
| `desktopApp/.../ui/deck/DeckLayout.kt` | Adjust sidebar width to use animated state |
|
||||
|
||||
**Current sidebar structure (DeckSidebar.kt):**
|
||||
- 56.dp wide Column
|
||||
- AccountSwitcherDropdown (person icon) → Add Column → Import → Spacer → Bunker/Tor → Settings
|
||||
- No labels, no active state indicator, no feeds section
|
||||
|
||||
**New sidebar structure:**
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ [Avatar 40dp] Username │ ← tappable, opens account switcher
|
||||
│ @npub... │
|
||||
├─────────────────────────┤
|
||||
│ 🏠 Home │ ← main nav items
|
||||
│ 🔍 Search │
|
||||
│ ✉️ Messages │
|
||||
│ 💰 Wallet │
|
||||
│ 🔖 Bookmarks │
|
||||
│ ⚙️ Settings │
|
||||
├─────────────────────────┤
|
||||
│ FEEDS │ ← section header
|
||||
│ 📷 Photography │ ← from FeedDefinitionRepository
|
||||
│ 🏡 Homestead │
|
||||
│ 🏛️ Architecture │
|
||||
├─────────────────────────┤
|
||||
│ │ ← spacer
|
||||
│ [Bunker] [Tor] │ ← status indicators
|
||||
│ ◀ Collapse │ ← collapse toggle
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
**Key implementation details:**
|
||||
|
||||
1. **Animated width:** `animateDpAsState(if (expanded) 240.dp else 56.dp, tween(300, easing = FastOutSlowInEasing))` + `Modifier.clipToBounds()` on sidebar container
|
||||
2. **Column width debounce:** DeckLayout's `fitColumnsToWidth()` fires via `LaunchedEffect(availableWidthDp)`. During sidebar animation, this thrashes every frame. Add `snapshotFlow { availableWidthDp }.debounce(300)` or gate on animation completion.
|
||||
3. **Collapse persistence:** `java.util.prefs.Preferences` key `sidebar_collapsed`
|
||||
3. **Nav items:** Map `DeckColumnType` to sidebar entries with icon + label
|
||||
4. **Active state:** `primaryContainer` background pill with `primary` tinted icon + `SemiBold` text
|
||||
5. **Hover state:** `onSurface.copy(alpha = 0.08f)` background on hover (see Phase 6 for shared utility)
|
||||
6. **Feeds section:** Read from `LocalFeedRepository.current` → `groupedFeeds.pinned + myFeeds`
|
||||
7. **Feed icons:** Each `FeedDefinition` gets an icon field (Material Symbol codepoint). Default to a generic feed icon.
|
||||
8. **Avatar:** 40dp circular, loaded from `LocalCache` user metadata. Fallback to `Person` icon.
|
||||
9. **Account switcher:** Tap avatar opens existing `AccountSwitcherDropdown` (restyled with rounded corners). **Fix dropdown offset:** current hardcoded `DpOffset(x = 48.dp)` must become dynamic based on sidebar width.
|
||||
10. **Collapsed mode:** Only icons shown, no labels, tooltip on hover. Width animates to 56dp. **Accessibility:** Add `contentDescription` to icons when labels are hidden.
|
||||
11. **Keyboard shortcut:** `Cmd/Ctrl+B` to toggle sidebar collapse (add to MenuBar in Main.kt).
|
||||
|
||||
**Label fade animation:**
|
||||
```kotlin
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = fadeIn(tween(200, delayMillis = 100)),
|
||||
exit = fadeOut(tween(100)),
|
||||
) {
|
||||
Text(label, style = MaterialTheme.typography.labelLarge)
|
||||
}
|
||||
```
|
||||
|
||||
**Success criteria:**
|
||||
- [ ] Sidebar shows 240dp with avatar + nav items + feeds
|
||||
- [ ] Active item has cyan pill indicator
|
||||
- [ ] Collapse toggle animates smoothly
|
||||
- [ ] Collapse state persists across restarts
|
||||
- [ ] Custom feeds appear in FEEDS section
|
||||
- [ ] Account switcher works from avatar tap
|
||||
|
||||
---
|
||||
|
||||
#### Phase 4: Card & Action Bar Refinement
|
||||
|
||||
**Goal:** Restyle NoteCard to flat+border, update action bar to match screenshot, add image corner treatment.
|
||||
|
||||
**Files:**
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `desktopApp/.../ui/note/NoteCard.kt` | Flat + border, 16dp padding, action bar, image corners |
|
||||
|
||||
**Card changes:**
|
||||
|
||||
> **Research fix:** `BorderStroke` creates new instance each recomposition, defeating skip. Remember it. NoteCard must accept `modifier` parameter.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun NoteCard(
|
||||
// ... other params,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val border = remember(MaterialTheme.colorScheme.outlineVariant) {
|
||||
BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
|
||||
}
|
||||
OutlinedCard(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
border = border,
|
||||
shape = MaterialTheme.shapes.medium, // 12dp
|
||||
) {
|
||||
Column(modifier = Modifier.padding(MaterialTheme.spacing.cardPadding)) { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Scope note:** 10+ files use `CardDefaults.cardColors` across desktop (NotificationsScreen, ChessScreen, ProfileInfoCard, WalletColumnScreen, etc.). All need consistent conversion to OutlinedCard.
|
||||
|
||||
**Action bar redesign:**
|
||||
- Icon size: 20dp (down from current)
|
||||
- Layout: `Row` with `Arrangement.spacedBy(16.dp)`
|
||||
- Each action: Icon + count `Text(labelSmall)` in a `Row(spacing = 4.dp)`
|
||||
- Active states: filled icon + `primary` color (liked heart, zapped lightning, reposted)
|
||||
- Icons: comment, heart, zap (lightning bolt), repost, share
|
||||
|
||||
**Image treatment:**
|
||||
- All inline images: `clip(RoundedCornerShape(8.dp))`
|
||||
- Images get `padding(top = 8.dp)` for spacing from text above
|
||||
- Already partially implemented (8.dp corners exist) — ensure consistency
|
||||
|
||||
**Card gaps:**
|
||||
- Remove any `HorizontalDivider` between cards in feed lists
|
||||
- Use `Arrangement.spacedBy(MaterialTheme.spacing.cardGap)` (8dp) in `LazyColumn`
|
||||
|
||||
**Success criteria:**
|
||||
- [ ] Cards show flat white surface with subtle border
|
||||
- [ ] 16dp internal padding
|
||||
- [ ] Action bar uses smaller icons with count badges
|
||||
- [ ] Images have rounded corners
|
||||
- [ ] No divider lines between cards
|
||||
|
||||
---
|
||||
|
||||
#### Phase 5: Column Headers & Search
|
||||
|
||||
**Goal:** Restyle per-column headers to match screenshot aesthetic, add rounded pill search bar, segmented feed toggles.
|
||||
|
||||
**Files:**
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `desktopApp/.../ui/deck/ColumnHeader.kt` | Restyle: height, background, typography |
|
||||
| `desktopApp/.../ui/deck/FeedScreen.kt` (or equivalent) | Add SegmentedButton for My Feed / Global toggle |
|
||||
| Search composable | Rounded pill styling |
|
||||
|
||||
**ColumnHeader changes:**
|
||||
```kotlin
|
||||
// Before: surfaceVariant.copy(alpha = 0.5f), height = 40.dp
|
||||
// After:
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
modifier = Modifier.height(MaterialTheme.spacing.columnHeaderHeight) // 48dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// icon + title with titleMedium style
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Search bar (rounded pill):**
|
||||
```kotlin
|
||||
TextField(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
),
|
||||
leadingIcon = { Icon(Search) },
|
||||
trailingIcon = { Text("⌘K", style = labelSmall, color = onSurfaceVariant) },
|
||||
placeholder = { Text("Search notes, profiles, hashtags...") },
|
||||
)
|
||||
```
|
||||
|
||||
**Feed toggles (SegmentedButton):**
|
||||
```kotlin
|
||||
SingleChoiceSegmentedButtonRow {
|
||||
SegmentedButton(selected = isMine, onClick = { ... }, shape = SegmentedButtonDefaults.itemShape(0, 2)) {
|
||||
Text("My Feed")
|
||||
}
|
||||
SegmentedButton(selected = !isMine, onClick = { ... }, shape = SegmentedButtonDefaults.itemShape(1, 2)) {
|
||||
Text("Global")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Research insight:** SegmentedButton confirmed available and stable in CMP 1.10.3 (already used in AppDrawer.kt). At `MIN_COLUMN_WIDTH` (300dp), SegmentedButton + icon + close button is tight. Gate visibility on column width > 400dp.
|
||||
|
||||
> **Keyboard shortcut:** Wire `Cmd/Ctrl+K` to focus the search column's text field in MenuBar (Main.kt).
|
||||
|
||||
**Success criteria:**
|
||||
- [ ] Column headers have consistent 48dp height with surfaceContainer background
|
||||
- [ ] Search bar is rounded pill with keyboard shortcut hint (Cmd+K wired)
|
||||
- [ ] Feed toggle uses M3 SegmentedButton (hidden in narrow columns < 400dp)
|
||||
|
||||
---
|
||||
|
||||
#### Phase 6: Polish & Micro-interactions
|
||||
|
||||
**Goal:** Add hover effects, skeleton loading, styled tooltips/dialogs/context menus/scrollbars/snackbars.
|
||||
|
||||
**Files:**
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| New: `desktopApp/.../ui/theme/HoverModifiers.kt` | `Modifier.hoverHighlight()` (desktop-only, `onPointerEvent`) |
|
||||
| New: `commons/.../ui/components/ShimmerPlaceholder.kt` | Skeleton shimmer (pure M3, shared) |
|
||||
| `desktopApp/.../ui/note/NoteCard.kt` | Add hover effect |
|
||||
| `desktopApp/.../ui/deck/DeckSidebar.kt` | Add hover to nav items |
|
||||
| Dialog composables | Surface color in dark mode |
|
||||
| DropdownMenu usages | Styled corners + border |
|
||||
|
||||
**Hover utility (uses `onPointerEvent` — codebase convention from AppDrawer.kt, ChatPane.kt):**
|
||||
|
||||
> **Research fix:** Original plan used `composed{}` + `@Composable` lambda in `drawBehind` — won't compile. `shape.topStart` is invalid on generic `Shape`. Rewritten to match project patterns.
|
||||
|
||||
```kotlin
|
||||
// HoverModifiers.kt (desktopApp/jvmMain — desktop-only, uses ExperimentalComposeUiApi)
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun Modifier.hoverHighlight(
|
||||
hoverColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f),
|
||||
shape: Shape = MaterialTheme.shapes.medium,
|
||||
): Modifier {
|
||||
val color = remember { mutableStateOf(Color.Transparent) }
|
||||
return this
|
||||
.onPointerEvent(PointerEventType.Enter) { color.value = hoverColor }
|
||||
.onPointerEvent(PointerEventType.Exit) { color.value = Color.Transparent }
|
||||
.drawBehind { drawRect(color.value) }
|
||||
// drawBehind reads color.value in draw phase only — no recomposition on hover
|
||||
}
|
||||
```
|
||||
|
||||
> **Performance note:** State read deferred to draw phase via `drawBehind`. No recomposition on hover enter/exit — only draw invalidation. Safe for LazyColumn with 50+ cards.
|
||||
|
||||
**Skeleton shimmer:**
|
||||
```kotlin
|
||||
// ShimmerPlaceholder.kt
|
||||
@Composable
|
||||
fun ShimmerPlaceholder(modifier: Modifier = Modifier) {
|
||||
val transition = rememberInfiniteTransition()
|
||||
val translateAnim by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1000f,
|
||||
animationSpec = infiniteRepeatable(tween(1200, easing = LinearEasing)),
|
||||
)
|
||||
val brush = Brush.linearGradient(
|
||||
colors = listOf(
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
MaterialTheme.colorScheme.surfaceContainer,
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
start = Offset(translateAnim - 500f, 0f),
|
||||
end = Offset(translateAnim, 0f),
|
||||
)
|
||||
Box(modifier.background(brush, MaterialTheme.shapes.medium))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NoteCardSkeleton() {
|
||||
OutlinedCard(
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ShimmerPlaceholder(Modifier.size(32.dp).clip(CircleShape)) // avatar
|
||||
Column(Arrangement.spacedBy(4.dp)) {
|
||||
ShimmerPlaceholder(Modifier.width(120.dp).height(12.dp)) // name
|
||||
ShimmerPlaceholder(Modifier.width(60.dp).height(10.dp)) // timestamp
|
||||
}
|
||||
}
|
||||
ShimmerPlaceholder(Modifier.fillMaxWidth().height(14.dp)) // text line 1
|
||||
ShimmerPlaceholder(Modifier.fillMaxWidth(0.7f).height(14.dp)) // text line 2
|
||||
ShimmerPlaceholder(Modifier.fillMaxWidth().height(180.dp)) // image
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Dialog styling (dark mode):**
|
||||
|
||||
> **Research fix:** `ColorScheme.isLight` does not exist in M3. Use `LocalIsDarkTheme` from Phase 2.
|
||||
|
||||
```kotlin
|
||||
// Wrap existing Dialog composables
|
||||
Dialog(onDismissRequest = ...) {
|
||||
Surface(
|
||||
color = if (LocalIsDarkTheme.current)
|
||||
MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
else
|
||||
MaterialTheme.colorScheme.surface,
|
||||
shape = MaterialTheme.shapes.large, // 16dp
|
||||
tonalElevation = 6.dp,
|
||||
) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Styled context menus:**
|
||||
```kotlin
|
||||
DropdownMenu(
|
||||
modifier = Modifier
|
||||
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, MaterialTheme.shapes.medium)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**Styled tooltips:**
|
||||
```kotlin
|
||||
TooltipBox(
|
||||
tooltip = {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
shadowElevation = 2.dp,
|
||||
) {
|
||||
Text(text, modifier = Modifier.padding(8.dp, 4.dp), style = labelSmall)
|
||||
}
|
||||
},
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**Snackbar (extract shared composable — two SnackbarHost instances exist: Main.kt and ChatPane.kt):**
|
||||
```kotlin
|
||||
// commons/commonMain — pure M3, no platform APIs
|
||||
@Composable
|
||||
fun AmethystSnackbarHost(
|
||||
hostState: SnackbarHostState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
SnackbarHost(hostState, modifier) { data ->
|
||||
Snackbar(
|
||||
snackbarData = data,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
containerColor = MaterialTheme.colorScheme.inverseSurface,
|
||||
contentColor = MaterialTheme.colorScheme.inverseOnSurface,
|
||||
actionColor = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Research insight:** Plan originally used `surfaceContainerHighest` — may lack WCAG AA contrast in light mode. `inverseSurface`/`inverseOnSurface` provides high contrast in both modes (M3 standard snackbar pattern).
|
||||
|
||||
**Scrollbar styling (via built-in `LocalScrollbarStyle`):**
|
||||
```kotlin
|
||||
// Provide at theme root in PlatformMaterialTheme
|
||||
CompositionLocalProvider(
|
||||
LocalScrollbarStyle provides ScrollbarStyle(
|
||||
minimalHeight = 48.dp, thickness = 4.dp,
|
||||
shape = RoundedCornerShape(2.dp), hoverDurationMillis = 300,
|
||||
unhoverColor = Color.Black.copy(alpha = 0.12f),
|
||||
hoverColor = Color.Black.copy(alpha = 0.50f),
|
||||
)
|
||||
) { ... }
|
||||
```
|
||||
|
||||
**Success criteria:**
|
||||
- [ ] Cards highlight on hover
|
||||
- [ ] Sidebar items highlight on hover
|
||||
- [ ] Skeleton shimmer shown during feed loading
|
||||
- [ ] Dialogs use elevated surface in dark mode
|
||||
- [ ] Context menus have rounded corners + border
|
||||
- [ ] Tooltips are styled
|
||||
- [ ] Snackbar matches brand
|
||||
|
||||
---
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
### Interaction Graph
|
||||
|
||||
Theme changes cascade through `PlatformMaterialTheme` → all `MaterialTheme.colorScheme.*` / `.shapes.*` / `.typography.*` consumers. No callbacks or middleware — purely declarative recomposition.
|
||||
|
||||
### Error Propagation
|
||||
|
||||
No new error paths. Theme resolution is pure function (no IO). Sidebar collapse preference uses `java.util.prefs.Preferences` (already used for custom feeds — failure falls back to expanded).
|
||||
|
||||
### State Lifecycle Risks
|
||||
|
||||
- Sidebar collapse state: stored in `Preferences`, read once at composition. No partial state risk.
|
||||
- Feed definitions: already managed by `FeedDefinitionRepository` with `StateFlow`. Sidebar reads same flow.
|
||||
- Account avatar: loaded from `LocalCache` metadata — may be null initially (fallback to icon).
|
||||
|
||||
### API Surface Parity
|
||||
|
||||
No external API changes. All changes are internal UI.
|
||||
|
||||
### Integration Test Scenarios
|
||||
|
||||
1. **Dark/light toggle:** Switch modes mid-session → all colors update (no cached old scheme)
|
||||
2. **Sidebar collapse + window resize:** Collapse sidebar, resize window narrow → columns should not clip
|
||||
3. **Custom feed CRUD:** Create/delete feed → sidebar FEEDS section updates live
|
||||
4. **Account switch:** Switch account → avatar + username in sidebar update
|
||||
5. **Feed loading:** Navigate to a feed → skeleton shimmer shows → cards render
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- [ ] Unified Amethyst color scheme (cyan/blue accent) on all platforms
|
||||
- [ ] Dark/light mode fully functional with proper contrast
|
||||
- [ ] 240dp sidebar with icon + label navigation items
|
||||
- [ ] Sidebar collapse/expand with smooth animation
|
||||
- [ ] Collapse state persists across app restarts
|
||||
- [ ] Avatar + username at top of sidebar with account switcher
|
||||
- [ ] Custom feeds section in sidebar reading from FeedDefinitionRepository
|
||||
- [ ] Flat cards with subtle border (no shadow)
|
||||
- [ ] 16dp card padding, 12dp card corners
|
||||
- [ ] Action bar with smaller icons and count badges
|
||||
- [ ] Rounded images with 8dp corners
|
||||
- [ ] Per-column headers with consistent 48dp height
|
||||
- [ ] Rounded pill search bar with keyboard shortcut hint
|
||||
- [ ] M3 SegmentedButton for feed toggles
|
||||
- [ ] Hover effects on cards and sidebar items
|
||||
- [ ] Skeleton shimmer during feed loading
|
||||
- [ ] Styled tooltips, context menus, dialogs, snackbars
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- [ ] App compiles and runs on macOS, Windows, Linux
|
||||
- [ ] No regression in existing functionality
|
||||
- [ ] Font rendering quality maintained (per-OS fonts preserved)
|
||||
- [ ] Smooth animations (sidebar collapse, hover) at 60fps
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- [ ] `./gradlew :desktopApp:compileKotlin` passes
|
||||
- [ ] `./gradlew spotlessApply` clean
|
||||
- [ ] Manual visual QA in dark and light modes
|
||||
- [ ] Sidebar collapse/expand tested
|
||||
|
||||
## Dependencies & Prerequisites
|
||||
|
||||
- No external library additions — everything uses existing Material3 + Compose APIs
|
||||
- `FeedDefinitionRepository` and custom feeds infrastructure already exist
|
||||
- `AccountSwitcherDropdown` already exists — needs restyling, not rewriting
|
||||
- Material Symbols font subset may need new icons for feed types (run `./tools/material-symbols-subset/subset.sh`)
|
||||
|
||||
## Risk Analysis & Mitigation
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|-----------|
|
||||
| Color contrast issues in dark mode | Medium | Medium | Test with accessibility contrast checker |
|
||||
| Sidebar animation jank on Linux | Low | Low | Use `tween(250)` easing, test on GNOME |
|
||||
| Breaking existing hover behavior | Low | Medium | Audit all `pointerInput` / `onPointerEvent` usages first |
|
||||
| Missing Material Symbol icons | Medium | Low | Run subset script, add needed codepoints |
|
||||
| Inline Color() literals bypassing theme | Medium | Low | Grep audit + replace in Phase 1 |
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [ ] Remove per-OS color scheme functions (macOsLight, gnomeDark, etc.)
|
||||
- [ ] Remove `PlatformAccent` accent resolution (no longer needed)
|
||||
- [ ] Remove per-OS shape variants
|
||||
- [ ] Grep for `Color(0xFF` in `desktopApp/` — replace with theme references
|
||||
- [ ] Remove `darkenForLight()` helper
|
||||
- [ ] Update any tests referencing old color values
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **Brainstorm document:** [docs/brainstorms/2026-05-27-desktop-visual-personality-brainstorm.md](docs/brainstorms/2026-05-27-desktop-visual-personality-brainstorm.md) — Key decisions: cyan/blue accent (#0096FF), flat+border cards, 240dp collapsible sidebar, Amethyst-branded over OS-native
|
||||
|
||||
### Internal References
|
||||
|
||||
- `PlatformColorScheme.kt` — current per-OS scheme implementation
|
||||
- `PlatformTheme.kt` — theme composition entry point
|
||||
- `DeckSidebar.kt` — current 56dp icon sidebar
|
||||
- `NoteCard.kt` — current card styling
|
||||
- `ColumnHeader.kt` — current column header (40dp, surfaceVariant)
|
||||
- `AccountSwitcherDropdown.kt` — existing account switcher
|
||||
- `FeedDefinitionRepository.kt` — custom feed data source
|
||||
- `FeedsDrawerTab.kt` — current feed browsing UI
|
||||
|
||||
### External References
|
||||
|
||||
- [Material Design 3 Color System](https://m3.material.io/styles/color/system/overview)
|
||||
- [Material Design 3 Elevation](https://m3.material.io/styles/elevation/applying-elevation)
|
||||
- [Custom Design Systems in Compose](https://developer.android.com/develop/ui/compose/designsystems/custom)
|
||||
175
docs/plans/2026-05-28-fix-feed-header-search-ux-plan.md
Normal file
175
docs/plans/2026-05-28-fix-feed-header-search-ux-plan.md
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: "fix: Feed Header Bar Layout + Inline Search UX"
|
||||
type: fix
|
||||
status: active
|
||||
date: 2026-05-28
|
||||
origin: docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md
|
||||
---
|
||||
|
||||
# fix: Feed Header Bar Layout + Inline Search UX
|
||||
|
||||
## Overview
|
||||
|
||||
Fix 5 issues with the feed header bar and search experience introduced in the visual personality overhaul.
|
||||
|
||||
## Problems
|
||||
|
||||
| # | Issue | Root Cause |
|
||||
|---|-------|-----------|
|
||||
| 1 | Feed tabs take too much space, search not centered | `Row` with `SpaceBetween` pushes search to far right. Tabs + "+ More" consume all left space |
|
||||
| 2 | Clicking search pill doesn't open search | `onSearchClick` callback not wired from `DeckColumnContainer` → `FeedScreen` → `FeedTabsHeader` |
|
||||
| 3 | Search opens as Dialog overlay (wrong UX) | `SearchSpotlight` uses `Dialog()` which creates separate AWT window. Should be inline expansion of the pill with dropdown + background blur |
|
||||
| 4 | Follow tabs can't be clicked | FilterChip `onClick` calls `onNavigateToFeed` for custom feeds, but the callback only handles `FeedSource.Filter` — other source types silently ignored |
|
||||
| 5 | Header doesn't match screenshot card aesthetic | Header is a plain `Row` with padding. Should be a card-like `Surface` with rounded corners and visual separation |
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### Fix 1: Redesign FeedTabsHeader layout
|
||||
|
||||
**File:** `FeedScreen.kt` — `FeedTabsHeader`
|
||||
|
||||
New layout — compact tabs on left, search pill takes center weight, compose on right:
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ [Following][Global][+] 🔍 Search notes... ⌘F ✏️ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Feed tabs: compact `FilterChip` with just emoji+name, `Arrangement.spacedBy(4.dp)`
|
||||
- Remove "+ More" chip (moved to sidebar already)
|
||||
- SearchPill: `Modifier.weight(1f)` so it fills remaining center space
|
||||
- Compose button: stays on far right
|
||||
- Wrap entire header in `Surface(shape = shapes.medium, color = surfaceContainer)` with 12dp padding
|
||||
|
||||
### Fix 2: Wire onSearchClick callback
|
||||
|
||||
**File:** `DeckColumnContainer.kt`
|
||||
|
||||
Pass `onSearchClick = { showSearchSpotlight = true }` through `FeedScreen` call sites. The `showSearchSpotlight` state is already at Main.kt level — need to pass it down as callback.
|
||||
|
||||
Simplest approach: add `onSearchClick` param to `MainContent` → column container → FeedScreen.
|
||||
|
||||
### Fix 3: Replace Dialog with inline search expansion
|
||||
|
||||
**File:** `SearchSpotlight.kt` → Delete. Replace with inline expansion in `SearchPill.kt`
|
||||
|
||||
Instead of a Dialog overlay, the SearchPill itself expands:
|
||||
|
||||
**Collapsed (default):**
|
||||
```
|
||||
🔍 Search notes, profiles... ⌘F
|
||||
```
|
||||
|
||||
**Expanded (on click or Cmd+F):**
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ 🔍 [typing here...] ⌘F │
|
||||
├────────────────────────────────────────┤
|
||||
│ Recent │
|
||||
│ 📝 bitcoin lightning │
|
||||
│ 📝 @fiatjaf │
|
||||
├────────────────────────────────────────┤
|
||||
│ Saved │
|
||||
│ ⭐ Nostr development │
|
||||
└────────────────────────────────────────┘
|
||||
+ background blur/dim behind dropdown
|
||||
```
|
||||
|
||||
Implementation:
|
||||
- `SearchPill` gains `expanded: Boolean` state
|
||||
- When expanded: pill becomes `BasicTextField` with same shape, a `DropdownMenu` or `Popup` appears below with history/results
|
||||
- Background: `Box(Modifier.fillMaxSize().background(Color.Black.copy(0.3f)))` rendered at the parent level when expanded
|
||||
- Clicking outside or pressing Escape collapses
|
||||
- `Cmd+F` sets expanded = true on the active feed column's SearchPill
|
||||
|
||||
### Fix 4: Fix follow tab click handlers
|
||||
|
||||
**File:** `FeedScreen.kt` — `FeedTabsHeader`
|
||||
|
||||
Current `onNavigateToFeed` callback only handles `FeedSource.Filter`. Need to also handle `FeedSource.Following` and `FeedSource.Global` by calling `onFeedModeChange` directly in the chip onClick (already done for the `when` branches — the bug is that custom feed chips with non-Filter sources silently do nothing).
|
||||
|
||||
Check: is `feed.source` ever something other than `Following`, `Global`, or `Filter`? If so, add handling.
|
||||
|
||||
### Fix 5: Card-based header design
|
||||
|
||||
**File:** `FeedScreen.kt` — `FeedTabsHeader`
|
||||
|
||||
Wrap the header Row in a `Surface`:
|
||||
```kotlin
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding, vertical = 8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
...
|
||||
) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
This gives it the same card treatment as feed items — white surface, subtle border, rounded corners.
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Fix header layout + card design (Fixes 1, 5)
|
||||
|
||||
**FeedScreen.kt — rewrite FeedTabsHeader:**
|
||||
- Wrap in `Surface` with `OutlinedCard` styling
|
||||
- Compact tabs (remove "+ More")
|
||||
- SearchPill with `weight(1f)` in center
|
||||
- 48dp height, consistent padding
|
||||
|
||||
### Phase 2: Inline search expansion (Fixes 2, 3)
|
||||
|
||||
**Delete SearchSpotlight.kt** — replace with expanded state in SearchPill.
|
||||
|
||||
**New SearchPill.kt:**
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SearchPill(
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
onOpenFullSearch: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
```
|
||||
- Collapsed: clickable Surface pill (current design)
|
||||
- Expanded: `BasicTextField` in same pill shape + `Popup`/`DropdownMenu` below with history
|
||||
- Scrim: parent renders a semi-transparent overlay when `expanded = true`
|
||||
|
||||
**Main.kt:**
|
||||
- `Cmd+F` sets a `searchExpanded` state that's passed down to the active feed
|
||||
- Remove `SearchSpotlight` rendering
|
||||
|
||||
### Phase 3: Fix tab click handlers (Fix 4)
|
||||
|
||||
**FeedScreen.kt:** Audit `onNavigateToFeed` callback path. Ensure all `FeedSource` types are handled.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Feed tabs are compact, search pill fills remaining center space
|
||||
- [ ] Header wrapped in card-like Surface matching screenshot aesthetic
|
||||
- [ ] Clicking SearchPill expands it into an input with dropdown below
|
||||
- [ ] Cmd+F expands the search pill (no separate overlay)
|
||||
- [ ] Typing in expanded search shows recent + saved searches
|
||||
- [ ] Clicking outside or pressing Escape collapses search
|
||||
- [ ] All feed tabs (Following, Global, custom) are clickable and switch feeds
|
||||
- [ ] "+ More" removed from header (already in sidebar)
|
||||
- [ ] Compiles, spotless clean
|
||||
|
||||
## Files Affected
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `desktopApp/.../ui/FeedScreen.kt` | Rewrite `FeedTabsHeader` — layout, card design, tab fix |
|
||||
| `desktopApp/.../ui/search/SearchPill.kt` | Add expanded state, inline BasicTextField, dropdown |
|
||||
| `desktopApp/.../ui/search/SearchSpotlight.kt` | Delete (replaced by inline expansion) |
|
||||
| `desktopApp/.../Main.kt` | Remove SearchSpotlight rendering, wire Cmd+F to feed search expansion |
|
||||
|
||||
## Sources
|
||||
|
||||
- Brainstorm: `docs/brainstorms/2026-05-28-feat-desktop-search-spotlight-brainstorm.md`
|
||||
- Deepen research: Dialog creates separate AWT window on desktop — use Box overlay or inline instead
|
||||
- Existing pattern: `LightboxOverlay.kt` uses Box overlay (not Dialog)
|
||||
34
todos/001-pending-p2-feed-content-margin-under-search.md
Normal file
34
todos/001-pending-p2-feed-content-margin-under-search.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
status: pending
|
||||
priority: p2
|
||||
issue_id: "001"
|
||||
tags: [code-review, ui, desktop, search]
|
||||
---
|
||||
|
||||
# Feed content needs more margin below search bar
|
||||
|
||||
## Problem Statement
|
||||
|
||||
When the search bar is expanded, feed items below get partially hidden by the expanded search card. The spacer height (60dp) that reserves space for the header is not enough when the search card expands with history/results.
|
||||
|
||||
## Findings
|
||||
|
||||
- FeedScreen.kt line 587: `Spacer(Modifier.height(60.dp))` reserves space for the collapsed header only
|
||||
- When search expands, the card grows downward but the feed content doesn't shift
|
||||
- Feed items near the top get obscured by the expanded search card + scrim
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
**Option A: Dynamic spacer based on search state**
|
||||
- When `searchActive`, increase spacer to match expanded card height (~300dp)
|
||||
- Pros: exact spacing. Cons: needs to track card height dynamically.
|
||||
|
||||
**Option B: Add extra bottom padding to expanded card**
|
||||
- Feed items already have their own padding. Just ensure the search card's expanded area doesn't overlap.
|
||||
- Pros: simple. Cons: may not cover all cases.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Feed items below the search bar are fully visible when search is collapsed
|
||||
- [ ] When search expands, feed content is not obscured by the expanded card
|
||||
- [ ] Smooth transition when expanding/collapsing
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
status: pending
|
||||
priority: p2
|
||||
issue_id: "002"
|
||||
tags: [code-review, ui, desktop, search]
|
||||
---
|
||||
|
||||
# Tapping search history should populate input and search
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Clicking a recent search item in the expanded search history currently just calls `onOpenFullSearch()` (opens full Search column). It should instead populate the search input with that query text and immediately start searching.
|
||||
|
||||
## Findings
|
||||
|
||||
- FeedScreen.kt SearchHistorySection: all history rows call `onOpenFullSearch()` on click
|
||||
- Should instead: set `searchText` to the history item's text, which triggers `updateFromText()` and relay subscriptions
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
**Option A: Pass `onHistoryItemClick: (String) -> Unit` to SearchHistorySection**
|
||||
- Callback sets `searchText = TextFieldValue(text)` in parent
|
||||
- `LaunchedEffect(searchText.text)` triggers `updateFromText()` automatically
|
||||
- Pros: clean separation. Cons: needs callback threading.
|
||||
|
||||
**Option B: Pass `searchText` MutableState directly**
|
||||
- SearchHistorySection writes to the state directly
|
||||
- Pros: simple. Cons: tight coupling.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Clicking a recent search item populates the search input with that text
|
||||
- [ ] Search results start loading immediately after populating
|
||||
- [ ] The search bar stays expanded (doesn't collapse)
|
||||
35
todos/003-pending-p2-cmd-f-context-aware.md
Normal file
35
todos/003-pending-p2-cmd-f-context-aware.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
status: pending
|
||||
priority: p2
|
||||
issue_id: "003"
|
||||
tags: [code-review, ui, desktop, search, keyboard]
|
||||
---
|
||||
|
||||
# Cmd+F should be context-aware: inline on feeds, full search elsewhere
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Cmd+F currently toggles `feedSearchActiveState` which only works on the Home/Feeds screen. When on Messages, Settings, Bookmarks, or other non-feed screens, Cmd+F should open the full Search column instead.
|
||||
|
||||
## Findings
|
||||
|
||||
- Main.kt MenuBar: Cmd+F sets `feedSearchActiveState.value = !feedSearchActiveState.value`
|
||||
- FeedScreen reads `LocalFeedSearchActive.current` — only works when FeedScreen is visible
|
||||
- On other screens (Messages, Settings, etc.), the state changes but nothing happens visually
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
**Option A: Check current screen type in Cmd+F handler**
|
||||
- Read `activeColumnType` from deck/single-pane state
|
||||
- If HomeFeed/GlobalFeed/CustomFeed → toggle inline search
|
||||
- Otherwise → navigate to Search column
|
||||
|
||||
**Option B: Use two shortcuts**
|
||||
- Cmd+F → always opens inline search on feed (no-op on other screens)
|
||||
- Cmd+Shift+F → always opens full Search column
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Cmd+F on Home/Feeds screen → inline search expands
|
||||
- [ ] Cmd+F on any other screen → opens full Search column/screen
|
||||
- [ ] Cmd+F on already-expanded search → collapses it
|
||||
36
todos/004-pending-p3-hardcoded-status-colors.md
Normal file
36
todos/004-pending-p3-hardcoded-status-colors.md
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
status: pending
|
||||
priority: p3
|
||||
issue_id: "004"
|
||||
tags: [code-review, theming, desktop]
|
||||
---
|
||||
|
||||
# Hardcoded status colors should use theme tokens
|
||||
|
||||
## Problem Statement
|
||||
|
||||
32 inline Color() constructors across 10 desktop files use hardcoded RGB values for status indicators (green/red/amber). These don't adapt to dark/light mode properly.
|
||||
|
||||
## Findings
|
||||
|
||||
Key files: RelayStatusCard.kt, TorStatusIndicator.kt, LoginProgressSteps.kt, MediaServerSettings.kt, DevSettingsSection.kt, NewKeyWarningCard.kt, ProfileInfoCard.kt
|
||||
|
||||
Common hardcoded values:
|
||||
- `Color(0xFF4CAF50)` green — should use a semantic success color
|
||||
- `Color(0xFFF44336)` red — should use `MaterialTheme.colorScheme.error`
|
||||
- `Color(0xFFFFB300)` amber — should use a semantic warning color
|
||||
- `Color.Red` / `Color.Green` — bare Material colors
|
||||
|
||||
## Proposed Solutions
|
||||
|
||||
Extract semantic status colors as ColorScheme extensions:
|
||||
```kotlin
|
||||
val ColorScheme.statusSuccess: Color get() = if (isLight) Color(0xFF339900) else Color(0xFF99cc33)
|
||||
val ColorScheme.statusError: Color get() = error
|
||||
val ColorScheme.statusWarning: Color get() = if (isLight) Color(0xFFC09B14) else Color(0xFFE1C419)
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] No bare Color.Red/Green/Yellow in desktop UI files
|
||||
- [ ] Status colors adapt properly to dark/light mode
|
||||
26
todos/005-pending-p3-inline-shape-audit.md
Normal file
26
todos/005-pending-p3-inline-shape-audit.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
status: pending
|
||||
priority: p3
|
||||
issue_id: "005"
|
||||
tags: [code-review, theming, desktop]
|
||||
---
|
||||
|
||||
# ~40 inline RoundedCornerShape() calls should use MaterialTheme.shapes
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The desktopApp module has ~40 inline `RoundedCornerShape()` calls with values 4dp, 8dp, 10dp, 12dp, 16dp. These should map to `MaterialTheme.shapes.*` tokens for consistency with the unified Amethyst shape system.
|
||||
|
||||
## Findings
|
||||
|
||||
Common clusters:
|
||||
- `8.dp` (~20 occurrences) → `MaterialTheme.shapes.small`
|
||||
- `12.dp` (~5 occurrences) → `MaterialTheme.shapes.medium`
|
||||
- `16.dp` (~4 occurrences) → `MaterialTheme.shapes.large`
|
||||
- `100.dp` / `999.dp` → pill shapes (acceptable as-is)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] All 8dp corner shapes use MaterialTheme.shapes.small
|
||||
- [ ] All 12dp corner shapes use MaterialTheme.shapes.medium
|
||||
- [ ] All 16dp corner shapes use MaterialTheme.shapes.large
|
||||
Reference in New Issue
Block a user