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:
nrobi144
2026-05-28 13:31:37 +03:00
parent 41a59a9499
commit 7ff4d8e3b7
35 changed files with 2172 additions and 134 deletions

View File

@@ -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)

View File

@@ -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
},

View File

@@ -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),
)

View File

@@ -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),
)
}

View File

@@ -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(

View File

@@ -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 -> {

View File

@@ -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)
}

View File

@@ -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,
)
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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() }
}

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,
)
}

View File

@@ -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),
)
}

View File

@@ -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 {

View File

@@ -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,
)
}

View File

@@ -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 },

View File

@@ -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))
}

View File

@@ -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,
)
}
}

View File

@@ -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),

View File

@@ -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
},
) {}
}

View File

@@ -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}")
}
}

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View 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)

View 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)

View 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

View File

@@ -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)

View 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

View 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

View 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