From a15c5d269255d4feab6ad6866bc209128f34b393 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 13:15:05 +0000 Subject: [PATCH 01/29] feat(desktop): native theming for macOS, GNOME, KDE, Windows Replace the hard-coded Material3 dark theme with a per-OS theme that follows the host system's appearance, accent color, fonts, and rounding language. Adds a `desktop/platform/` module with: - PlatformInfo: detects macOS, Windows, GNOME, KDE, other Linux via XDG_CURRENT_DESKTOP / DESKTOP_SESSION. - PlatformAppearance: reads OS dark/light preference (defaults on macOS, gsettings on GNOME, kreadconfig on KDE, registry on Windows) and refreshes on window focus. - PlatformAccent: pulls the user's accent color from each OS (named AppleAccentColor, gsettings accent-color, kdeglobals AccentColor, DWM AccentColor) and falls back to Amethyst purple. - PlatformFonts: resolves the OS's preferred UI font via Skia's FontMgr (SF Pro Text on macOS, Segoe UI Variable on Win 11, Cantarell/Adwaita Sans on GNOME, Noto Sans on KDE). - PlatformShapes / PlatformTypography / PlatformColorScheme: per-OS rounding (macOS 8/10/14, libadwaita 9/12/16, Breeze 6/8/12, WinUI 4/8/8), letter-spacing tightening for SF Pro / Adwaita, and surface tones from each OS's reference palette. macOS gets native chrome treatment: `apple.laf.useScreenMenuBar` routes the MenuBar to the system menu bar; `apple.awt.transparentTitleBar` + `apple.awt.fullWindowContent` lets the deck/sidebar extend under the traffic lights; a 28dp top strip in MainContent reserves space so content doesn't underlap them. DeckSidebar bumps from 48dp to 56dp (desktop density) and switches its background to the Material3 `surfaceContainer` token, which our new per-OS schemes drive to the right tone for each platform. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../vitorpamplona/amethyst/desktop/Main.kt | 38 ++- .../desktop/platform/PlatformAccent.kt | 165 +++++++++++ .../desktop/platform/PlatformAppearance.kt | 139 ++++++++++ .../desktop/platform/PlatformColorScheme.kt | 262 ++++++++++++++++++ .../desktop/platform/PlatformFonts.kt | 134 +++++++++ .../amethyst/desktop/platform/PlatformInfo.kt | 74 +++++ .../desktop/platform/PlatformShapes.kt | 91 ++++++ .../desktop/platform/PlatformTheme.kt | 85 ++++++ .../desktop/platform/PlatformTypography.kt | 88 ++++++ .../amethyst/desktop/ui/deck/DeckSidebar.kt | 4 +- 10 files changed, 1073 insertions(+), 7 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformFonts.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 6b5472079..e0ac62577 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -46,7 +47,6 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider -import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopRelayCategories import com.vitorpamplona.amethyst.desktop.network.DefaultRelays import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher +import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool @@ -131,7 +132,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import kotlin.time.Duration.Companion.seconds -private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") +private val isMacOS = com.vitorpamplona.amethyst.desktop.platform.PlatformInfo.isMacOS enum class LayoutMode { SINGLE_PANE, @@ -184,6 +185,15 @@ sealed class DesktopScreen { private var activeTorManager: com.vitorpamplona.amethyst.desktop.tor.DesktopTorManager? = null fun main() { + // macOS: route the app's MenuBar to the system menu bar at the top of the + // screen and set the application name shown in the apple-menu. Both must be + // set before AWT initializes (i.e. before any Swing/AWT class loads). + if (com.vitorpamplona.amethyst.desktop.platform.PlatformInfo.isMacOS) { + System.setProperty("apple.laf.useScreenMenuBar", "true") + System.setProperty("apple.awt.application.name", "Amethyst") + System.setProperty("apple.awt.application.appearance", "system") + } + Log.minLevel = LogLevel.DEBUG DesktopImageLoaderSetup.setup() Runtime.getRuntime().addShutdownHook( @@ -247,6 +257,10 @@ fun main() { state = windowState, title = "Amethyst", ) { + // macOS: transparent + full-window-content title bar so the deck/sidebar + // shows through, with traffic lights still drawn on top. No-op elsewhere. + applyNativeWindowChrome() + MenuBar { Menu("File") { Item( @@ -738,9 +752,10 @@ fun App( } } - MaterialTheme( - colorScheme = darkColorScheme(), - ) { + val isDark by com.vitorpamplona.amethyst.desktop.platform + .rememberSystemDark(LocalAwtWindow.current) + + com.vitorpamplona.amethyst.desktop.platform.PlatformMaterialTheme(isDark = isDark) { ProvideMaterialSymbols { Surface( modifier = Modifier.fillMaxSize(), @@ -1102,6 +1117,19 @@ fun MainContent( ) { Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { + // macOS: reserve a title bar strip so deck/sidebar content doesn't + // underlap the traffic lights. The strip is colored to match the + // sidebar so the whole top edge reads as one continuous toolbar. + if (!isImmersive && + com.vitorpamplona.amethyst.desktop.platform.PlatformInfo.isMacOS + ) { + Box( + Modifier + .fillMaxWidth() + .height(com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop) + .background(MaterialTheme.colorScheme.surfaceContainer), + ) + } Row(Modifier.fillMaxSize().weight(1f)) { when (layoutMode) { LayoutMode.SINGLE_PANE -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt new file mode 100644 index 000000000..48dc1950c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.commons.ui.theme.DefaultPrimary +import com.vitorpamplona.quartz.utils.Log + +/** + * Resolves the user's preferred OS accent color. Falls back to Amethyst's purple + * brand color if the OS doesn't expose an accent or detection fails. + * + * - macOS: `defaults read -g AppleAccentColor` returns -1..7 (named accents) or + * `AppleHighlightColor` carries an RGB triplet for the multicolor case. + * - GNOME 47+: `gsettings get org.gnome.desktop.interface accent-color`. + * - KDE: parses the `AccentColor` key from kdeglobals. + * - Windows: registry `ColorizationColor` (ABGR DWORD). + * + * Returning the brand color is a safe default — Amethyst purple matches the app's + * identity and looks intentional rather than broken. + */ +object PlatformAccent { + fun systemAccent(): Color = + when (PlatformInfo.current) { + Platform.MACOS -> macOSAccent() + Platform.GNOME -> gnomeAccent() + Platform.KDE -> kdeAccent() + Platform.WINDOWS -> windowsAccent() + else -> DefaultPrimary + } ?: DefaultPrimary + + // macOS named accent colors (NSColor controlAccentColor variants). + // Index matches AppleAccentColor defaults value; -1 = Multicolor (use highlight). + private val macAccents = + mapOf( + -1 to Color(0xFF0064E1), // Multicolor → blue (system default) + 0 to Color(0xFFEF5743), // Red + 1 to Color(0xFFEC8E2C), // Orange + 2 to Color(0xFFF8BA00), // Yellow + 3 to Color(0xFF62BA46), // Green + 4 to Color(0xFF0064E1), // Blue + 5 to Color(0xFFC44495), // Purple + 6 to Color(0xFFF74F9E), // Pink + 7 to Color(0xFF8C8C8C), // Graphite + ) + + private fun macOSAccent(): Color? { + val out = exec("defaults", "read", "-g", "AppleAccentColor") + val idx = out?.trim()?.toIntOrNull() + if (idx != null) return macAccents[idx] + + // Highlight color triple ("0.7 0.45 0.85 Purple") — first 3 floats are RGB 0..1. + val highlight = exec("defaults", "read", "-g", "AppleHighlightColor") ?: return null + val rgb = highlight.trim().split(" ").mapNotNull { it.toFloatOrNull() } + if (rgb.size >= 3) return Color(rgb[0].coerceIn(0f, 1f), rgb[1].coerceIn(0f, 1f), rgb[2].coerceIn(0f, 1f)) + return null + } + + // GNOME 47+ accent names mapped to the libadwaita reference accent colors. + private val gnomeAccents = + mapOf( + "blue" to Color(0xFF3584E4), + "teal" to Color(0xFF2190A4), + "green" to Color(0xFF3A944A), + "yellow" to Color(0xFFC88800), + "orange" to Color(0xFFED5B00), + "red" to Color(0xFFE62D42), + "pink" to Color(0xFFD56199), + "purple" to Color(0xFF9141AC), + "slate" to Color(0xFF6F8396), + ) + + private fun gnomeAccent(): Color? { + val out = exec("gsettings", "get", "org.gnome.desktop.interface", "accent-color") ?: return null + val name = out.trim().trim('\'').lowercase() + return gnomeAccents[name] + } + + private fun kdeAccent(): Color? { + // kdeglobals stores AccentColor as comma-separated RGB ("123,45,200"). + val home = System.getProperty("user.home") ?: return null + val candidates = + listOf( + "$home/.config/kdeglobals", + "$home/.kde/share/config/kdeglobals", + ) + for (path in candidates) { + val file = java.io.File(path) + if (!file.exists()) continue + try { + val text = file.readText() + val match = Regex("(?m)^AccentColor\\s*=\\s*(\\d+),\\s*(\\d+),\\s*(\\d+)").find(text) + if (match != null) { + val (r, g, b) = match.destructured + return Color(r.toInt(), g.toInt(), b.toInt()) + } + } catch (e: Exception) { + Log.d("PlatformAccent") { "Failed to parse KDE accent at $path: ${e.message}" } + } + } + return null + } + + private fun windowsAccent(): Color? { + // HKCU\Software\Microsoft\Windows\DWM has AccentColor as a DWORD in 0xAABBGGRR (ABGR). + val out = + exec( + "reg", + "query", + "HKCU\\Software\\Microsoft\\Windows\\DWM", + "/v", + "AccentColor", + ) ?: return null + val match = Regex("AccentColor\\s+REG_DWORD\\s+0x([0-9a-fA-F]+)").find(out) ?: return null + val value = match.groupValues[1].toLongOrNull(16) ?: return null + // ABGR: high byte = A, then B, G, R + val r = (value and 0xFFL).toInt() + val g = ((value shr 8) and 0xFFL).toInt() + val b = ((value shr 16) and 0xFFL).toInt() + return Color(r, g, b) + } + + private fun exec(vararg cmd: String): String? = + try { + val proc = + ProcessBuilder(*cmd) + .redirectErrorStream(true) + .start() + val out = + proc.inputStream + .bufferedReader() + .readText() + .trim() + val finished = proc.waitFor(2, java.util.concurrent.TimeUnit.SECONDS) + if (!finished) { + proc.destroyForcibly() + null + } else if (proc.exitValue() != 0) { + null + } else { + out + } + } catch (e: Exception) { + Log.d("PlatformAccent") { "Failed to exec ${cmd.joinToString(" ")}: ${e.message}" } + null + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt new file mode 100644 index 000000000..e2e6d1008 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import com.vitorpamplona.quartz.utils.Log +import java.awt.Window +import java.awt.event.WindowFocusListener + +/** + * Reads the OS's preferred light/dark appearance. + * + * - macOS: `defaults read -g AppleInterfaceStyle` returns "Dark" only when dark is on. + * - GNOME: `gsettings get org.gnome.desktop.interface color-scheme` → 'prefer-dark' / 'prefer-light' / 'default'. + * - KDE: `kreadconfig5 --group General --key ColorScheme` (or kreadconfig6) — name contains "Dark" when dark. + * - Windows: HKCU AppsUseLightTheme registry value (0 = dark). + * + * Detection is stale-tolerant: re-runs on window focus so users who flip their + * system theme see Amethyst follow within a second of bringing the window forward. + */ +object PlatformAppearance { + fun isSystemDark(): Boolean = + when (PlatformInfo.current) { + Platform.MACOS -> isMacOSDark() + Platform.GNOME -> isGnomeDark() + Platform.KDE -> isKdeDark() + Platform.WINDOWS -> isWindowsDark() + else -> true + } + + private fun isMacOSDark(): Boolean { + val out = exec("defaults", "read", "-g", "AppleInterfaceStyle") ?: return false + return out.contains("Dark", ignoreCase = true) + } + + private fun isGnomeDark(): Boolean { + val out = exec("gsettings", "get", "org.gnome.desktop.interface", "color-scheme") ?: return true + if (out.contains("prefer-dark", ignoreCase = true)) return true + if (out.contains("prefer-light", ignoreCase = true)) return false + // 'default' → fall back to legacy gtk-theme heuristic + val theme = exec("gsettings", "get", "org.gnome.desktop.interface", "gtk-theme").orEmpty() + return theme.contains("dark", ignoreCase = true) + } + + private fun isKdeDark(): Boolean { + // Try Plasma 6 first, fall back to 5. + val tools = listOf("kreadconfig6", "kreadconfig5") + for (tool in tools) { + val out = exec(tool, "--group", "General", "--key", "ColorScheme") + if (!out.isNullOrBlank()) return out.contains("dark", ignoreCase = true) + } + return true + } + + private fun isWindowsDark(): Boolean { + val out = + exec( + "reg", + "query", + "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + "/v", + "AppsUseLightTheme", + ) ?: return false + // Output line looks like: " AppsUseLightTheme REG_DWORD 0x0" + val match = Regex("AppsUseLightTheme\\s+REG_DWORD\\s+0x([0-9a-fA-F]+)").find(out) ?: return false + return match.groupValues[1].toIntOrNull(16) == 0 + } + + private fun exec(vararg cmd: String): String? = + try { + val proc = + ProcessBuilder(*cmd) + .redirectErrorStream(true) + .start() + val out = + proc.inputStream + .bufferedReader() + .readText() + .trim() + val finished = proc.waitFor(2, java.util.concurrent.TimeUnit.SECONDS) + if (!finished) { + proc.destroyForcibly() + null + } else if (proc.exitValue() != 0) { + null + } else { + out + } + } catch (e: Exception) { + Log.d("PlatformAppearance") { "Failed to exec ${cmd.joinToString(" ")}: ${e.message}" } + null + } +} + +/** + * Returns a Compose [State] that tracks the OS dark/light preference and + * refreshes whenever the given AWT [Window] gains focus. Lightweight (~30ms shell-out + * on focus) and avoids polling. + */ +@Composable +fun rememberSystemDark(awtWindow: Window?): State { + val state = remember { mutableStateOf(PlatformAppearance.isSystemDark()) } + DisposableEffect(awtWindow) { + if (awtWindow == null) return@DisposableEffect onDispose {} + val listener = + object : WindowFocusListener { + override fun windowGainedFocus(e: java.awt.event.WindowEvent?) { + state.value = PlatformAppearance.isSystemDark() + } + + override fun windowLostFocus(e: java.awt.event.WindowEvent?) = Unit + } + awtWindow.addWindowFocusListener(listener) + onDispose { awtWindow.removeWindowFocusListener(listener) } + } + return state +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt new file mode 100644 index 000000000..5018a7f2d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +/** + * Per-OS Material3 [ColorScheme]s tuned to match each platform's native surface + * tones. The OS accent (resolved via [PlatformAccent]) is plumbed in as `primary` + * so links, focus rings, and selection highlights match the rest of the user's + * system. + * + * Surface tones come from each OS's reference palettes: + * - macOS dark: NSWindowBackgroundColor / NSAlternateSelectedControlColor. + * - macOS light: NSColor systemBackgroundColor (#FFFFFF) / window background (#ECECEC). + * - GNOME dark: libadwaita `@window_bg_color` (#242424) / `@view_bg_color` (#1E1E1E). + * - GNOME light: libadwaita `@window_bg_color` (#FAFAFA) / `@view_bg_color` (#FFFFFF). + * - KDE Breeze dark/light defaults. + * - Windows 11 dark/light: WinUI mica reference values. + */ +object PlatformColorScheme { + fun resolve( + dark: Boolean, + accent: Color, + ): ColorScheme = + when (PlatformInfo.current) { + Platform.MACOS -> if (dark) macOSDark(accent) else macOSLight(accent) + Platform.GNOME -> if (dark) gnomeDark(accent) else gnomeLight(accent) + Platform.KDE -> if (dark) kdeDark(accent) else kdeLight(accent) + Platform.WINDOWS -> if (dark) windowsDark(accent) else windowsLight(accent) + else -> if (dark) genericDark(accent) else genericLight(accent) + } + + // ── macOS ───────────────────────────────────────────────────────────────── + + private fun macOSDark(accent: Color) = + darkColorScheme( + primary = accent, + onPrimary = onAccent(accent), + secondary = accent, + tertiary = accent, + background = Color(0xFF1E1E1E), + onBackground = Color(0xFFE5E5E5), + surface = Color(0xFF1E1E1E), + onSurface = Color(0xFFE5E5E5), + surfaceVariant = Color(0xFF2A2A2A), + onSurfaceVariant = Color(0xFFB8B8B8), + surfaceContainer = Color(0xFF252525), + surfaceContainerHigh = Color(0xFF2D2D2D), + surfaceContainerHighest = Color(0xFF353535), + surfaceContainerLow = Color(0xFF1A1A1A), + surfaceContainerLowest = Color(0xFF141414), + surfaceDim = Color(0xFF1A1A1A), + surfaceBright = Color(0xFF353535), + outline = Color(0xFF6A6A6A), + outlineVariant = Color(0xFF3A3A3A), + ) + + private fun macOSLight(accent: Color) = + lightColorScheme( + primary = accent, + onPrimary = onAccent(accent), + secondary = accent, + tertiary = accent, + background = Color(0xFFECECEC), + onBackground = Color(0xFF1A1A1A), + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF1A1A1A), + surfaceVariant = Color(0xFFF2F2F2), + onSurfaceVariant = Color(0xFF555555), + surfaceContainer = Color(0xFFF6F6F6), + surfaceContainerHigh = Color(0xFFEDEDED), + surfaceContainerHighest = Color(0xFFE5E5E5), + surfaceContainerLow = Color(0xFFFAFAFA), + surfaceContainerLowest = Color(0xFFFFFFFF), + outline = Color(0xFFB0B0B0), + outlineVariant = Color(0xFFD8D8D8), + ) + + // ── GNOME (libadwaita) ──────────────────────────────────────────────────── + + private fun gnomeDark(accent: Color) = + darkColorScheme( + primary = accent, + onPrimary = onAccent(accent), + secondary = accent, + tertiary = accent, + background = Color(0xFF242424), + onBackground = Color(0xFFFFFFFF), + surface = Color(0xFF1E1E1E), + onSurface = Color(0xFFFFFFFF), + surfaceVariant = Color(0xFF323232), + onSurfaceVariant = Color(0xFFCCCCCC), + surfaceContainer = Color(0xFF2C2C2C), + surfaceContainerHigh = Color(0xFF383838), + surfaceContainerHighest = Color(0xFF424242), + surfaceContainerLow = Color(0xFF222222), + surfaceContainerLowest = Color(0xFF1A1A1A), + outline = Color(0xFF5E5E5E), + outlineVariant = Color(0xFF3A3A3A), + ) + + private fun gnomeLight(accent: Color) = + lightColorScheme( + primary = accent, + onPrimary = onAccent(accent), + secondary = accent, + tertiary = accent, + background = Color(0xFFFAFAFA), + onBackground = Color(0xFF1A1A1A), + surface = Color(0xFFFFFFFF), + onSurface = Color(0xFF1A1A1A), + surfaceVariant = Color(0xFFF0F0F0), + onSurfaceVariant = Color(0xFF5E5E5E), + surfaceContainer = Color(0xFFF4F4F4), + surfaceContainerHigh = Color(0xFFEDEDED), + surfaceContainerHighest = Color(0xFFE5E5E5), + surfaceContainerLow = Color(0xFFF9F9F9), + surfaceContainerLowest = Color(0xFFFFFFFF), + outline = Color(0xFFB0B0B0), + outlineVariant = Color(0xFFD4D4D4), + ) + + // ── KDE Plasma (Breeze) ─────────────────────────────────────────────────── + + private fun kdeDark(accent: Color) = + darkColorScheme( + primary = accent, + onPrimary = onAccent(accent), + secondary = accent, + tertiary = accent, + background = Color(0xFF1B1E20), + onBackground = Color(0xFFFCFCFC), + surface = Color(0xFF232629), + onSurface = Color(0xFFFCFCFC), + surfaceVariant = Color(0xFF2A2E32), + onSurfaceVariant = Color(0xFFBDC3C7), + surfaceContainer = Color(0xFF272A2E), + surfaceContainerHigh = Color(0xFF31353A), + surfaceContainerHighest = Color(0xFF3B4045), + surfaceContainerLow = Color(0xFF1F2225), + surfaceContainerLowest = Color(0xFF18191B), + outline = Color(0xFF4D5258), + outlineVariant = Color(0xFF34383C), + ) + + private fun kdeLight(accent: Color) = + lightColorScheme( + primary = accent, + onPrimary = onAccent(accent), + secondary = accent, + tertiary = accent, + background = Color(0xFFEFF0F1), + onBackground = Color(0xFF232629), + surface = Color(0xFFFCFCFC), + onSurface = Color(0xFF232629), + surfaceVariant = Color(0xFFE5E9EC), + onSurfaceVariant = Color(0xFF4D4D4D), + surfaceContainer = Color(0xFFF2F3F4), + surfaceContainerHigh = Color(0xFFEAECEE), + surfaceContainerHighest = Color(0xFFE0E3E5), + surfaceContainerLow = Color(0xFFF7F8F9), + surfaceContainerLowest = Color(0xFFFFFFFF), + outline = Color(0xFFBABEC2), + outlineVariant = Color(0xFFD9DCDF), + ) + + // ── Windows 11 (WinUI 3 / Mica) ─────────────────────────────────────────── + + private fun windowsDark(accent: Color) = + darkColorScheme( + primary = accent, + onPrimary = onAccent(accent), + secondary = accent, + tertiary = accent, + background = Color(0xFF202020), + onBackground = Color(0xFFFFFFFF), + surface = Color(0xFF2B2B2B), + onSurface = Color(0xFFFFFFFF), + surfaceVariant = Color(0xFF323232), + onSurfaceVariant = Color(0xFFCCCCCC), + surfaceContainer = Color(0xFF272727), + surfaceContainerHigh = Color(0xFF323232), + surfaceContainerHighest = Color(0xFF3D3D3D), + surfaceContainerLow = Color(0xFF1F1F1F), + surfaceContainerLowest = Color(0xFF1A1A1A), + outline = Color(0xFF5E5E5E), + outlineVariant = Color(0xFF3A3A3A), + ) + + private fun windowsLight(accent: Color) = + lightColorScheme( + primary = accent, + onPrimary = onAccent(accent), + secondary = accent, + tertiary = accent, + background = Color(0xFFF3F3F3), + onBackground = Color(0xFF1A1A1A), + surface = Color(0xFFFBFBFB), + onSurface = Color(0xFF1A1A1A), + surfaceVariant = Color(0xFFEDEDED), + onSurfaceVariant = Color(0xFF555555), + surfaceContainer = Color(0xFFF6F6F6), + surfaceContainerHigh = Color(0xFFEDEDED), + surfaceContainerHighest = Color(0xFFE5E5E5), + surfaceContainerLow = Color(0xFFFAFAFA), + surfaceContainerLowest = Color(0xFFFFFFFF), + outline = Color(0xFFB0B0B0), + outlineVariant = Color(0xFFD8D8D8), + ) + + // ── Generic (other Linux DEs / Unknown) ────────────────────────────────── + + private fun genericDark(accent: Color) = + darkColorScheme( + primary = accent, + onPrimary = onAccent(accent), + secondary = accent, + tertiary = accent, + background = Color(0xFF1E1E1E), + surface = Color(0xFF1E1E1E), + surfaceVariant = Color(0xFF2A2A2A), + ) + + private fun genericLight(accent: Color) = + lightColorScheme( + primary = accent, + onPrimary = onAccent(accent), + secondary = accent, + tertiary = accent, + ) + + /** + * Picks readable text color (white or black) on top of the given accent based on + * its perceived luminance (Rec. 709 weights). + */ + private fun onAccent(accent: Color): Color { + val luminance = 0.2126f * accent.red + 0.7152f * accent.green + 0.0722f * accent.blue + return if (luminance > 0.55f) Color.Black else Color.White + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformFonts.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformFonts.kt new file mode 100644 index 000000000..8cddb3431 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformFonts.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.Typeface +import org.jetbrains.skia.FontMgr +import org.jetbrains.skia.FontStyle +import java.awt.GraphicsEnvironment + +/** + * Resolves a [FontFamily] backed by the host OS's preferred UI font, with a + * per-OS fallback chain. Uses Skia's font manager to look up the family by name + * — names not installed on the system are skipped, so the order acts as a chain. + * + * Targets per OS: + * - macOS: SF Pro Text (the system UI font; "Helvetica Neue" / "Helvetica" as fallbacks). + * - GNOME: Adwaita Sans (47+), Cantarell (older), Inter, Noto Sans. + * - KDE Plasma: Noto Sans (Plasma's default), then Inter / DejaVu Sans. + * - Windows: Segoe UI Variable Text (Win 11), Segoe UI (Win 10). + * + * The final fallback is `FontFamily.Default` so Compose still has something to draw + * with on stripped-down systems. + */ +object PlatformFonts { + val ui: FontFamily by lazy { resolve(uiCandidates()) } + + val mono: FontFamily by lazy { resolve(monoCandidates(), fallback = FontFamily.Monospace) } + + private fun uiCandidates(): List = + when (PlatformInfo.current) { + Platform.MACOS -> { + listOf("SF Pro Text", ".AppleSystemUIFont", "Helvetica Neue", "Helvetica") + } + + Platform.WINDOWS -> { + listOf("Segoe UI Variable Text", "Segoe UI Variable", "Segoe UI") + } + + Platform.GNOME -> { + listOf("Adwaita Sans", "Cantarell", "Inter", "Noto Sans", "DejaVu Sans") + } + + Platform.KDE -> { + listOf("Noto Sans", "Inter", "DejaVu Sans", "Cantarell") + } + + Platform.LINUX_OTHER -> { + listOf("Inter", "Noto Sans", "DejaVu Sans", "Cantarell") + } + + Platform.UNKNOWN -> { + emptyList() + } + } + + private fun monoCandidates(): List = + when (PlatformInfo.current) { + Platform.MACOS -> { + listOf("SF Mono", "Menlo", "Monaco") + } + + Platform.WINDOWS -> { + listOf("Cascadia Mono", "Cascadia Code", "Consolas") + } + + Platform.GNOME -> { + listOf("Adwaita Mono", "Source Code Pro", "DejaVu Sans Mono") + } + + Platform.KDE -> { + listOf("Hack", "Noto Sans Mono", "Source Code Pro") + } + + Platform.LINUX_OTHER -> { + listOf("JetBrains Mono", "Source Code Pro", "DejaVu Sans Mono") + } + + Platform.UNKNOWN -> { + emptyList() + } + } + + private fun resolve( + candidates: List, + fallback: FontFamily = FontFamily.Default, + ): FontFamily { + if (candidates.isEmpty()) return fallback + val installed = installedFamilies + val name = candidates.firstOrNull { it in installed } ?: return fallback + return systemFamily(name) + } + + /** + * Builds a [FontFamily] from a system family name. Skia's `FontMgr.matchFamilyStyle` + * gives us a regular-weight typeface; Skia synthesizes bold/italic from it on demand, + * which is how every native UI toolkit on desktop renders system fonts. + */ + private fun systemFamily(name: String): FontFamily { + val skTypeface = + runCatching { FontMgr.default.matchFamilyStyle(name, FontStyle.NORMAL) } + .getOrNull() ?: return FontFamily.Default + return FontFamily(Typeface(skTypeface)) + } + + private val installedFamilies: Set by lazy { + try { + GraphicsEnvironment + .getLocalGraphicsEnvironment() + .availableFontFamilyNames + .toSet() + } catch (e: Exception) { + emptySet() + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt new file mode 100644 index 000000000..58f41b32c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +/** + * Identifies the host operating system and (on Linux) the desktop environment so the + * UI can adopt native fonts, shapes, accent colors, and window chrome. + * + * Detection happens once at JVM start and is cached for the process lifetime. + */ +enum class Platform { + MACOS, + WINDOWS, + GNOME, + KDE, + LINUX_OTHER, + UNKNOWN, + ; + + val isLinux: Boolean get() = this == GNOME || this == KDE || this == LINUX_OTHER +} + +object PlatformInfo { + val current: Platform by lazy { detect() } + + val isMacOS: Boolean get() = current == Platform.MACOS + val isWindows: Boolean get() = current == Platform.WINDOWS + val isGnome: Boolean get() = current == Platform.GNOME + val isKde: Boolean get() = current == Platform.KDE + val isLinux: Boolean get() = current.isLinux + + private fun detect(): Platform { + val osName = System.getProperty("os.name", "").lowercase() + return when { + osName.contains("mac") || osName.contains("darwin") -> Platform.MACOS + osName.contains("win") -> Platform.WINDOWS + osName.contains("nux") || osName.contains("nix") -> detectLinuxEnv() + else -> Platform.UNKNOWN + } + } + + private fun detectLinuxEnv(): Platform { + // Per the freedesktop spec, XDG_CURRENT_DESKTOP is the canonical hint. + // It's a colon-separated list — match any token. + val xdg = System.getenv("XDG_CURRENT_DESKTOP")?.lowercase().orEmpty() + val session = System.getenv("XDG_SESSION_DESKTOP")?.lowercase().orEmpty() + val desktop = System.getenv("DESKTOP_SESSION")?.lowercase().orEmpty() + val combined = "$xdg:$session:$desktop" + + return when { + "gnome" in combined || "unity" in combined || "pantheon" in combined -> Platform.GNOME + "kde" in combined || "plasma" in combined -> Platform.KDE + else -> Platform.LINUX_OTHER + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt new file mode 100644 index 000000000..fbd183140 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformShapes.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Shapes +import androidx.compose.ui.unit.dp + +/** + * Per-OS Material3 [Shapes] tuned to match each platform's native rounding language. + * Material's defaults (4 / 4 / 0 dp) read as Android — these values match what users + * see in their OS's first-party apps. + * + * - macOS (Sonoma+): ~10 / 12 / 16 dp continuous-style corners. + * - GNOME (libadwaita): 9 / 12 / 16 dp — adw_dialog / adw_card baseline. + * - KDE (Breeze): 6 / 8 / 12 dp — Breeze prefers tighter rounding than libadwaita. + * - Windows (WinUI 3): 4 / 8 / 8 dp — WinUI's mica surfaces use modest rounding. + */ +object PlatformShapes { + val current: Shapes by lazy { + when (PlatformInfo.current) { + Platform.MACOS -> { + Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(10.dp), + large = RoundedCornerShape(14.dp), + extraLarge = RoundedCornerShape(20.dp), + ) + } + + Platform.GNOME -> { + Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(9.dp), + medium = RoundedCornerShape(12.dp), + large = RoundedCornerShape(16.dp), + extraLarge = RoundedCornerShape(24.dp), + ) + } + + Platform.KDE -> { + Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(6.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(12.dp), + extraLarge = RoundedCornerShape(16.dp), + ) + } + + Platform.WINDOWS -> { + Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(8.dp), + extraLarge = RoundedCornerShape(12.dp), + ) + } + + Platform.LINUX_OTHER, Platform.UNKNOWN -> { + Shapes( + extraSmall = RoundedCornerShape(6.dp), + small = RoundedCornerShape(8.dp), + medium = RoundedCornerShape(10.dp), + large = RoundedCornerShape(14.dp), + extraLarge = RoundedCornerShape(20.dp), + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt new file mode 100644 index 000000000..739d0ee0a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTheme.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.FrameWindowScope + +/** + * Wraps content in a [MaterialTheme] tuned for the host OS: native fonts, accent + * color, surface tones, and shape rounding all match what the user sees in their + * other apps. + * + * Pair this with [applyNativeWindowChrome] inside the [androidx.compose.ui.window.Window] + * scope to also get native title-bar treatment (transparent title bar with + * traffic-light inset on macOS). + */ +@Composable +fun PlatformMaterialTheme( + isDark: Boolean, + content: @Composable () -> Unit, +) { + val accent = remember { PlatformAccent.systemAccent() } + val colorScheme = remember(isDark, accent) { PlatformColorScheme.resolve(isDark, accent) } + MaterialTheme( + colorScheme = colorScheme, + typography = PlatformTypography.current, + shapes = PlatformShapes.current, + content = content, + ) +} + +/** + * Inset to apply at the top of window content so it doesn't underlap the macOS + * traffic lights once `applyNativeWindowChrome` has made the title bar transparent + * + full-content. ~28 dp clears the buttons with comfortable padding. On non-macOS + * platforms returns 0 dp so layouts stay flush. + */ +val titleBarInsetTop: Dp + get() = if (PlatformInfo.isMacOS) 28.dp else 0.dp + +/** + * Applies platform-specific native window chrome. + * + * On macOS: + * - `apple.awt.transparentTitleBar = true` removes the title bar tint so app + * content shows through. + * - `apple.awt.fullWindowContent = true` lets content extend under the title bar + * while the traffic lights stay drawn on top. + * - `apple.awt.windowTitleVisible = false` hides the "Amethyst" string so the + * bar is clean. + * + * On other platforms this is a no-op — the OS chrome stays as-is, which is + * already what users expect (custom Compose-drawn title bars on Linux/Windows + * mostly look worse than the system's, especially for window snapping & a11y). + */ +@Composable +fun FrameWindowScope.applyNativeWindowChrome() { + if (!PlatformInfo.isMacOS) return + val rootPane = window.rootPane + rootPane.putClientProperty("apple.awt.transparentTitleBar", true) + rootPane.putClientProperty("apple.awt.fullWindowContent", true) + rootPane.putClientProperty("apple.awt.windowTitleVisible", false) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt new file mode 100644 index 000000000..f11e02839 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformTypography.kt @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp + +/** + * Material3 [Typography] using the host OS's preferred UI font with per-OS letter + * spacing tuned to match native apps. Material's defaults are positively tracked + * (Roboto-style); Apple's SF Pro and GNOME's Adwaita are tracked tighter at the + * larger sizes, so we mirror that. + * + * The body sizes stay close to Material defaults so the existing layout code that + * assumes 14–16sp body text doesn't reflow. + */ +object PlatformTypography { + val current: Typography by lazy { build(PlatformFonts.ui) } + + private fun build(family: FontFamily): Typography { + // Per-OS letter spacing offset applied to display/headline styles. + val tightening: TextUnit = + when (PlatformInfo.current) { + Platform.MACOS -> (-0.4).sp + + // SF Pro is set tight at large sizes + Platform.GNOME -> (-0.2).sp + + // Adwaita Sans is mildly tight + Platform.KDE, Platform.WINDOWS -> 0.sp + + else -> 0.sp + } + + fun ts( + size: Int, + line: Int, + weight: FontWeight = FontWeight.Normal, + tracking: TextUnit = 0.sp, + ) = TextStyle( + fontFamily = family, + fontWeight = weight, + fontSize = size.sp, + lineHeight = line.sp, + letterSpacing = tracking, + ) + + return Typography( + displayLarge = ts(57, 64, FontWeight.Normal, tightening), + displayMedium = ts(45, 52, FontWeight.Normal, tightening), + displaySmall = ts(36, 44, FontWeight.Normal, tightening), + headlineLarge = ts(32, 40, FontWeight.SemiBold, tightening), + headlineMedium = ts(28, 36, FontWeight.SemiBold, tightening), + headlineSmall = ts(24, 32, FontWeight.SemiBold, tightening), + titleLarge = ts(22, 28, FontWeight.SemiBold), + titleMedium = ts(16, 24, FontWeight.Medium, 0.15.sp), + titleSmall = ts(14, 20, FontWeight.Medium, 0.1.sp), + bodyLarge = ts(16, 24, FontWeight.Normal, 0.15.sp), + bodyMedium = ts(14, 20, FontWeight.Normal, 0.25.sp), + bodySmall = ts(12, 16, FontWeight.Normal, 0.4.sp), + labelLarge = ts(14, 20, FontWeight.Medium, 0.1.sp), + labelMedium = ts(12, 16, FontWeight.Medium, 0.5.sp), + labelSmall = ts(11, 16, FontWeight.Medium, 0.5.sp), + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index a5a282e00..807cfd167 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -57,9 +57,9 @@ fun DeckSidebar( Column( modifier = modifier - .width(48.dp) + .width(56.dp) .fillMaxHeight() - .background(MaterialTheme.colorScheme.surfaceVariant) + .background(MaterialTheme.colorScheme.surfaceContainer) .padding(vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, From 46864a07b8d845ff108cfecd0c2181a34356151f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 13:25:21 +0000 Subject: [PATCH 02/29] feat(desktop): platform/appearance/accent overrides for local preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three override hooks so a single-OS developer can preview every platform's theming without VMs: AMETHYST_PLATFORM=GNOME ./gradlew :desktopApp:run AMETHYST_APPEARANCE=light ./gradlew :desktopApp:run AMETHYST_ACCENT=#3584E4 ./gradlew :desktopApp:run Each override accepts either an env var or a `-Damethyst.=` JVM property (forwarded from the gradle invocation via build.gradle.kts). - PlatformInfo: `amethyst.platform` accepts MACOS, WINDOWS, GNOME, KDE, LINUX_OTHER, UNKNOWN. Adds a `host` accessor for code that needs the real underlying OS (for a future fallback hook). - PlatformAppearance: `amethyst.appearance` accepts light/dark. - PlatformAccent: `amethyst.accent` accepts `#RRGGBB`, `RRGGBB`, or any libadwaita accent name (blue, teal, green, yellow, orange, red, pink, purple, slate). Window chrome stays native to the host OS — overriding the platform swaps in-app theming only, not the AWT title bar — so on a Mac you still see traffic lights even when previewing the GNOME theme. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- desktopApp/build.gradle.kts | 7 +++++ .../desktop/platform/PlatformAccent.kt | 31 +++++++++++++++++-- .../desktop/platform/PlatformAppearance.kt | 28 +++++++++++++++-- .../amethyst/desktop/platform/PlatformInfo.kt | 23 +++++++++++++- 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 0b1a166a0..09a54abe6 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -91,6 +91,13 @@ compose.desktop { jvmArgs += "-Xmx2g" + // Forward platform-preview overrides from the gradle invocation to the + // launched app's JVM so `./gradlew :desktopApp:run -Damethyst.platform=GNOME` + // works in addition to the env-var form (`AMETHYST_PLATFORM=GNOME`). + listOf("amethyst.platform", "amethyst.appearance", "amethyst.accent").forEach { key -> + System.getProperty(key)?.let { jvmArgs += "-D$key=$it" } + } + nativeDistributions { appResourcesRootDir.set(project.layout.projectDirectory.dir("src/jvmMain/appResources")) targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb, TargetFormat.Rpm) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt index 48dc1950c..fb7616782 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAccent.kt @@ -38,14 +38,41 @@ import com.vitorpamplona.quartz.utils.Log * identity and looks intentional rather than broken. */ object PlatformAccent { - fun systemAccent(): Color = - when (PlatformInfo.current) { + /** + * Resolves the OS accent color, with an override hook for testing: + * `-Damethyst.accent=#3584E4` (system property) or `AMETHYST_ACCENT=blue` + * (env var). Accepted: `#RRGGBB`, `RRGGBB`, or any libadwaita accent name + * (blue, teal, green, yellow, orange, red, pink, purple, slate). + */ + fun systemAccent(): Color { + forcedAccent()?.let { return it } + return when (PlatformInfo.current) { Platform.MACOS -> macOSAccent() Platform.GNOME -> gnomeAccent() Platform.KDE -> kdeAccent() Platform.WINDOWS -> windowsAccent() else -> DefaultPrimary } ?: DefaultPrimary + } + + private fun forcedAccent(): Color? { + val raw = + System.getProperty("amethyst.accent") + ?: System.getenv("AMETHYST_ACCENT") + ?: return null + val trimmed = raw.trim() + // Hex (#RRGGBB or RRGGBB) — long form for clarity + val hex = trimmed.removePrefix("#") + if (hex.length == 6 && hex.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) { + val v = hex.toLongOrNull(16) ?: return null + return Color( + red = ((v shr 16) and 0xFFL).toInt(), + green = ((v shr 8) and 0xFFL).toInt(), + blue = (v and 0xFFL).toInt(), + ) + } + return gnomeAccents[trimmed.lowercase()] + } // macOS named accent colors (NSColor controlAccentColor variants). // Index matches AppleAccentColor defaults value; -1 = Multicolor (use highlight). diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt index e2e6d1008..6754782d6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppearance.kt @@ -41,14 +41,38 @@ import java.awt.event.WindowFocusListener * system theme see Amethyst follow within a second of bringing the window forward. */ object PlatformAppearance { - fun isSystemDark(): Boolean = - when (PlatformInfo.current) { + /** + * Resolves the OS dark/light preference, with an override hook for testing + * on a single machine: `-Damethyst.appearance=light|dark` (system property) + * or `AMETHYST_APPEARANCE=light|dark` (env var). + * + * When the platform is overridden but appearance isn't, this calls the + * override platform's detection routine. On a Mac forced to GNOME the + * `gsettings` shell-out won't exist and it falls through to the GNOME + * default (dark) — pass `AMETHYST_APPEARANCE=light` to flip it. + */ + fun isSystemDark(): Boolean { + forcedAppearance()?.let { return it } + return when (PlatformInfo.current) { Platform.MACOS -> isMacOSDark() Platform.GNOME -> isGnomeDark() Platform.KDE -> isKdeDark() Platform.WINDOWS -> isWindowsDark() else -> true } + } + + private fun forcedAppearance(): Boolean? { + val raw = + System.getProperty("amethyst.appearance") + ?: System.getenv("AMETHYST_APPEARANCE") + ?: return null + return when (raw.lowercase()) { + "dark" -> true + "light" -> false + else -> null + } + } private fun isMacOSDark(): Boolean { val out = exec("defaults", "read", "-g", "AppleInterfaceStyle") ?: return false diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt index 58f41b32c..6c9d7c59b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformInfo.kt @@ -39,7 +39,20 @@ enum class Platform { } object PlatformInfo { - val current: Platform by lazy { detect() } + /** + * The detected (or overridden) UI platform — drives all per-OS theming. + * + * Override for testing on a single machine via `-Damethyst.platform=GNOME` + * (system property) or `AMETHYST_PLATFORM=GNOME` (env var). Accepted values + * are the [Platform] enum names, case-insensitive: MACOS, WINDOWS, GNOME, + * KDE, LINUX_OTHER, UNKNOWN. + */ + val current: Platform by lazy { override() ?: detect() } + + /** The actual host OS, ignoring any override. Useful for shell-out helpers + * that should always hit the real underlying system (e.g. accent detection + * falling back to the Mac's value when the override platform's CLI is absent). */ + val host: Platform by lazy { detect() } val isMacOS: Boolean get() = current == Platform.MACOS val isWindows: Boolean get() = current == Platform.WINDOWS @@ -47,6 +60,14 @@ object PlatformInfo { val isKde: Boolean get() = current == Platform.KDE val isLinux: Boolean get() = current.isLinux + private fun override(): Platform? { + val raw = + System.getProperty("amethyst.platform") + ?: System.getenv("AMETHYST_PLATFORM") + ?: return null + return runCatching { Platform.valueOf(raw.uppercase()) }.getOrNull() + } + private fun detect(): Platform { val osName = System.getProperty("os.name", "").lowercase() return when { From 78a13a82c608a085bd89f23f167eb931d99ad5b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 13:29:32 +0000 Subject: [PATCH 03/29] docs(desktop): guide for previewing per-OS theming locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks through the AMETHYST_PLATFORM / AMETHYST_APPEARANCE / AMETHYST_ACCENT overrides — what they swap, what they don't (host-OS chrome stays), a per-platform review checklist, and where each piece of theming code lives. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- desktopApp/THEME_PREVIEW.md | 143 ++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 desktopApp/THEME_PREVIEW.md diff --git a/desktopApp/THEME_PREVIEW.md b/desktopApp/THEME_PREVIEW.md new file mode 100644 index 000000000..9cfc41a53 --- /dev/null +++ b/desktopApp/THEME_PREVIEW.md @@ -0,0 +1,143 @@ +# Manual Testing: Desktop Native Theming + +The desktop app adapts its colors, fonts, shapes, and accent to the host OS +(macOS, Windows, GNOME, KDE, other Linux). This guide shows how to preview +each platform's theme without leaving your dev machine, and what to look at +when reviewing a theming change. + +## Quick Start + +Three environment variables drive the preview overrides. Each one also has +a `-Damethyst.=` system-property form, forwarded from gradle to +the launched app's JVM. + +| Variable | Values | Effect | +|---|---|---| +| `AMETHYST_PLATFORM` | `MACOS`, `WINDOWS`, `GNOME`, `KDE`, `LINUX_OTHER`, `UNKNOWN` | Forces in-app theming for that OS | +| `AMETHYST_APPEARANCE` | `light`, `dark` | Forces dark/light mode | +| `AMETHYST_ACCENT` | `#RRGGBB`, `RRGGBB`, or libadwaita name (`blue`, `teal`, `green`, `yellow`, `orange`, `red`, `pink`, `purple`, `slate`) | Forces accent color | + +Examples: + +```bash +# Native (no override) — uses your real OS +./gradlew :desktopApp:run + +# GNOME light theme with the libadwaita default blue accent +AMETHYST_PLATFORM=GNOME AMETHYST_APPEARANCE=light AMETHYST_ACCENT=blue ./gradlew :desktopApp:run + +# KDE Breeze dark with a custom accent +AMETHYST_PLATFORM=KDE AMETHYST_APPEARANCE=dark AMETHYST_ACCENT=#3DAEE9 ./gradlew :desktopApp:run + +# Windows 11 (WinUI 3 mica tones) +AMETHYST_PLATFORM=WINDOWS ./gradlew :desktopApp:run + +# Equivalent system-property form +./gradlew :desktopApp:run -Damethyst.platform=GNOME -Damethyst.appearance=light +``` + +## What Changes vs. What Doesn't + +The override swaps **in-app theming only**. The window chrome (title bar, +traffic lights / minimize-maximize buttons, screen menu bar on macOS) is +drawn by AWT from the actual host OS, not by our theme code. So: + +| Element | Follows override? | Notes | +|---|---|---| +| `colorScheme` (background, surface, primary…) | ✅ | Per-OS reference palettes | +| Body / heading fonts | ✅ | SF Pro on macOS, Cantarell on GNOME, Noto Sans on KDE, Segoe UI Variable on Windows | +| Button / card / dialog rounding | ✅ | macOS 8/10/14, libadwaita 9/12/16, Breeze 6/8/12, WinUI 4/8/8 | +| Accent color | ✅ | Threaded through `MaterialTheme.colorScheme.primary` | +| Sidebar density (56 dp) | ✅ | Same on all OSes (desktop convention) | +| Native title bar / traffic lights | ❌ | Drawn by host OS — to see the real GNOME header bar or KDE Breeze title, you need a real Linux machine or VM | +| macOS screen menu bar | ❌ | Only active when host OS is macOS | +| `apple.awt.transparentTitleBar` content extension | ❌ | macOS-host-only | + +## Review Checklist + +When reviewing a theming change, launch each preview and verify: + +### macOS (`AMETHYST_PLATFORM=MACOS`, or no override on a Mac) + +- [ ] Sidebar background reads as `surfaceContainer` — slightly lighter than the deck background, not jarringly different +- [ ] Body text renders in SF Pro Text (check by zooming a screenshot — SF has distinctive 'a', 'g', 'k' shapes) +- [ ] Card / dialog corners ~10 dp (a hair tighter than libadwaita) +- [ ] Letter spacing is slightly tight at large headings (SF tightens at display sizes) +- [ ] On a real Mac: traffic lights sit at top-left over the sidebar color, NOT over a white default-OS strip +- [ ] On a real Mac: menu bar appears at the top of the screen, not inside the window + +### GNOME (`AMETHYST_PLATFORM=GNOME`) + +- [ ] Surfaces match libadwaita references: `#242424` window bg dark, `#FAFAFA` window bg light +- [ ] Cards have 12 dp medium rounding (visibly more rounded than macOS) +- [ ] If Cantarell or Adwaita Sans is installed locally, body text uses it; otherwise falls through to Inter / Noto Sans +- [ ] Try `AMETHYST_ACCENT=blue` and confirm primary color is `#3584E4` (libadwaita default) + +### KDE (`AMETHYST_PLATFORM=KDE`) + +- [ ] Surfaces match Breeze references: `#1B1E20` background dark, `#EFF0F1` background light +- [ ] Rounding is tighter than macOS / GNOME (8 dp medium, 6 dp small) +- [ ] Body text renders in Noto Sans if installed +- [ ] Default accent (when nothing forced) is the Amethyst purple fallback — KDE accent detection won't run on macOS + +### Windows (`AMETHYST_PLATFORM=WINDOWS`) + +- [ ] Surfaces match WinUI 3 mica tones: `#202020` background dark, `#F3F3F3` background light +- [ ] Rounding is the tightest of any platform: 4 dp small, 8 dp medium +- [ ] Body text uses Segoe UI Variable Text only if installed locally (not present on macOS by default — falls back to FontFamily.Default) + +## Side-by-side Comparison + +The launched app is a single window. To compare two themes you currently +need to launch the app twice: + +```bash +# Terminal 1 +AMETHYST_PLATFORM=GNOME ./gradlew :desktopApp:run + +# Terminal 2 (after the first finishes building) +AMETHYST_PLATFORM=MACOS ./gradlew :desktopApp:run +``` + +Each launch opens its own window — drag them next to each other. + +## Known Limitations + +1. **No native chrome on host OS.** The window frame, title bar buttons, + and (on macOS) screen menu bar always come from the real host OS. + To see a real GNOME header bar or KDE title bar, use a Linux machine + or VM. + +2. **OS detection shell-outs return defaults when their CLI is missing.** + On macOS, `gsettings` and `kreadconfig5` aren't installed, so + `AMETHYST_PLATFORM=GNOME` without `AMETHYST_APPEARANCE` defaults to + dark and without `AMETHYST_ACCENT` defaults to Amethyst purple. Pass + the explicit overrides to control them. + +3. **Font fallback chain is deterministic but not always satisfying.** + The chain (e.g. for GNOME: Adwaita Sans → Cantarell → Inter → Noto + Sans → DejaVu Sans) walks Skia's font manager and picks the first + installed family. If none of the candidates are installed, + `FontFamily.Default` is used (looks like Roboto-ish). To install the + GNOME family on macOS for testing: + ```bash + brew install --cask font-cantarell + ``` + +4. **Accent name list is libadwaita-only.** Apple's named accents (red, + orange, etc. as integers) and Windows registry accents resolve only + when the corresponding host OS is the real OS. Use hex + (`AMETHYST_ACCENT=#FF6B35`) for arbitrary colors. + +## Where the Code Lives + +All preview behavior is in `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/`: + +- `PlatformInfo.kt` — OS detection + `amethyst.platform` override +- `PlatformAppearance.kt` — dark/light detection + `amethyst.appearance` override +- `PlatformAccent.kt` — accent detection + `amethyst.accent` override +- `PlatformFonts.kt` — system font resolution via Skia FontMgr +- `PlatformShapes.kt` — per-OS Material3 Shapes +- `PlatformTypography.kt` — per-OS Material3 Typography +- `PlatformColorScheme.kt` — per-OS dark/light ColorSchemes +- `PlatformTheme.kt` — `PlatformMaterialTheme` composable + `applyNativeWindowChrome()` From 7a9806e93671ee70d4636875f58910ee44fc2781 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 13:38:21 +0000 Subject: [PATCH 04/29] fix(desktop): extend content under macOS title bar, not above it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous approach reserved a 28dp strip across the whole top of the window for traffic lights — wasting the area right of the lights that Slack / Notion / Safari-style apps reuse for app content. Now the sidebar / NavigationRail extends to the top of the window (their surfaceContainer covers the traffic lights on the left), and the main content area (deck columns / single-pane content) also extends to the top since it's to the right of the lights. - DeckSidebar: top padding now 8dp + titleBarInsetTop so icons clear the traffic lights. - SinglePaneLayout's NavigationRail: uses its `header` slot to hold a Spacer of titleBarInsetTop, same effect. - Removed the full-width title bar strip from MainContent. - SinglePaneLayout NavigationRail container color switched from surfaceVariant to surfaceContainer to match the deck sidebar and the Material 3 sidebar convention. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../com/vitorpamplona/amethyst/desktop/Main.kt | 13 ------------- .../amethyst/desktop/ui/deck/DeckSidebar.kt | 3 ++- .../amethyst/desktop/ui/deck/SinglePaneLayout.kt | 6 +++++- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index e0ac62577..fe63d226f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1117,19 +1117,6 @@ fun MainContent( ) { Box(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) { - // macOS: reserve a title bar strip so deck/sidebar content doesn't - // underlap the traffic lights. The strip is colored to match the - // sidebar so the whole top edge reads as one continuous toolbar. - if (!isImmersive && - com.vitorpamplona.amethyst.desktop.platform.PlatformInfo.isMacOS - ) { - Box( - Modifier - .fillMaxWidth() - .height(com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop) - .background(MaterialTheme.colorScheme.surfaceContainer), - ) - } Row(Modifier.fillMaxSize().weight(1f)) { when (layoutMode) { LayoutMode.SINGLE_PANE -> { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt index 807cfd167..2bfe71a01 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -43,6 +43,7 @@ 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.components.BunkerHeartbeatIndicator +import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop import com.vitorpamplona.amethyst.desktop.ui.tor.TorStatusIndicator @Composable @@ -60,7 +61,7 @@ fun DeckSidebar( .width(56.dp) .fillMaxHeight() .background(MaterialTheme.colorScheme.surfaceContainer) - .padding(vertical = 8.dp), + .padding(top = 8.dp + titleBarInsetTop, bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, ) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 3a9fdcb77..52116250b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -53,6 +54,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher +import com.vitorpamplona.amethyst.desktop.platform.titleBarInsetTop import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback @@ -98,7 +100,9 @@ fun SinglePaneLayout( if (!isImmersive) { NavigationRail( modifier = Modifier.width(80.dp).fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.surfaceVariant, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + // macOS: push rail items below the traffic lights. + header = { Spacer(Modifier.height(titleBarInsetTop)) }, ) { val pinnedScreens by pinnedNavBarState.pinnedScreens.collectAsState() pinnedScreens.forEach { screenType -> From 6edc43237733eff2a27ac46964a396282dccc238 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 13:44:06 +0000 Subject: [PATCH 05/29] fix(desktop): macOS light-mode primary contrast + transparent window icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate readability fixes: 1. macOS light-mode primary was poor contrast on the gray background. - Background was #ECECEC (Apple's title-bar chrome gray), now #F5F5F7 (Apple's secondarySystemBackgroundColor — used for main content). - Surface container tones shifted accordingly so the sidebar (surfaceContainer) is still visibly grayer than the content. - Accents with high luminance (Apple Yellow, Graphite) now darken to a readable shade in light mode via a luminance clamp that preserves hue — saturated blue is untouched, but yellow pulls toward amber so the primary color stays visible on near-white surfaces. 2. Window icon was the generic Java cup during `./gradlew :desktopApp:run` because the Window composable had no `icon` param. The 100x100 icon in resources also looked blocky in the dock. Now: - icon.png replaced with the 512x512 transparent logo from fastlane/metadata/android/en-US/images/icon.png. - Window(icon = ...) loads it via Skia (non-deprecated path), shown in the macOS dock / Windows taskbar / GNOME & KDE task switchers. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../vitorpamplona/amethyst/desktop/Main.kt | 16 +++++ .../desktop/platform/PlatformColorScheme.kt | 57 ++++++++++++++---- desktopApp/src/jvmMain/resources/icon.png | Bin 7016 -> 16645 bytes 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index fe63d226f..56176a09f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -61,6 +61,8 @@ 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.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyShortcut import androidx.compose.ui.unit.dp @@ -252,10 +254,24 @@ fun main() { // Callback set by App() for single pane navigation from MenuBar var navigateToScreen by remember { mutableStateOf<((DeckColumnType) -> Unit)?>(null) } + // Transparent 512x512 PNG shown in the macOS dock / Windows taskbar / GNOME + // & KDE task switchers while running via gradle (the packaged app uses the + // icon.icns / icon.ico configured in nativeDistributions). + val appIcon = + remember { + val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() + val bitmap = + org.jetbrains.skia.Image + .makeFromEncoded(bytes) + .toComposeImageBitmap() + BitmapPainter(bitmap) + } + Window( onCloseRequest = ::exitApplication, state = windowState, title = "Amethyst", + icon = appIcon, ) { // macOS: transparent + full-window-content title bar so the deck/sidebar // shows through, with traffic lights still drawn on top. No-op elsewhere. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt index 5018a7f2d..6a8c3557b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformColorScheme.kt @@ -77,26 +77,59 @@ object PlatformColorScheme { outlineVariant = Color(0xFF3A3A3A), ) - private fun macOSLight(accent: Color) = - lightColorScheme( - primary = accent, - onPrimary = onAccent(accent), - secondary = accent, - tertiary = accent, - background = Color(0xFFECECEC), + private fun macOSLight(accent: Color): ColorScheme { + // Apple's accent palette includes Yellow (#F8BA00) and Graphite (#8C8C8C) + // which have terrible contrast on any light surface. Darken high-luminance + // accents so the primary color stays readable without disabling the user's + // preference — the hue is preserved, just pulled toward a usable lightness. + val readable = darkenForLight(accent) + return lightColorScheme( + primary = readable, + onPrimary = onAccent(readable), + secondary = readable, + tertiary = readable, + // Apple's light-mode main content background is near-white (#F5F5F7 / + // Apple's secondarySystemBackgroundColor); the #ECECEC gray is only + // used behind the title bar chrome — which we map to surfaceContainer. + background = Color(0xFFF5F5F7), onBackground = Color(0xFF1A1A1A), surface = Color(0xFFFFFFFF), onSurface = Color(0xFF1A1A1A), - surfaceVariant = Color(0xFFF2F2F2), + surfaceVariant = Color(0xFFECECEC), onSurfaceVariant = Color(0xFF555555), - surfaceContainer = Color(0xFFF6F6F6), - surfaceContainerHigh = Color(0xFFEDEDED), - surfaceContainerHighest = Color(0xFFE5E5E5), - surfaceContainerLow = Color(0xFFFAFAFA), + surfaceContainer = Color(0xFFECECEC), + surfaceContainerHigh = Color(0xFFE5E5E5), + surfaceContainerHighest = Color(0xFFDDDDDD), + surfaceContainerLow = Color(0xFFF0F0F0), surfaceContainerLowest = Color(0xFFFFFFFF), outline = Color(0xFFB0B0B0), outlineVariant = Color(0xFFD8D8D8), ) + } + + /** + * Scales an accent color toward black until its relative luminance is at + * most [maxLuminance]. Preserves hue (all RGB channels scale by the same + * factor). Only applied on light surfaces — on dark surfaces the raw accent + * already has good contrast. + * + * 0.38 was picked empirically: it keeps a saturated blue visible without + * darkening it, while pulling yellow/graphite down to a readable amber/gray. + */ + private fun darkenForLight( + c: Color, + maxLuminance: Float = 0.38f, + ): Color { + val lum = 0.2126f * c.red + 0.7152f * c.green + 0.0722f * c.blue + if (lum <= maxLuminance) return c + val scale = maxLuminance / lum + return Color( + red = (c.red * scale).coerceIn(0f, 1f), + green = (c.green * scale).coerceIn(0f, 1f), + blue = (c.blue * scale).coerceIn(0f, 1f), + alpha = c.alpha, + ) + } // ── GNOME (libadwaita) ──────────────────────────────────────────────────── diff --git a/desktopApp/src/jvmMain/resources/icon.png b/desktopApp/src/jvmMain/resources/icon.png index dbb5d7153df9e4fe370bfde124098a89b0582392..ffe89a16f0378d0e21e8ab3e1c8b2d46b4cf0b52 100644 GIT binary patch literal 16645 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 Y!Xv2regUZt-Yi3`&3BoVns~ Date: Fri, 24 Apr 2026 13:47:30 +0000 Subject: [PATCH 06/29] fix(desktop): set macOS dock / Cmd+Tab icon via Taskbar API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window(icon = ...) only sets the in-title-bar proxy icon — it does NOT drive the Cmd+Tab app switcher on macOS. That reads java.awt.Taskbar's iconImage, which was never set, so gradle-run sessions showed the generic Java coffee-cup square. Load the PNG via ImageIO (preserves alpha), then set Taskbar.iconImage before the application{} block starts. Guarded by isTaskbarSupported + Feature.ICON_IMAGE so the call is a no-op on platforms without it. Applies on all OSes that support the Taskbar API — macOS gets the dock icon, GNOME/KDE get the task-switcher thumbnail, Windows gets the taskbar icon. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../vitorpamplona/amethyst/desktop/Main.kt | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 56176a09f..c63db689f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -196,6 +196,26 @@ fun main() { System.setProperty("apple.awt.application.appearance", "system") } + // Set the dock / taskbar icon image before any window is shown. + // + // On macOS the Cmd+Tab app switcher and the dock use the Taskbar API's + // iconImage, NOT the Window(icon=) composable parameter (which only sets + // the in-title-bar proxy icon). Without this, a JVM launched via gradle + // shows the generic Java coffee-cup square in Cmd+Tab. ImageIO preserves + // the PNG alpha channel so the dock renders the logo with transparency. + try { + val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() + val awtImage = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) + if (awtImage != null && java.awt.Taskbar.isTaskbarSupported()) { + val taskbar = java.awt.Taskbar.getTaskbar() + if (taskbar.isSupported(java.awt.Taskbar.Feature.ICON_IMAGE)) { + taskbar.iconImage = awtImage + } + } + } catch (e: Exception) { + Log.w("Main") { "Failed to set dock icon: ${e.message}" } + } + Log.minLevel = LogLevel.DEBUG DesktopImageLoaderSetup.setup() Runtime.getRuntime().addShutdownHook( From c6cb6441031206f283a1051fb3db4c0b6e2f13f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 13:52:35 +0000 Subject: [PATCH 07/29] fix(desktop): flip Messages screen surfaces to match native convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversation list (left) was rendering on surface (white) while the chat content (right) was inheriting the window background — backwards from how Messages.app, Slack, Telegram, Discord lay out a two-pane chat: secondary surface on the list side, primary (white in light mode) on the content side. - ConversationListPane root now paints surfaceContainer. - Unselected ConversationCard goes transparent so the pane's surfaceContainer shows through; selected / focused tints re-derived from primary and onSurface so they still read on the new bg. - Right-side chat Box now explicitly paints surface. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../desktop/ui/chats/ConversationListPane.kt | 14 +++++++++++--- .../desktop/ui/chats/DesktopMessagesScreen.kt | 9 ++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index 35417f212..3e8bd2fd4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key @@ -120,6 +121,9 @@ fun ConversationListPane( modifier = modifier .fillMaxHeight() + // Chat-list pane reads as a secondary surface (like Messages.app, + // Slack, Telegram); the chat content pane to the right stays white. + .background(MaterialTheme.colorScheme.surfaceContainer) .focusRequester(focusRequester) .focusable() .onPreviewKeyEvent { event -> @@ -284,9 +288,13 @@ private fun ConversationCard( ) { val backgroundColor = when { - isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - isFocused -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - else -> MaterialTheme.colorScheme.surface + isSelected -> MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + + isFocused -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.06f) + + // Transparent lets the pane's surfaceContainer show through — the + // list pane is the "secondary surface" in this screen. + else -> Color.Transparent } Row( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 76fe06e72..b172c6303 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.desktop.ui.chats +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -286,7 +287,13 @@ private fun SplitMessagesContent( VerticalDivider(modifier = Modifier.fillMaxHeight()) - Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surface), + ) { val currentRoom = selectedRoom if (currentRoom != null) { val feedViewModel = From e83e4c4663c3b281af9e0c15d1dbe22ae4d95644 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 13:55:38 +0000 Subject: [PATCH 08/29] feat(desktop): wrap logo in macOS squircle so dock icon looks native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS Big Sur+ mandates the squircle icon template for dock / Cmd+Tab — bare transparent logos stick out next to first-party apps. PlatformAppIcon wraps the source logo in a 1024x1024 white squircle with 10% padding at runtime, only when the host OS is macOS (PlatformInfo.host, not .current — the override is for in-app theming, the dock is drawn by the real OS). No extra image file: Java2D renders the squircle from the existing icon.png so Linux / Windows paths stay untouched (they still get the raw transparent logo, which is the right call for GNOME and matches Windows 11 taskbar rendering). Apple's true shape is a superellipse; Java2D lacks a primitive for it, so RoundRectangle2D with 22.5% corner radius is the practical approx (invisible difference at dock icon sizes). Applied to both java.awt.Taskbar.iconImage (dock) and Window(icon=) (title-bar thumb). https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../vitorpamplona/amethyst/desktop/Main.kt | 29 +++-- .../desktop/platform/PlatformAppIcon.kt | 102 ++++++++++++++++++ 2 files changed, 123 insertions(+), 8 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index c63db689f..df890e6b7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -202,14 +202,21 @@ fun main() { // iconImage, NOT the Window(icon=) composable parameter (which only sets // the in-title-bar proxy icon). Without this, a JVM launched via gradle // shows the generic Java coffee-cup square in Cmd+Tab. ImageIO preserves - // the PNG alpha channel so the dock renders the logo with transparency. + // the PNG alpha channel so the dock renders the logo with transparency; + // on macOS the logo is then wrapped in a squircle so it matches + // first-party dock icons. try { val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() - val awtImage = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) - if (awtImage != null && java.awt.Taskbar.isTaskbarSupported()) { + val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) + val adapted = + raw?.let { + com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon + .adaptForHost(it) + } + if (adapted != null && java.awt.Taskbar.isTaskbarSupported()) { val taskbar = java.awt.Taskbar.getTaskbar() if (taskbar.isSupported(java.awt.Taskbar.Feature.ICON_IMAGE)) { - taskbar.iconImage = awtImage + taskbar.iconImage = adapted } } } catch (e: Exception) { @@ -274,15 +281,21 @@ fun main() { // Callback set by App() for single pane navigation from MenuBar var navigateToScreen by remember { mutableStateOf<((DeckColumnType) -> Unit)?>(null) } - // Transparent 512x512 PNG shown in the macOS dock / Windows taskbar / GNOME - // & KDE task switchers while running via gradle (the packaged app uses the - // icon.icns / icon.ico configured in nativeDistributions). + // Window title-bar / taskbar thumbnail icon. On macOS the source logo + // is wrapped in a squircle so it matches every other dock icon; on + // other platforms the raw transparent logo is used as-is. val appIcon = remember { val bytes = Unit::class.java.getResourceAsStream("/icon.png")!!.readBytes() + val raw = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) + val adapted = + com.vitorpamplona.amethyst.desktop.platform.PlatformAppIcon + .adaptForHost(raw) + val buf = java.io.ByteArrayOutputStream() + javax.imageio.ImageIO.write(adapted, "png", buf) val bitmap = org.jetbrains.skia.Image - .makeFromEncoded(bytes) + .makeFromEncoded(buf.toByteArray()) .toComposeImageBitmap() BitmapPainter(bitmap) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt new file mode 100644 index 000000000..82358de9e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +import java.awt.AlphaComposite +import java.awt.Color +import java.awt.RenderingHints +import java.awt.geom.RoundRectangle2D +import java.awt.image.BufferedImage + +/** + * Adapts a transparent source logo to the host OS's app-icon conventions. + * + * - macOS (Big Sur+) requires a "squircle" app icon template: the mark sits on + * a rounded-square background at ~22.5% corner radius with padding around the + * edges. A raw transparent logo in the dock looks out-of-place next to + * first-party apps, so we wrap it in a white squircle when running on macOS. + * - Other platforms return the source image unchanged — Windows and most Linux + * DEs render transparent icons fine, and GNOME apps specifically tend to + * avoid background shapes. + */ +object PlatformAppIcon { + /** + * Wraps [source] in a macOS-style squircle if the host OS is macOS; returns + * the source unchanged otherwise. Not scaled by [PlatformInfo.current] (the + * preview override) — the dock is drawn by the real host OS, so only the + * real host platform matters here. + */ + fun adaptForHost(source: BufferedImage): BufferedImage = + when (PlatformInfo.host) { + Platform.MACOS -> macOsSquircle(source) + else -> source + } + + /** + * Renders [source] inside a 1024x1024 white squircle with padding around + * the mark. Matches Apple's HIG macOS app icon template (Big Sur+). + * + * Implementation notes: + * - Apple's "squircle" is a superellipse, not a standard rounded rectangle; + * Java2D doesn't ship a superellipse primitive, so [RoundRectangle2D] + * with a 22.5% corner radius is the practical approximation used by most + * third-party tooling. At dock-icon sizes the difference is invisible. + * - 10% padding on each side keeps the mark from touching the squircle + * corners, matching Apple's template content area (824/1024 ≈ 80%). + */ + private fun macOsSquircle(source: BufferedImage): BufferedImage { + val size = 1024 + val cornerDiameter = (size * 0.45f) // diameter = 2 * radius; radius = 22.5% + val padding = (size * 0.10f).toInt() + val innerSize = size - 2 * padding + + val out = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB) + val g = out.createGraphics() + try { + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC) + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) + + // Paint the squircle background in white. Clip must be set BEFORE + // the fill so the fill respects the rounded corners. + val squircle = + RoundRectangle2D.Float( + 0f, + 0f, + size.toFloat(), + size.toFloat(), + cornerDiameter, + cornerDiameter, + ) + g.composite = AlphaComposite.Src + g.color = Color(0xFFFFFF) + g.fill(squircle) + + // Composite the source logo on top, scaled into the padded area. + g.composite = AlphaComposite.SrcOver + g.clip = squircle // keep the logo within the squircle on overhang + g.drawImage(source, padding, padding, innerSize, innerSize, null) + } finally { + g.dispose() + } + return out + } +} From 979c262d87d11e741d8249360ea1e4a675346d6a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 13:58:10 +0000 Subject: [PATCH 09/29] fix(desktop): drop 12dp border around single-pane content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SinglePaneLayout wrapped the main content area in `.padding(12.dp)`, leaving a visible strip of the window background around every screen. On macOS with the theming updates this strip became very noticeable: conversation list, chat content, and the NavigationRail no longer shared a single edge, and you could see `#F5F5F7` framing the whole content block. Native desktop apps don't frame content like that — padding is added inside cards / lists / dialogs, not around the window content area. Removing the wrapper padding lets the Messages two-column layout (surfaceContainer sidebar + surface chat) run edge-to-edge. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../amethyst/desktop/ui/deck/SinglePaneLayout.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt index 52116250b..7f1c67b8d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -181,9 +181,10 @@ fun SinglePaneLayout( } Column(modifier = Modifier.weight(1f).fillMaxHeight()) { - Box( - modifier = Modifier.fillMaxSize().padding(if (isImmersive) 0.dp else 12.dp), - ) { + // Content extends to the window edges; individual screens add their + // own internal padding where appropriate (Messages uses full-bleed + // panes to match native two-column chat apps). + Box(modifier = Modifier.fillMaxSize()) { // Always keep RootContent composed so state (e.g. search results) survives navigation RootContent( columnType = currentColumnType, From be8fef15dbd4208f93dcdd723268ddddbb570a41 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 13:59:29 +0000 Subject: [PATCH 10/29] fix(desktop): match Apple HIG squircle margins so dock icon isn't oversized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous impl filled the entire 1024x1024 canvas with the squircle. Apple's macOS app icon template leaves ~100px of transparent margin around a centered 824x824 squircle (content area is 80.5% of the canvas width) — which is the size the dock normalizes to when laying out next to first-party apps. Filling the whole canvas made Amethyst render ~20% larger than its neighbors in the dock and Cmd+Tab. Now matches Apple's reference geometry: - Canvas: 1024x1024 (transparent margins of 100px) - Squircle: 824x824 centered - Corner radius: ~185px (22.45% of the squircle, per HIG) - Mark: 10% padding inside the squircle https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../desktop/platform/PlatformAppIcon.kt | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt index 82358de9e..cdfd76f34 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt @@ -51,38 +51,47 @@ object PlatformAppIcon { } /** - * Renders [source] inside a 1024x1024 white squircle with padding around - * the mark. Matches Apple's HIG macOS app icon template (Big Sur+). + * Renders [source] inside a white squircle following Apple's macOS app icon + * template (Big Sur+). The squircle fills ~80.5% of the canvas width + * (824/1024 per Apple's HIG) — the rest is transparent margin, matching + * the size at which first-party dock icons render so ours doesn't appear + * oversized next to them. * * Implementation notes: * - Apple's "squircle" is a superellipse, not a standard rounded rectangle; * Java2D doesn't ship a superellipse primitive, so [RoundRectangle2D] - * with a 22.5% corner radius is the practical approximation used by most - * third-party tooling. At dock-icon sizes the difference is invisible. - * - 10% padding on each side keeps the mark from touching the squircle - * corners, matching Apple's template content area (824/1024 ≈ 80%). + * with a ~22.37% corner radius (185/824) is the practical approximation + * used by most third-party tooling. At dock-icon sizes the difference + * is invisible. + * - The mark is padded ~10% inside the squircle so it doesn't touch the + * rounded corners. */ private fun macOsSquircle(source: BufferedImage): BufferedImage { - val size = 1024 - val cornerDiameter = (size * 0.45f) // diameter = 2 * radius; radius = 22.5% - val padding = (size * 0.10f).toInt() - val innerSize = size - 2 * padding + val canvas = 1024 + // Apple's reference template: 824x824 squircle centered in a 1024x1024 + // canvas (100px transparent margin each side). + val squircleSize = 824 + val squircleMargin = (canvas - squircleSize) / 2 + // Apple's reference corner radius on the 824-box is ~185px (≈22.45%). + val cornerDiameter = (squircleSize * 0.4490f) + // Mark padding inside the squircle: 10% of the squircle size. + val markPadding = (squircleSize * 0.10f).toInt() + val markOrigin = squircleMargin + markPadding + val markSize = squircleSize - 2 * markPadding - val out = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB) + val out = BufferedImage(canvas, canvas, BufferedImage.TYPE_INT_ARGB) val g = out.createGraphics() try { g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC) g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) - // Paint the squircle background in white. Clip must be set BEFORE - // the fill so the fill respects the rounded corners. val squircle = RoundRectangle2D.Float( - 0f, - 0f, - size.toFloat(), - size.toFloat(), + squircleMargin.toFloat(), + squircleMargin.toFloat(), + squircleSize.toFloat(), + squircleSize.toFloat(), cornerDiameter, cornerDiameter, ) @@ -92,8 +101,8 @@ object PlatformAppIcon { // Composite the source logo on top, scaled into the padded area. g.composite = AlphaComposite.SrcOver - g.clip = squircle // keep the logo within the squircle on overhang - g.drawImage(source, padding, padding, innerSize, innerSize, null) + g.clip = squircle + g.drawImage(source, markOrigin, markOrigin, markSize, markSize, null) } finally { g.dispose() } From f3ef3514b2e87b05c922584c7372fc894bbd0e13 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 14:07:39 +0000 Subject: [PATCH 11/29] feat(desktop): icon mark sized to match neighbors, add drop shadow, unify screen headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Icon: - Mark now fills ~85% of the squircle (was 80%) so the goose reads at the same visual weight as first-party dock icons. Apple's template specifies 7.5-10% padding inside the squircle; 7.5% lands closer to the average first-party icon. - Baked a drop shadow into the icon PNG: 8px offset, 18px Gaussian blur at ~28% black, rendered under the white squircle on its own layer so the blur doesn't leak into the mark. First-party macOS icons include this shadow in the PNG — the dock doesn't add one at render time. Separable Gaussian (2x 1D passes) keeps startup fast. Screen header consistency (match Messages pattern): - Bookmarks, Drafts, Search, Reads, Highlights: titles switched from headlineMedium to titleMedium. - All five header rows now pad horizontal = 12.dp, vertical = 8.dp (matching ConversationListPane's "Messages" header). - Removed the 16.dp outer wrapper padding from Bookmarks and the 16.dp bottom padding from Reads header — screens now sit edge-to-edge. - DeckColumnContainer's 12.dp outer padding around column content removed for the same reason SinglePaneLayout's was: creates a `#F5F5F7` frame around every screen that reads as inconsistent with Messages (and every native desktop app). https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../desktop/platform/PlatformAppIcon.kt | 83 +++++++++++++++---- .../amethyst/desktop/ui/BookmarksScreen.kt | 4 +- .../amethyst/desktop/ui/DraftsScreen.kt | 7 +- .../amethyst/desktop/ui/ReadsScreen.kt | 7 +- .../amethyst/desktop/ui/SearchScreen.kt | 7 +- .../desktop/ui/deck/DeckColumnContainer.kt | 8 +- .../ui/highlights/MyHighlightsScreen.kt | 5 +- 7 files changed, 93 insertions(+), 28 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt index cdfd76f34..672a2a64e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt @@ -25,6 +25,8 @@ import java.awt.Color import java.awt.RenderingHints import java.awt.geom.RoundRectangle2D import java.awt.image.BufferedImage +import java.awt.image.ConvolveOp +import java.awt.image.Kernel /** * Adapts a transparent source logo to the host OS's app-icon conventions. @@ -74,10 +76,45 @@ object PlatformAppIcon { val squircleMargin = (canvas - squircleSize) / 2 // Apple's reference corner radius on the 824-box is ~185px (≈22.45%). val cornerDiameter = (squircleSize * 0.4490f) - // Mark padding inside the squircle: 10% of the squircle size. - val markPadding = (squircleSize * 0.10f).toInt() + // Mark padding inside the squircle: 7.5% each side (~85% fill) so the + // mark reads at roughly the same visual weight as first-party dock icons. + val markPadding = (squircleSize * 0.075f).toInt() val markOrigin = squircleMargin + markPadding val markSize = squircleSize - 2 * markPadding + // Drop shadow under the squircle — Apple's dock icons include a subtle + // shadow baked into the PNG; the dock doesn't add one at render time. + val shadowOffset = 8 + val shadowBlur = 18 + + val squircle = + RoundRectangle2D.Float( + squircleMargin.toFloat(), + squircleMargin.toFloat(), + squircleSize.toFloat(), + squircleSize.toFloat(), + cornerDiameter, + cornerDiameter, + ) + + // Rasterize the shadow onto its own layer so the blur doesn't leak + // into the white squircle or the mark. + val shadowLayer = BufferedImage(canvas, canvas, BufferedImage.TYPE_INT_ARGB) + shadowLayer.createGraphics().apply { + setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + color = Color(0, 0, 0, 72) // ~28% black + val shadowShape = + RoundRectangle2D.Float( + squircleMargin.toFloat(), + (squircleMargin + shadowOffset).toFloat(), + squircleSize.toFloat(), + squircleSize.toFloat(), + cornerDiameter, + cornerDiameter, + ) + fill(shadowShape) + dispose() + } + val blurredShadow = gaussianBlur(shadowLayer, shadowBlur) val out = BufferedImage(canvas, canvas, BufferedImage.TYPE_INT_ARGB) val g = out.createGraphics() @@ -86,21 +123,13 @@ object PlatformAppIcon { g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC) g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) - val squircle = - RoundRectangle2D.Float( - squircleMargin.toFloat(), - squircleMargin.toFloat(), - squircleSize.toFloat(), - squircleSize.toFloat(), - cornerDiameter, - cornerDiameter, - ) - g.composite = AlphaComposite.Src + // Shadow first, then the white squircle on top, then the mark. + g.composite = AlphaComposite.SrcOver + g.drawImage(blurredShadow, 0, 0, null) + g.color = Color(0xFFFFFF) g.fill(squircle) - // Composite the source logo on top, scaled into the padded area. - g.composite = AlphaComposite.SrcOver g.clip = squircle g.drawImage(source, markOrigin, markOrigin, markSize, markSize, null) } finally { @@ -108,4 +137,30 @@ object PlatformAppIcon { } return out } + + /** + * Separable Gaussian blur via [ConvolveOp]. Splits the 2D kernel into two + * 1D passes (horizontal then vertical) — O(N) per pixel instead of O(N²) + * for a radius-N blur, which keeps startup snappy even at 1024x1024. + */ + private fun gaussianBlur( + src: BufferedImage, + radius: Int, + ): BufferedImage { + if (radius < 1) return src + val size = radius * 2 + 1 + val sigma = radius / 2f + val kernel = FloatArray(size) + var sum = 0f + for (i in 0 until size) { + val x = (i - radius).toFloat() + kernel[i] = kotlin.math.exp(-(x * x) / (2f * sigma * sigma)) + sum += kernel[i] + } + for (i in 0 until size) kernel[i] /= sum + + val horiz = ConvolveOp(Kernel(size, 1, kernel), ConvolveOp.EDGE_NO_OP, null) + val vert = ConvolveOp(Kernel(1, size, kernel), ConvolveOp.EDGE_NO_OP, null) + return vert.filter(horiz.filter(src, null), null) + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index e692b6470..a3e4c144d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -252,12 +252,12 @@ fun BookmarksScreen( modifier = Modifier .fillMaxWidth() - .padding(16.dp), + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = "Bookmarks", - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt index 4f296d562..22b2246a4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -68,13 +68,16 @@ fun DraftsScreen( Column(modifier = Modifier.fillMaxSize()) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( "Drafts", - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) Button(onClick = { onOpenEditor(null) }) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 618579e16..d779b3a77 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -294,7 +294,10 @@ fun ReadsScreen( Column(modifier = Modifier.fillMaxSize()) { // Header — wraps on narrow columns FlowRow( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -305,7 +308,7 @@ fun ReadsScreen( ) { Text( "Reads", - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 2c80ef19b..832f8dd2d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -331,13 +331,16 @@ fun SearchScreen( // Title row Row( - modifier = Modifier.fillMaxWidth(), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( "Search", - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) Text( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index c870c2a8c..d622e3c74 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -24,7 +24,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -130,9 +129,10 @@ fun DeckColumnContainer( HorizontalDivider() - Box( - modifier = Modifier.fillMaxSize().padding(12.dp), - ) { + // Content runs edge-to-edge; each screen adds its own header padding + // to match the Messages pattern (padding(horizontal = 12, vertical = 8) + // on the title row, no outer wrapper). + Box(modifier = Modifier.fillMaxSize()) { // Always keep RootContent composed so state (e.g. search results) survives navigation RootContent( columnType = column.type, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt index 0c9835404..24d7c3c4c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -74,11 +74,12 @@ fun MyHighlightsScreen( Column(modifier = Modifier.fillMaxSize()) { Text( "Highlights", - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(8.dp)) if (allHighlights.isEmpty()) { EmptyState( From 8fb16bc7a38113d5a0daeb824dd3e3747ddaf31b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 14:11:36 +0000 Subject: [PATCH 12/29] fix(desktop): bump macOS icon mark to 90% of squircle Mark padding inside the squircle down from 7.5% to 5% each side, so the goose fills the squircle a bit more fully and matches the visual weight of neighboring dock icons. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../amethyst/desktop/platform/PlatformAppIcon.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt index 672a2a64e..aa6adf65e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformAppIcon.kt @@ -76,9 +76,9 @@ object PlatformAppIcon { val squircleMargin = (canvas - squircleSize) / 2 // Apple's reference corner radius on the 824-box is ~185px (≈22.45%). val cornerDiameter = (squircleSize * 0.4490f) - // Mark padding inside the squircle: 7.5% each side (~85% fill) so the + // Mark padding inside the squircle: 5% each side (~90% fill) so the // mark reads at roughly the same visual weight as first-party dock icons. - val markPadding = (squircleSize * 0.075f).toInt() + val markPadding = (squircleSize * 0.05f).toInt() val markOrigin = squircleMargin + markPadding val markSize = squircleSize - 2 * markPadding // Drop shadow under the squircle — Apple's dock icons include a subtle From b198385849532e7ad5fc89f7bf990272dcb32f64 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 14:23:32 +0000 Subject: [PATCH 13/29] feat(desktop): tabs-first header for Home / Reads, compact Notifications header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactored the three feed screen headers so they all match the Messages pattern — single compact row padded horizontal = 12, vertical = 8, no oversized titles, no multi-row metadata. FeedScreen (Home): - Replaced the `headlineMedium` "Global Feed" / "Following Feed" title plus a redundant Global/Following chip row plus a separate relay-count meta-line plus a large "New Post" button with: [ Following | Global ] [relays] [refresh] [+ compose] The selected tab IS the screen title. Relay count + followed-user count surface through a hover tooltip on the relays icon (desktop convention; info is preserved without taking a whole row). - Relays icon click opens the picker when the account can edit, falls through to navigate-to-relays otherwise. ReadsScreen: - Same treatment: dropped the "Reads" label, lifted Following/Global chips to the left, single refresh icon on the right. "N relays connected" moved to the refresh button's content description. commons/FeedHeader (used by NotificationsScreen): - Dropped the RelayStatusIndicator (icon + count text + refresh button, took 3 slots). Now just titleMedium on the left + a single refresh IconButton on the right with relay count in its content description, matching the Messages "titleMedium + icon buttons" rhythm. - Internal padding(horizontal = 12, vertical = 8) baked in so callers don't need a trailing Spacer. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../amethyst/commons/ui/feed/FeedHeader.kt | 60 ++---- .../amethyst/desktop/ui/FeedScreen.kt | 181 +++++++++--------- .../desktop/ui/NotificationsScreen.kt | 2 - .../amethyst/desktop/ui/ReadsScreen.kt | 80 +++----- 4 files changed, 134 insertions(+), 189 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt index 835f5910d..23e2f8ccb 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.ui.feed import androidx.compose.foundation.layout.Arrangement 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.material3.Icon import androidx.compose.material3.IconButton @@ -34,10 +35,12 @@ import androidx.compose.ui.Modifier 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.RelayStatusColors /** - * Header component for feed screens with title and relay connection status. + * Compact Messages-style header for feed screens: titleMedium on the left, + * a single refresh IconButton on the right. Relay count is rolled into the + * refresh button's content description so it still surfaces in hover tooltips + * / accessibility readers without stealing a whole row. * * @param title The feed title * @param connectedRelayCount Number of connected relays @@ -52,62 +55,25 @@ fun FeedHeader( modifier: Modifier = Modifier, ) { Row( - modifier = modifier.fillMaxWidth(), + modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( title, - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) - RelayStatusIndicator( - connectedCount = connectedRelayCount, - onRefresh = onRefresh, - ) - } -} - -/** - * Compact relay connection status indicator with refresh button. - */ -@Composable -fun RelayStatusIndicator( - connectedCount: Int, - onRefresh: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - val statusColor = - when { - connectedCount == 0 -> RelayStatusColors.Disconnected - connectedCount < 3 -> RelayStatusColors.Connecting - else -> RelayStatusColors.Connected - } - - Icon( - symbol = if (connectedCount > 0) MaterialSymbols.Check else MaterialSymbols.Close, - contentDescription = null, - tint = statusColor, - modifier = Modifier.size(16.dp), - ) - - Text( - "$connectedCount relay${if (connectedCount != 1) "s" else ""}", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall, - ) - - IconButton(onClick = onRefresh) { + IconButton( + onClick = onRefresh, + modifier = Modifier.size(32.dp), + ) { Icon( MaterialSymbols.Refresh, - contentDescription = "Reconnect", + contentDescription = "Refresh ($connectedRelayCount relays connected)", tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 727fb9eb5..ea9c72bf0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -20,12 +20,11 @@ */ package com.vitorpamplona.amethyst.desktop.ui -import androidx.compose.foundation.clickable +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -33,15 +32,14 @@ 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.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -489,7 +487,6 @@ fun FeedScreen( onDispose { subId?.let { coordinator.releaseInteractions(it) } } } - @OptIn(ExperimentalLayoutApi::class) Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { // Header with compose button @@ -629,9 +626,15 @@ fun FeedScreen( } /** - * Feed header with title, mode selector, relay count, and compose button. + * Feed header \u2014 Messages-style single row: Following/Global tabs on the left, + * compact icon buttons on the right (relays, refresh, compose). The selected + * tab IS the title; no redundant "Global Feed" / "Following Feed" label. + * + * Relay count and followed-users count are surfaced via a hover tooltip on + * the relays icon (desktop convention \u2014 the at-a-glance info is preserved + * without stealing header real estate). */ -@OptIn(ExperimentalLayoutApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable private fun FeedHeader( feedMode: FeedMode, @@ -644,90 +647,96 @@ private fun FeedHeader( onNavigateToRelays: () -> Unit = {}, onOpenRelayPicker: () -> Unit = {}, ) { - FlowRow( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Column { - FlowRow( - verticalArrangement = Arrangement.Center, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - if (feedMode == FeedMode.GLOBAL) "Global Feed" else "Following Feed", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, + // Tabs \u2014 the selected one is the screen title. + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (account != null) { + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { onFeedModeChange(FeedMode.FOLLOWING) }, + label = { Text("Following") }, ) - - if (account != null) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - FilterChip( - selected = feedMode == FeedMode.GLOBAL, - onClick = { onFeedModeChange(FeedMode.GLOBAL) }, - label = { Text("Global") }, - ) - FilterChip( - selected = feedMode == FeedMode.FOLLOWING, - onClick = { onFeedModeChange(FeedMode.FOLLOWING) }, - label = { Text("Following") }, - ) - } - } - } - - Spacer(Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "${feedRelays.size} relays", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - modifier = - Modifier.clickable { onNavigateToRelays() }, - ) - if (feedMode == FeedMode.FOLLOWING) { - Text( - " \u2022 $followedUsersCount followed", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.width(8.dp)) - if (account != null && !account.isReadOnly) { - IconButton( - onClick = onOpenRelayPicker, - modifier = Modifier.size(24.dp), - ) { - Icon( - MaterialSymbols.Dns, - contentDescription = "Edit Feed Relays", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } - Spacer(Modifier.width(4.dp)) - } - IconButton( - onClick = onRefresh, - modifier = Modifier.size(24.dp), - ) { - Icon( - MaterialSymbols.Refresh, - contentDescription = "Refresh", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } } + FilterChip( + selected = feedMode == FeedMode.GLOBAL, + onClick = { onFeedModeChange(FeedMode.GLOBAL) }, + label = { Text("Global") }, + ) } - Button( - onClick = onCompose, - enabled = account != null && !account.isReadOnly, - ) { - Icon(MaterialSymbols.Add, "New Post", Modifier.size(18.dp)) - Spacer(Modifier.width(8.dp)) - Text("New Post") + // Actions \u2014 compact icon buttons at the same scale as the Messages header. + Row(verticalAlignment = Alignment.CenterVertically) { + val relaysTooltip = + buildString { + append("${feedRelays.size} relays") + if (feedMode == FeedMode.FOLLOWING) { + append(" \u2022 $followedUsersCount followed") + } + } + TooltipArea( + tooltip = { + Surface( + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 2.dp, + shadowElevation = 4.dp, + ) { + Text( + relaysTooltip, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + }, + ) { + IconButton( + onClick = { + if (account != null && !account.isReadOnly) { + onOpenRelayPicker() + } else { + onNavigateToRelays() + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + MaterialSymbols.Dns, + contentDescription = relaysTooltip, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } + + IconButton( + onClick = onRefresh, + modifier = Modifier.size(32.dp), + ) { + Icon( + MaterialSymbols.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + + if (account != null && !account.isReadOnly) { + IconButton( + onClick = onCompose, + modifier = Modifier.size(32.dp), + ) { + Icon( + MaterialSymbols.Add, + contentDescription = "New Post", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index ef1e1d677..6c19e5833 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -245,8 +245,6 @@ fun NotificationsScreen( onRefresh = { relayManager.connect() }, ) - Spacer(Modifier.height(16.dp)) - if (connectedRelays.isEmpty()) { LoadingState("Connecting to relays...") } else if (notifications.isEmpty() && !initialLoadComplete) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index d779b3a77..a85b53674 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -23,8 +23,6 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -32,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.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.Card @@ -290,70 +287,45 @@ fun ReadsScreen( } } - @OptIn(ExperimentalLayoutApi::class) Column(modifier = Modifier.fillMaxSize()) { - // Header — wraps on narrow columns - FlowRow( + // Header — Messages-style: tabs left, refresh right. The selected tab + // (Following / Global) acts as the screen title, so no separate label. + Row( modifier = Modifier .fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Column { - FlowRow( - verticalArrangement = Arrangement.Center, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - "Reads", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (account != null) { + FilterChip( + selected = feedMode == FeedMode.FOLLOWING, + onClick = { feedMode = FeedMode.FOLLOWING }, + label = { Text("Following") }, ) - - // Feed mode selector - if (account != null) { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - FilterChip( - selected = feedMode == FeedMode.GLOBAL, - onClick = { feedMode = FeedMode.GLOBAL }, - label = { Text("Global") }, - ) - FilterChip( - selected = feedMode == FeedMode.FOLLOWING, - onClick = { feedMode = FeedMode.FOLLOWING }, - label = { Text("Following") }, - ) - } - } } + FilterChip( + selected = feedMode == FeedMode.GLOBAL, + onClick = { feedMode = FeedMode.GLOBAL }, + label = { Text("Global") }, + ) + } - Spacer(Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "${connectedRelays.size} relays connected", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(8.dp)) - IconButton( - onClick = { relayManager.connect() }, - modifier = Modifier.size(24.dp), - ) { - Icon( - MaterialSymbols.Refresh, - contentDescription = "Refresh", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } - } + IconButton( + onClick = { relayManager.connect() }, + modifier = Modifier.size(32.dp), + ) { + Icon( + MaterialSymbols.Refresh, + contentDescription = "Refresh (${connectedRelays.size} relays connected)", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) } } - Spacer(Modifier.height(8.dp)) - when { connectedRelays.isEmpty() -> { LoadingState("Connecting to relays...") From ff12b6abbe7b17609ae5ad367b2a86518d1162dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 14:38:43 +0000 Subject: [PATCH 14/29] feat(desktop): Messages-style headers across every remaining screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rolled the compact header pattern out to the screens the earlier pass missed. Each one now lives on the same padding/typography rhythm as Messages: Row(fillMaxWidth, padding horizontal = 12, vertical = 8), titleMedium for labels, IconButton size = 32dp with 20dp icons tinted with colorScheme.primary. - ThreadScreen: back button + "Thread" title. Was headlineMedium with bottom-only padding; now compact row. - UserProfileScreen: back + "Profile" title on the left, Edit / Follow buttons kept on the right (they stay as text buttons — they represent destructive intent, not icon affordances). - ArticleReaderScreen: back + "Article" title; zoom % label still surfaces next to the title when != 100%. - ArticleEditorScreen: back IconButton + "Article" title (was a text "Back" OutlinedButton with no title). Save / Publish buttons kept on the right — same reasoning as UserProfileScreen. - ChessScreen: back (conditional) + "Chess" / "Live Game" title; refresh + New Game converted from Button → IconButton so the header reads at the same scale as the rest. - RelayDashboardScreen: had no header at all, just a PrimaryTabRow. Converted Monitor / Configure to FilterChip tabs-first (matching Feed / Reads) so the selected tab is the title. FeedHeader relocation: - Moved commons/ui/feed/FeedHeader.kt → desktopApp/ui/FeedHeader.kt. Only NotificationsScreen.kt referenced it, and dropping the import from that file was enough because it's now in the same package. - Removed the empty commons/ui/feed/ directory. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../amethyst/desktop/chess/ChessScreen.kt | 45 +++++++++++-------- .../desktop/ui/ArticleEditorScreen.kt | 34 +++++++++++--- .../desktop/ui/ArticleReaderScreen.kt | 11 ++--- .../amethyst/desktop/ui}/FeedHeader.kt | 2 +- .../desktop/ui/NotificationsScreen.kt | 1 - .../amethyst/desktop/ui/ThreadScreen.kt | 11 ++--- .../amethyst/desktop/ui/UserProfileScreen.kt | 15 ++++--- .../desktop/ui/relay/RelayDashboardScreen.kt | 22 ++++++--- 8 files changed, 92 insertions(+), 49 deletions(-) rename {commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed => desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui}/FeedHeader.kt (98%) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 5e50bcf17..4f00c44cb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.fillMaxSize 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.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -38,7 +39,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -179,42 +179,49 @@ fun ChessScreen( var showNewGameDialog by remember { mutableStateOf(false) } Column(modifier = Modifier.fillMaxSize()) { - // Header + // Header — Messages-style: compact row, titleMedium title, icon-only actions Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Row(verticalAlignment = Alignment.CenterVertically) { if (selectedGameId != null) { - IconButton(onClick = { viewModel.selectGame(null) }) { - Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back to list") + IconButton(onClick = { viewModel.selectGame(null) }, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = "Back to list", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) } Spacer(Modifier.width(8.dp)) } Text( if (selectedGameId != null) "Live Game" else "Chess", - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) } if (selectedGameId == null) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Refresh button - IconButton(onClick = { viewModel.forceRefresh() }) { - Icon(MaterialSymbols.Refresh, "Refresh") + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { viewModel.forceRefresh() }, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.Refresh, + contentDescription = "Refresh", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) } - - // New Game button if (!account.isReadOnly) { - Button(onClick = { showNewGameDialog = true }) { - Icon(MaterialSymbols.Add, "New Game") - Spacer(Modifier.width(8.dp)) - Text("New Game") + IconButton(onClick = { showNewGameDialog = true }, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.Add, + contentDescription = "New Game", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt index 965b25821..6812d5226 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleEditorScreen.kt @@ -29,12 +29,15 @@ import androidx.compose.foundation.layout.fillMaxSize 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.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -63,6 +66,8 @@ import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownEditorState import com.vitorpamplona.amethyst.commons.compose.editor.MarkdownToolbar import com.vitorpamplona.amethyst.commons.compose.editor.MetadataPanel import com.vitorpamplona.amethyst.commons.compose.markdown.RenderMarkdown +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip23LongContent.LongFormPublishAction import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager @@ -227,24 +232,41 @@ fun ArticleEditorScreen( } }, ) { - // Top bar + // Header — Messages-style: back + titleMedium on left, actions on right Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - OutlinedButton(onClick = onBack) { - Text("Back") + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + Spacer(Modifier.width(8.dp)) + Text( + "Article", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { saveMessage?.let { Text( it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.align(Alignment.CenterVertically), ) } + // Save/Publish stay as text buttons — they're primary destructive + // actions, not affordances you'd reduce to an icon. OutlinedButton(onClick = { saveDraft() }) { Text("Save") } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt index a63511ac4..1e8804efd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ArticleReaderScreen.kt @@ -404,24 +404,25 @@ fun ArticleReaderScreen( } }, ) { - // Top bar: back + bookmark placeholder + // Header — Messages-style: compact row, titleMedium title Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { + IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { Icon( MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = "Back", - modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), ) } Spacer(Modifier.width(8.dp)) Text( "Article", - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) if (zoomLevel != 1.0f) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedHeader.kt similarity index 98% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedHeader.kt index 23e2f8ccb..802d7eb12 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedHeader.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.feed +package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 6c19e5833..9cfd88660 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -56,7 +56,6 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPa import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.ui.components.EmptyState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState -import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader import com.vitorpamplona.amethyst.commons.util.toTimeAgo import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 0c7489a76..0c3ec0322 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -198,22 +198,23 @@ fun ThreadScreen( Box(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) { - // Header with back button + // Header — Messages-style: compact row with back + titleMedium Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - IconButton(onClick = onBack) { + IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { Icon( MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = "Back", - modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), ) } Spacer(Modifier.width(8.dp)) Text( "Thread", - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 41348b130..c961d1882 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -458,21 +458,26 @@ fun UserProfileScreen( ) } - // Header with back button + // Header — Messages-style: compact row, titleMedium title item(key = "header") { Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack) { - Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back") + IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) } Spacer(Modifier.width(8.dp)) Text( "Profile", - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.titleMedium, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDashboardScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDashboardScreen.kt index fdff5c1a7..f2d6163f4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDashboardScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayDashboardScreen.kt @@ -20,11 +20,13 @@ */ package com.vitorpamplona.amethyst.desktop.ui.relay +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.PrimaryTabRow -import androidx.compose.material3.Tab +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.FilterChip import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -34,7 +36,9 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.desktop.model.DesktopAccountRelays import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager @@ -50,7 +54,6 @@ enum class DashboardTab( CONFIGURE("Configure"), } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun RelayDashboardScreen( relayManager: DesktopRelayConnectionManager, @@ -79,12 +82,17 @@ fun RelayDashboardScreen( } Column(modifier = modifier.fillMaxSize()) { - PrimaryTabRow(selectedTabIndex = DashboardTab.entries.indexOf(selectedTab)) { + // Header — Messages-style tabs-first: selected chip acts as the screen title. + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { DashboardTab.entries.forEach { tab -> - Tab( + FilterChip( selected = selectedTab == tab, onClick = { selectedTab = tab }, - text = { Text(tab.label) }, + label = { Text(tab.label) }, ) } } From 08f9c8bfd45f37aed7d17096ec24f3bc6781768e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 14:55:20 +0000 Subject: [PATCH 15/29] feat(desktop): 12dp horizontal gutter around feed/list content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Content cards were butting up against the nav rail / window edge. Added contentPadding = PaddingValues(horizontal = 12.dp) to every scroll container so items sit inset by the same amount the header row already uses — a consistent gutter the eye can anchor to. Applied to: FeedScreen, ReadsScreen, NotificationsScreen, BookmarksScreen, DraftsScreen, SearchScreen, MyHighlightsScreen, UserProfileScreen, ThreadScreen, ChessScreen, RelayMetricsTab. RelayConfigTab (verticalScroll Column) got the same horizontal padding via its content modifier. Messages is untouched — its two-pane layout with an internal VerticalDivider already provides its own visual separation. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt | 2 ++ .../com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt | 2 ++ .../com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt | 2 ++ .../kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt | 2 ++ .../vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt | 2 ++ .../kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt | 2 ++ .../com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt | 2 ++ .../com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt | 2 ++ .../com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt | 2 ++ .../amethyst/desktop/ui/highlights/MyHighlightsScreen.kt | 2 ++ .../vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt | 2 +- .../vitorpamplona/amethyst/desktop/ui/relay/RelayMetricsTab.kt | 2 ++ 12 files changed, 23 insertions(+), 1 deletion(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 4f00c44cb..6f549e41f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -416,6 +417,7 @@ private fun ChessLobby( LazyColumn( state = listState, + contentPadding = PaddingValues(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { // Active games section (user is participant) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index a3e4c144d..e6bd5355a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -303,6 +304,7 @@ fun BookmarksScreen( else -> { LazyColumn( modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 12.dp), ) { items(currentEvents, key = { it.id }) { event -> Column( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt index 22b2246a4..0721982dd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -96,6 +97,7 @@ fun DraftsScreen( ) } else { LazyColumn( + contentPadding = PaddingValues(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(drafts, key = { it.slug }) { entry -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index ea9c72bf0..90466bb21 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -546,6 +547,7 @@ fun FeedScreen( is FeedState.Loaded -> { val loadedState by state.feed.collectAsState() LazyColumn( + contentPadding = PaddingValues(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(loadedState.list, key = { it.idHex }) { note -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 9cfd88660..ee62d0b96 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -256,6 +257,7 @@ fun NotificationsScreen( ) } else { LazyColumn( + contentPadding = PaddingValues(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index a85b53674..e55af2e42 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -359,6 +360,7 @@ fun ReadsScreen( else -> { LazyColumn( + contentPadding = PaddingValues(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(events, key = { it.id }) { event -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 832f8dd2d..f9b812fd4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -523,6 +524,7 @@ private fun SearchEmptyState( ) { LazyColumn( modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { // Saved searches diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 0c3ec0322..b3e855c01 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -239,6 +240,7 @@ fun ThreadScreen( else -> { LazyColumn( + contentPadding = PaddingValues(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(0.dp), ) { // Root note diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index c961d1882..f922a8fed 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -441,6 +442,7 @@ fun UserProfileScreen( } else { LazyColumn( state = listState, + contentPadding = PaddingValues(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxSize(), ) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt index 24d7c3c4c..b8bb5129b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.desktop.ui.highlights import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -88,6 +89,7 @@ fun MyHighlightsScreen( ) } else { LazyColumn( + contentPadding = PaddingValues(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { allHighlights.forEach { (addressTag, highlights) -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt index ea22d6b16..65e87046b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayConfigTab.kt @@ -73,7 +73,7 @@ fun RelayConfigTab( modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(top = 8.dp), + .padding(horizontal = 12.dp, vertical = 8.dp), ) { // 1. Connected Relays (collapsed by default to show other sections) CollapsibleSection( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricsTab.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricsTab.kt index cdda77c4d..901c3da6a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricsTab.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricsTab.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.desktop.ui.relay import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -92,6 +93,7 @@ fun RelayMetricsTab( LazyColumn( modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(statuses, key = { it.url.url }) { status -> From d212b88103ca378ce1768d4d30582a067ffa1dfc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 15:11:48 +0000 Subject: [PATCH 16/29] feat(desktop): ReadingColumn width cap, conditional Profile back, card styling pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces ReadingColumn, a top-level scaffold that caps feed/list screens at 720 dp and centers them — matches the Twitter/Mastodon desktop pattern so cards don't stretch disproportionately on 4K displays. Applied to: Home, Reads, Notifications, Bookmarks, Drafts, Highlights, Search, Thread, Profile, Settings. Messages stays full-width (its own two-pane sizing). Chess, Relay Dashboard, Article Reader/Editor keep their current full-width layouts (tools/reading logic dictates width independently). Header consistency: - Bookmarks / Drafts / Highlights / Search now have a minimum header row height of 48dp so screens without action buttons sit at the same visual weight as screens with IconButtons. - Drafts' "New Draft" button converted from text Button to IconButton for consistency with the other screens' icon-only actions. - Settings title switched from headlineMedium to titleMedium and wrapped in the standard h=12/v=8 header row. Profile back button: - UserProfileScreen now takes canGoBack: Boolean = false. The back arrow only renders when stacked onto a nav stack (clicking a user in the feed / notifications). Top-level "My Profile" accessed via the nav rail has no back arrow — nothing above it to pop. - Applied to both the in-header back button and the floating scroll-aware header that appears when scrolling through posts. Home cards: - NoteCard switched from surfaceVariant fill (gray) to surface (white) + 1dp elevation, matching LongFormCard on Reads. The subtle shadow gives the feed the same lift as the articles screen. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../vitorpamplona/amethyst/desktop/Main.kt | 369 ++++---- .../amethyst/desktop/ui/BookmarksScreen.kt | 4 +- .../amethyst/desktop/ui/DraftsScreen.kt | 19 +- .../amethyst/desktop/ui/FeedScreen.kt | 2 +- .../desktop/ui/NotificationsScreen.kt | 3 +- .../amethyst/desktop/ui/ReadingColumn.kt | 67 ++ .../amethyst/desktop/ui/ReadsScreen.kt | 3 +- .../amethyst/desktop/ui/SearchScreen.kt | 417 ++++----- .../amethyst/desktop/ui/ThreadScreen.kt | 2 +- .../amethyst/desktop/ui/UserProfileScreen.kt | 846 +++++++++--------- .../desktop/ui/deck/DeckColumnContainer.kt | 1 + .../ui/highlights/MyHighlightsScreen.kt | 26 +- .../amethyst/desktop/ui/note/NoteCard.kt | 3 +- 13 files changed, 946 insertions(+), 816 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index df890e6b7..a82aa523e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -29,7 +29,9 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -1319,213 +1321,232 @@ fun RelaySettingsScreen( accountManager.loadNwcConnection() } - Column( - modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()), + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, ) { - Text( - "Settings", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - - Spacer(Modifier.height(24.dp)) - - // Wallet Connect Section - Text( - "Wallet Connect (NWC)", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Spacer(Modifier.height(8.dp)) - - Text( - "Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(Modifier.height(12.dp)) - - if (nwcConnection != null) { + Column( + modifier = + Modifier + .fillMaxSize() + .widthIn(max = 720.dp) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 12.dp), + ) { Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column { - Text( - "Wallet Connected", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - ) - Text( - "Relay: ${nwcConnection!!.relayUri.url}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - OutlinedButton( - onClick = { accountManager.clearNwcConnection() }, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), + Text( + "Settings", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + + Spacer(Modifier.height(16.dp)) + + // Wallet Connect Section + Text( + "Wallet Connect (NWC)", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + Text( + "Connect a Lightning wallet to enable zaps. Get a connection string from Alby, Mutiny, or other NWC-compatible wallets.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(12.dp)) + + if (nwcConnection != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text("Disconnect") + Column { + Text( + "Wallet Connected", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + "Relay: ${nwcConnection!!.relayUri.url}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + OutlinedButton( + onClick = { accountManager.clearNwcConnection() }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Disconnect") + } + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = nwcInput, + onValueChange = { + nwcInput = it + nwcError = null + }, + label = { Text("NWC Connection String") }, + placeholder = { Text("nostr+walletconnect://...") }, + modifier = Modifier.weight(1f), + singleLine = true, + isError = nwcError != null, + supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, + ) + Button( + onClick = { + val result = accountManager.setNwcConnection(nwcInput) + result.fold( + onSuccess = { nwcInput = "" }, + onFailure = { nwcError = it.message ?: "Invalid connection string" }, + ) + }, + enabled = nwcInput.isNotBlank(), + ) { + Text("Connect") + } } } - } else { + + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + + // Media Server Settings + MediaServerSettings( + initialServers = DesktopPreferences.blossomServers, + onServersChanged = { DesktopPreferences.blossomServers = it }, + ) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + + // Tor Settings + com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection( + torStatus = torStatus, + currentSettings = torSettings, + onSettingsChanged = onTorSettingsChanged, + ) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + + // Developer Settings Section (only in debug mode) + if (DebugConfig.isDebugMode) { + com.vitorpamplona.amethyst.desktop.ui + .DevSettingsSection(account = account) + Spacer(Modifier.height(24.dp)) + HorizontalDivider() + Spacer(Modifier.height(24.dp)) + } + + Text( + "Relay Settings", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "${connectedRelays.size} of ${relayStatuses.size} relays connected", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + IconButton(onClick = { relayManager.connect() }) { + Icon( + MaterialSymbols.Refresh, + contentDescription = "Reconnect", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + Spacer(Modifier.height(16.dp)) + Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( - value = nwcInput, - onValueChange = { - nwcInput = it - nwcError = null - }, - label = { Text("NWC Connection String") }, - placeholder = { Text("nostr+walletconnect://...") }, + value = newRelayUrl, + onValueChange = { newRelayUrl = it }, + label = { Text("Add relay") }, + placeholder = { Text("wss://relay.example.com") }, modifier = Modifier.weight(1f), singleLine = true, - isError = nwcError != null, - supportingText = nwcError?.let { { Text(it, color = MaterialTheme.colorScheme.error) } }, ) Button( onClick = { - val result = accountManager.setNwcConnection(nwcInput) - result.fold( - onSuccess = { nwcInput = "" }, - onFailure = { nwcError = it.message ?: "Invalid connection string" }, - ) + if (newRelayUrl.isNotBlank()) { + relayManager.addRelay(newRelayUrl) + newRelayUrl = "" + } }, - enabled = nwcInput.isNotBlank(), + enabled = newRelayUrl.isNotBlank(), ) { - Text("Connect") + Text("Add") } } - } - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(16.dp)) - // Media Server Settings - MediaServerSettings( - initialServers = DesktopPreferences.blossomServers, - onServersChanged = { DesktopPreferences.blossomServers = it }, - ) - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - - // Tor Settings - com.vitorpamplona.amethyst.desktop.ui.tor.TorSettingsSection( - torStatus = torStatus, - currentSettings = torSettings, - onSettingsChanged = onTorSettingsChanged, - ) - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - - // Developer Settings Section (only in debug mode) - if (DebugConfig.isDebugMode) { - com.vitorpamplona.amethyst.desktop.ui - .DevSettingsSection(account = account) - Spacer(Modifier.height(24.dp)) - HorizontalDivider() - Spacer(Modifier.height(24.dp)) - } - - Text( - "Relay Settings", - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - Spacer(Modifier.height(8.dp)) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - "${connectedRelays.size} of ${relayStatuses.size} relays connected", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - IconButton(onClick = { relayManager.connect() }) { - Icon( - MaterialSymbols.Refresh, - contentDescription = "Reconnect", - tint = MaterialTheme.colorScheme.primary, - ) - } - } - - Spacer(Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = newRelayUrl, - onValueChange = { newRelayUrl = it }, - label = { Text("Add relay") }, - placeholder = { Text("wss://relay.example.com") }, + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f), - singleLine = true, - ) - Button( - onClick = { - if (newRelayUrl.isNotBlank()) { - relayManager.addRelay(newRelayUrl) - newRelayUrl = "" - } - }, - enabled = newRelayUrl.isNotBlank(), ) { - Text("Add") + items(relayStatuses.values.toList(), key = { it.url.url }) { status -> + RelayStatusCard( + status = status, + onRemove = { relayManager.removeRelay(status.url) }, + ) + } } - } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(16.dp)) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.weight(1f), - ) { - items(relayStatuses.values.toList(), key = { it.url.url }) { status -> - RelayStatusCard( - status = status, - onRemove = { relayManager.removeRelay(status.url) }, - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { relayManager.addDefaultRelays() }) { + Text("Reset to Defaults") + } } - } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(16.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = { relayManager.addDefaultRelays() }) { - Text("Reset to Defaults") + val logoutScope = rememberCoroutineScope() + OutlinedButton( + onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Text("Logout") } } - - Spacer(Modifier.height(16.dp)) - - val logoutScope = rememberCoroutineScope() - OutlinedButton( - onClick = { logoutScope.launch { accountManager.logout(deleteKey = true) } }, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text("Logout") - } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index e6bd5355a..8c418e309 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -247,12 +248,13 @@ fun BookmarksScreen( val currentEvents = if (selectedTab == BookmarkTab.PUBLIC) publicEvents else privateEvents val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds - Column(modifier = Modifier.fillMaxSize()) { + ReadingColumn { // Header with tabs Row( modifier = Modifier .fillMaxWidth() + .heightIn(min = 48.dp) .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt index 0721982dd..5668a93a6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -26,14 +26,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -67,11 +67,12 @@ fun DraftsScreen( val scope = rememberCoroutineScope() var deleteTarget by remember { mutableStateOf(null) } - Column(modifier = Modifier.fillMaxSize()) { + ReadingColumn { Row( modifier = Modifier .fillMaxWidth() + .heightIn(min = 48.dp) .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, @@ -81,9 +82,15 @@ fun DraftsScreen( style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) - Button(onClick = { onOpenEditor(null) }) { - Icon(MaterialSymbols.Add, contentDescription = null) - Text("New Draft", modifier = Modifier.padding(start = 4.dp)) + // Convert "New Draft" button to an icon for consistency with other + // screens' tabs-first + icon-actions header pattern. + IconButton(onClick = { onOpenEditor(null) }, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.Add, + contentDescription = "New Draft", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 90466bb21..f8542c338 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -489,7 +489,7 @@ fun FeedScreen( } Box(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize()) { + ReadingColumn { // Header with compose button FeedHeader( feedMode = feedMode, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index ee62d0b96..6a7395e89 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -238,7 +237,7 @@ fun NotificationsScreen( } } - Column(modifier = Modifier.fillMaxSize()) { + ReadingColumn { FeedHeader( title = "Notifications", connectedRelayCount = connectedRelays.size, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt new file mode 100644 index 000000000..7f6bb82f5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Maximum reading width for single-pane content screens. Matches the + * comfortable column width used by Twitter / Mastodon / Threads on desktop — + * wider than a book column (which tops out around 600 dp), narrower than a + * full-window feed, so cards don't stretch disproportionately on 4K displays. + */ +val DefaultReadingWidth: Dp = 720.dp + +/** + * A top-level content scaffold that caps width and centers its column on wide + * displays. Each feed / list / profile screen wraps its contents in this so + * cards maintain a consistent proportion across the whole app. + * + * Not used by: + * - Messages (two-pane layout with its own sizing) + * - Article Reader (has its own narrower reading-width logic) + * - Editor / Chess / Relay Dashboard (rely on full width for tools / boards) + */ +@Composable +fun ReadingColumn( + modifier: Modifier = Modifier, + maxWidth: Dp = DefaultReadingWidth, + content: @Composable ColumnScope.() -> Unit, +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter, + ) { + Column( + modifier = modifier.fillMaxSize().widthIn(max = maxWidth), + content = content, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index e55af2e42..9b015f0b9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -288,7 +287,7 @@ fun ReadsScreen( } } - Column(modifier = Modifier.fillMaxSize()) { + ReadingColumn { // Header — Messages-style: tabs left, refresh right. The selected tab // (Following / Global) acts as the screen title, so no separate label. Row( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index f9b812fd4..e980ccb2d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -34,9 +34,11 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -289,227 +291,236 @@ fun SearchScreen( focusRequester.requestFocus() } - Column( + androidx.compose.foundation.layout.Box( modifier = - modifier - .fillMaxSize() - .onPreviewKeyEvent { event -> - if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false - when (event.key) { - Key.Escape -> { - if (panelExpanded) { - state.togglePanel() - } else if (displayText.isNotEmpty()) { - state.clearSearch() - } - true - } - - else -> { - false - } - } - }, + androidx.compose.ui.Modifier + .fillMaxSize(), + contentAlignment = androidx.compose.ui.Alignment.TopCenter, ) { - // Progress bar at very top - AnimatedVisibility( - visible = isSearching, - enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), - exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), - ) { - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.primary, - trackColor = MaterialTheme.colorScheme.surfaceVariant, - ) - } - - // Relay status banner - SearchSyncBanner( - relayStates = relayStates, - isSearching = isSearching, - ) - - // Title row - Row( + Column( modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - "Search", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - ) - Text( - "${localCache.userCount()} users cached", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + modifier + .fillMaxSize() + .widthIn(max = DefaultReadingWidth) + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + when (event.key) { + Key.Escape -> { + if (panelExpanded) { + state.togglePanel() + } else if (displayText.isNotEmpty()) { + state.clearSearch() + } + true + } - Spacer(Modifier.height(16.dp)) - - // Search bar with advanced toggle - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + else -> { + false + } + } + }, ) { - OutlinedTextField( - value = textFieldValue, - onValueChange = { - textFieldValue = it - state.updateFromText(it.text) - }, - modifier = Modifier.weight(1f).focusRequester(focusRequester), - placeholder = { Text("Search notes, people, tags... or use operators") }, - leadingIcon = { + // Progress bar at very top + AnimatedVisibility( + visible = isSearching, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + + // Relay status banner + SearchSyncBanner( + relayStates = relayStates, + isSearching = isSearching, + ) + + // Title row + Row( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Search", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + "${localCache.userCount()} users cached", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(16.dp)) + + // Search bar with advanced toggle + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + state.updateFromText(it.text) + }, + modifier = Modifier.weight(1f).focusRequester(focusRequester), + placeholder = { Text("Search notes, people, tags... or use operators") }, + leadingIcon = { + Icon( + MaterialSymbols.Search, + contentDescription = "Search", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = { + if (displayText.isNotEmpty()) { + IconButton(onClick = { state.clearSearch() }) { + Icon( + MaterialSymbols.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + singleLine = true, + shape = RoundedCornerShape(12.dp), + ) + if (account != null && !account.isReadOnly) { + IconButton(onClick = { showRelayPicker = true }) { + Icon( + MaterialSymbols.Dns, + contentDescription = "Search Relays", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + IconButton(onClick = { state.togglePanel() }) { Icon( - MaterialSymbols.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSurfaceVariant, + MaterialSymbols.Tune, + contentDescription = "Advanced Search", + tint = + if (panelExpanded) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, ) - }, - trailingIcon = { - if (displayText.isNotEmpty()) { - IconButton(onClick = { state.clearSearch() }) { - Icon( - MaterialSymbols.Clear, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + } + } + + // Search relay picker dialog + if (showRelayPicker && account != null) { + val pickerRelays = + remember { + mutableStateListOf().also { + it.addAll(searchRelays) } } - }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - ) - if (account != null && !account.isReadOnly) { - IconButton(onClick = { showRelayPicker = true }) { - Icon( - MaterialSymbols.Dns, - contentDescription = "Search Relays", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - IconButton(onClick = { state.togglePanel() }) { - Icon( - MaterialSymbols.Tune, - contentDescription = "Advanced Search", - tint = - if (panelExpanded) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + AlertDialog( + onDismissRequest = { showRelayPicker = false }, + title = { Text("Search Relays") }, + text = { + SearchRelayEditor( + localRelays = pickerRelays, + signer = account.signer, + onPublish = { event -> + relayManager.broadcastToAll(event) + accountRelays?.consumePublishedEvent(event) + accountRelays?.setSearchRelays(pickerRelays.toSet()) + }, + ) + }, + confirmButton = { + TextButton(onClick = { showRelayPicker = false }) { + Text("Close") + } + }, ) } - } - // Search relay picker dialog - if (showRelayPicker && account != null) { - val pickerRelays = - remember { - mutableStateListOf().also { - it.addAll(searchRelays) + // Expandable advanced panel + AnimatedVisibility( + visible = panelExpanded, + enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), + ) { + AdvancedSearchPanel( + query = query, + onKindsChanged = { state.updateKinds(it) }, + onPseudoKindsChanged = { state.updatePseudoKinds(it) }, + onAuthorAdded = { state.addAuthor(it) }, + onAuthorRemoved = { state.removeAuthor(it) }, + onDateRangeChanged = { since, until -> state.updateDateRange(since, until) }, + onHashtagAdded = { state.addHashtag(it) }, + onHashtagRemoved = { state.removeHashtag(it) }, + onExcludeAdded = { state.addExcludeTerm(it) }, + onExcludeRemoved = { state.removeExcludeTerm(it) }, + onLanguageChanged = { state.updateLanguage(it) }, + onClear = { state.clearSearch() }, + modifier = Modifier.padding(top = 8.dp), + ) + } + + Spacer(Modifier.height(16.dp)) + + // Results + val hasAnyResults = + bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty() + + if (bech32Results.isNotEmpty()) { + // Show bech32 results (exact lookup) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + "Direct lookup", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = 4.dp), + ) + bech32Results.forEach { result -> + SearchResultCard( + result = result, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onNavigateToHashtag = onNavigateToHashtag, + ) } } - AlertDialog( - onDismissRequest = { showRelayPicker = false }, - title = { Text("Search Relays") }, - text = { - SearchRelayEditor( - localRelays = pickerRelays, - signer = account.signer, - onPublish = { event -> - relayManager.broadcastToAll(event) - accountRelays?.consumePublishedEvent(event) - accountRelays?.setSearchRelays(pickerRelays.toSet()) - }, - ) - }, - confirmButton = { - TextButton(onClick = { showRelayPicker = false }) { - Text("Close") - } - }, - ) - } - - // Expandable advanced panel - AnimatedVisibility( - visible = panelExpanded, - enter = expandVertically(expandFrom = Alignment.Top) + fadeIn(), - exit = shrinkVertically(shrinkTowards = Alignment.Top) + fadeOut(), - ) { - AdvancedSearchPanel( - query = query, - onKindsChanged = { state.updateKinds(it) }, - onPseudoKindsChanged = { state.updatePseudoKinds(it) }, - onAuthorAdded = { state.addAuthor(it) }, - onAuthorRemoved = { state.removeAuthor(it) }, - onDateRangeChanged = { since, until -> state.updateDateRange(since, until) }, - onHashtagAdded = { state.addHashtag(it) }, - onHashtagRemoved = { state.removeHashtag(it) }, - onExcludeAdded = { state.addExcludeTerm(it) }, - onExcludeRemoved = { state.removeExcludeTerm(it) }, - onLanguageChanged = { state.updateLanguage(it) }, - onClear = { state.clearSearch() }, - modifier = Modifier.padding(top = 8.dp), - ) - } - - Spacer(Modifier.height(16.dp)) - - // Results - val hasAnyResults = - bech32Results.isNotEmpty() || peopleResults.isNotEmpty() || noteResults.isNotEmpty() - - if (bech32Results.isNotEmpty()) { - // Show bech32 results (exact lookup) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + } else if (hasAnyResults) { + SearchResultsList( + state = state, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + localCache = localCache, + ) + } else if (!debouncedQuery.isEmpty && !isSearching) { Text( - "Direct lookup", - style = MaterialTheme.typography.labelMedium, + "No results found. Try broader terms or fewer filters.", color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = 4.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } else if (!isSearching) { + // Empty state: show history + saved searches + operator hints + SearchEmptyState( + historyItems = historyItems, + savedSearches = savedSearches, + onLoadQuery = { query -> state.updateFromText(QuerySerializer.serialize(query)) }, + onDeleteSaved = { id -> SearchHistoryStore.deleteSavedSearch(id) }, + onClearHistory = { SearchHistoryStore.clearHistory() }, ) - bech32Results.forEach { result -> - SearchResultCard( - result = result, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - onNavigateToHashtag = onNavigateToHashtag, - ) - } } - } else if (hasAnyResults) { - SearchResultsList( - state = state, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - localCache = localCache, - ) - } else if (!debouncedQuery.isEmpty && !isSearching) { - Text( - "No results found. Try broader terms or fewer filters.", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - } else if (!isSearching) { - // Empty state: show history + saved searches + operator hints - SearchEmptyState( - historyItems = historyItems, - savedSearches = savedSearches, - onLoadQuery = { query -> state.updateFromText(QuerySerializer.serialize(query)) }, - onDeleteSaved = { id -> SearchHistoryStore.deleteSavedSearch(id) }, - onClearHistory = { SearchHistoryStore.clearHistory() }, - ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index b3e855c01..b57685ec3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -198,7 +198,7 @@ fun ThreadScreen( val replyNotes = threadNotes.filter { it.idHex != noteId } Box(modifier = Modifier.fillMaxSize()) { - Column(modifier = Modifier.fillMaxSize()) { + ReadingColumn { // Header — Messages-style: compact row with back + titleMedium Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index f922a8fed..dded5f020 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -36,6 +36,7 @@ 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.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -115,6 +116,7 @@ fun UserProfileScreen( nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, onBack: () -> Unit, + canGoBack: Boolean = false, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, @@ -436,489 +438,503 @@ fun UserProfileScreen( previousFirstVisibleItemScrollOffset = currentOffset } - Box(modifier = Modifier.fillMaxSize()) { - if (connectedRelays.isEmpty()) { - LoadingState("Connecting to relays...") - } else { - LazyColumn( - state = listState, - contentPadding = PaddingValues(horizontal = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxSize(), - ) { - // Broadcast banner - item(key = "broadcast") { - ProfileBroadcastBanner( - status = broadcastStatus, - onTap = { - if (broadcastStatus is ProfileBroadcastStatus.Success || - broadcastStatus is ProfileBroadcastStatus.Failed - ) { - broadcastStatus = ProfileBroadcastStatus.Idle - } - }, - ) - } + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { + Box(modifier = Modifier.fillMaxSize().widthIn(max = DefaultReadingWidth)) { + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else { + LazyColumn( + state = listState, + contentPadding = PaddingValues(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize(), + ) { + // Broadcast banner + item(key = "broadcast") { + ProfileBroadcastBanner( + status = broadcastStatus, + onTap = { + if (broadcastStatus is ProfileBroadcastStatus.Success || + broadcastStatus is ProfileBroadcastStatus.Failed + ) { + broadcastStatus = ProfileBroadcastStatus.Idle + } + }, + ) + } - // Header — Messages-style: compact row, titleMedium title - item(key = "header") { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { - Icon( - MaterialSymbols.AutoMirrored.ArrowBack, - contentDescription = "Back", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), + // Header — Messages-style: compact row, titleMedium title + item(key = "header") { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // Back is shown only when stacked onto a nav stack (e.g. + // clicked a user in the feed). Top-level "My Profile" + // from the nav rail sets canGoBack = false so no arrow. + if (canGoBack) { + IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + Spacer(Modifier.width(8.dp)) + } + Text( + "Profile", + style = MaterialTheme.typography.titleMedium, ) } - Spacer(Modifier.width(8.dp)) - Text( - "Profile", - style = MaterialTheme.typography.titleMedium, - ) - } - // Edit button for own profile - if (isOwnProfile && account.isReadOnly == false) { - OutlinedButton( - onClick = { - editingDisplayName = displayName ?: "" - showEditDialog = true - }, - ) { - Icon( - MaterialSymbols.Edit, - contentDescription = "Edit profile", - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text("Edit Profile") - } - } - - // Follow/Unfollow button for other profiles - if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { - Column(horizontalAlignment = Alignment.End) { - Button( + // Edit button for own profile + if (isOwnProfile && account.isReadOnly == false) { + OutlinedButton( onClick = { - scope.launch { - val currentStatus = followState.currentStatusOrNull() + editingDisplayName = displayName ?: "" + showEditDialog = true + }, + ) { + Icon( + MaterialSymbols.Edit, + contentDescription = "Edit profile", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text("Edit Profile") + } + } - followState.setFollowLoading() - try { - val updatedEvent = - if (currentStatus?.isFollowing == true) { - unfollowUser(pubKeyHex, account, relayManager, myContactList) - } else { - followUser(pubKeyHex, account, relayManager, myContactList) - } + // Follow/Unfollow button for other profiles + if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { + Column(horizontalAlignment = Alignment.End) { + Button( + onClick = { + scope.launch { + val currentStatus = followState.currentStatusOrNull() - // Update both stored contact list and followState - myContactList = updatedEvent - followState.setFollowSuccess(updatedEvent, pubKeyHex) - } catch (e: Exception) { - e.printStackTrace() - followState.setFollowError(e.message ?: "Failed to update follow status", e) + followState.setFollowLoading() + try { + val updatedEvent = + if (currentStatus?.isFollowing == true) { + unfollowUser(pubKeyHex, account, relayManager, myContactList) + } else { + followUser(pubKeyHex, account, relayManager, myContactList) + } + + // Update both stored contact list and followState + myContactList = updatedEvent + followState.setFollowSuccess(updatedEvent, pubKeyHex) + } catch (e: Exception) { + e.printStackTrace() + followState.setFollowError(e.message ?: "Failed to update follow status", e) + } + } + }, + enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading, + ) { + val state = followState.state.collectAsState().value + val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false + val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading + + when { + !contactListLoaded -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text("Loading...") + } + + isLoading -> { + androidx.compose.material3.CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollowing..." else "Following...") + } + + else -> { + Icon( + if (isFollowing) MaterialSymbols.PersonRemove else MaterialSymbols.PersonAdd, + contentDescription = if (isFollowing) "Unfollow" else "Follow", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text(if (isFollowing) "Unfollow" else "Follow") } } - }, - enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading, - ) { - val state = followState.state.collectAsState().value - val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false - val isLoading = state is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading - - when { - !contactListLoaded -> { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - Text("Loading...") - } - - isLoading -> { - androidx.compose.material3.CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - ) - Spacer(Modifier.width(8.dp)) - Text(if (isFollowing) "Unfollowing..." else "Following...") - } - - else -> { - Icon( - if (isFollowing) MaterialSymbols.PersonRemove else MaterialSymbols.PersonAdd, - contentDescription = if (isFollowing) "Unfollow" else "Follow", - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.width(8.dp)) - Text(if (isFollowing) "Unfollow" else "Follow") - } } - } - val errorMessage = - followState.state - .collectAsState() - .value - .errorOrNull() - errorMessage?.let { error -> - Spacer(Modifier.height(4.dp)) - Text( - error, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - ) + val errorMessage = + followState.state + .collectAsState() + .value + .errorOrNull() + errorMessage?.let { error -> + Spacer(Modifier.height(4.dp)) + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } } } } } - } - // Profile card - item(key = "profile-card") { - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { - UserAvatar( - userHex = pubKeyHex, - pictureUrl = picture, - size = 56.dp, - contentDescription = "Profile picture", - ) - - Column(modifier = Modifier.weight(1f)) { - Text( - displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, + // Profile card + item(key = "profile-card") { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + UserAvatar( + userHex = pubKeyHex, + pictureUrl = picture, + size = 56.dp, + contentDescription = "Profile picture", ) - Spacer(Modifier.height(4.dp)) - val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub() - var copied by remember { mutableStateOf(false) } - LaunchedEffect(copied) { - if (copied) { - delay(2000) - copied = false + Column(modifier = Modifier.weight(1f)) { + Text( + displayName ?: (pubKeyHex.hexToByteArrayOrNull()?.toNpub()?.take(20) ?: pubKeyHex.take(20)), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + val npub = pubKeyHex.hexToByteArrayOrNull()?.toNpub() + var copied by remember { mutableStateOf(false) } + + LaunchedEffect(copied) { + if (copied) { + delay(2000) + copied = false + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + (npub?.take(32) ?: pubKeyHex.take(32)) + "...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (npub != null) { + IconButton( + onClick = { + val clipboard = Toolkit.getDefaultToolkit().systemClipboard + clipboard.setContents(StringSelection(npub), null) + copied = true + }, + modifier = Modifier.size(20.dp), + ) { + Icon( + if (copied) MaterialSymbols.Check else MaterialSymbols.ContentCopy, + contentDescription = if (copied) "Copied" else "Copy npub", + modifier = Modifier.size(14.dp), + tint = + if (copied) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } } } + } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { + if (about != null) { + Spacer(Modifier.height(12.dp)) + Text( + about!!, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Column { Text( - (npub?.take(32) ?: pubKeyHex.take(32)) + "...", + "$followersCount", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "Followers", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - if (npub != null) { - IconButton( - onClick = { - val clipboard = Toolkit.getDefaultToolkit().systemClipboard - clipboard.setContents(StringSelection(npub), null) - copied = true - }, - modifier = Modifier.size(20.dp), - ) { - Icon( - if (copied) MaterialSymbols.Check else MaterialSymbols.ContentCopy, - contentDescription = if (copied) "Copied" else "Copy npub", - modifier = Modifier.size(14.dp), - tint = - if (copied) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, + } + Column { + Text( + "$followingCount", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + "Following", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + + // Tabs + item(key = "tabs") { + PrimaryTabRow(selectedTabIndex = selectedTab) { + Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { + Text("Notes", modifier = Modifier.padding(12.dp)) + } + Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { + Text( + "Reads${if (articleEvents.isNotEmpty()) " (${articleEvents.size})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } + Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) { + Text("Gallery", modifier = Modifier.padding(12.dp)) + } + Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) { + Text( + "Highlights${if (highlightEvents.isNotEmpty()) " (${highlightEvents.size})" else ""}", + modifier = Modifier.padding(12.dp), + ) + } + } + } + + // Tab content + when (selectedTab) { + 0 -> { + when (profileFeedState) { + is FeedState.Loading -> { + item(key = "loading") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + androidx.compose.material3.CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + "Loading posts...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } - } - if (about != null) { - Spacer(Modifier.height(12.dp)) - Text( - about!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - } - - Spacer(Modifier.height(12.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Column { - Text( - "$followersCount", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - "Followers", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Column { - Text( - "$followingCount", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Text( - "Following", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } - - // Tabs - item(key = "tabs") { - PrimaryTabRow(selectedTabIndex = selectedTab) { - Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { - Text("Notes", modifier = Modifier.padding(12.dp)) - } - Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { - Text( - "Reads${if (articleEvents.isNotEmpty()) " (${articleEvents.size})" else ""}", - modifier = Modifier.padding(12.dp), - ) - } - Tab(selected = selectedTab == 2, onClick = { selectedTab = 2 }) { - Text("Gallery", modifier = Modifier.padding(12.dp)) - } - Tab(selected = selectedTab == 3, onClick = { selectedTab = 3 }) { - Text( - "Highlights${if (highlightEvents.isNotEmpty()) " (${highlightEvents.size})" else ""}", - modifier = Modifier.padding(12.dp), - ) - } - } - } - - // Tab content - when (selectedTab) { - 0 -> { - when (profileFeedState) { - is FeedState.Loading -> { - item(key = "loading") { - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - androidx.compose.material3.CircularProgressIndicator() - Spacer(Modifier.height(16.dp)) + is FeedState.Empty -> { + item(key = "empty") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { Text( - "Loading posts...", + "No posts yet", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } - } - is FeedState.Empty -> { - item(key = "empty") { + is FeedState.FeedError -> { + item(key = "error") { + Box( + modifier = Modifier.fillMaxWidth().padding(32.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + "Failed to load posts", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + Spacer(Modifier.height(8.dp)) + Text( + (profileFeedState as FeedState.FeedError).errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = { retryTrigger++ }) { + Text("Retry") + } + } + } + } + } + + is FeedState.Loaded -> { + // loadedNotes collected outside LazyColumn in profileLoadedNotes + items(profileLoadedNotes, key = { it.idHex }) { note -> + FeedNoteCard( + note = note, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + onReply = onCompose, + onZapFeedback = onZapFeedback, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onImageClick = { urls, index -> + lightboxState = LightboxState(urls, index) + }, + onMediaClick = { urls, index, seekPos -> + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .playVideo(urls[index], seekPos) + com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer + .toggleFullscreen() + }, + ) + } + } + } + } + + 1 -> { + if (articleEvents.isEmpty()) { + item(key = "no-articles") { Box( modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center, ) { Text( - "No posts yet", + "No long-form articles", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } + } else { + items( + articleEvents.sortedWith(compareByDescending { it.publishedAt() ?: it.createdAt }.thenBy { it.id }), + key = { "art-${it.id}" }, + ) { article -> + LongFormCard( + event = article, + localCache = localCache, + onAuthorClick = { onNavigateToProfile(article.pubKey) }, + onClick = { + val addressTag = "${LongTextNoteEvent.KIND}:${article.pubKey}:${article.dTag()}" + onNavigateToArticle(addressTag) + }, + ) + } } + } - is FeedState.FeedError -> { - item(key = "error") { + 2 -> { + item(key = "gallery") { + GalleryTab( + pictureEvents = pictureEvents, + onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, + modifier = Modifier.fillParentMaxHeight(), + ) + } + } + + 3 -> { + if (highlightEvents.isEmpty()) { + item(key = "no-highlights") { Box( modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center, ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - "Failed to load posts", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.error, - ) - Spacer(Modifier.height(8.dp)) - Text( - (profileFeedState as FeedState.FeedError).errorMessage, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(16.dp)) - OutlinedButton(onClick = { retryTrigger++ }) { - Text("Retry") - } - } + Text( + "No published highlights", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - } - - is FeedState.Loaded -> { - // loadedNotes collected outside LazyColumn in profileLoadedNotes - items(profileLoadedNotes, key = { it.idHex }) { note -> - FeedNoteCard( - note = note, - relayManager = relayManager, + } else { + items( + highlightEvents.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }), + key = { "hl-${it.id}" }, + ) { highlight -> + PublishedHighlightCard( + highlight = highlight, localCache = localCache, - account = account, - nwcConnection = nwcConnection, - onReply = onCompose, - onZapFeedback = onZapFeedback, - onNavigateToProfile = onNavigateToProfile, - onNavigateToThread = onNavigateToThread, - onImageClick = { urls, index -> - lightboxState = LightboxState(urls, index) - }, - onMediaClick = { urls, index, seekPos -> - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .playVideo(urls[index], seekPos) - com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer - .toggleFullscreen() - }, ) } } } } - - 1 -> { - if (articleEvents.isEmpty()) { - item(key = "no-articles") { - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - "No long-form articles", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else { - items( - articleEvents.sortedWith(compareByDescending { it.publishedAt() ?: it.createdAt }.thenBy { it.id }), - key = { "art-${it.id}" }, - ) { article -> - LongFormCard( - event = article, - localCache = localCache, - onAuthorClick = { onNavigateToProfile(article.pubKey) }, - onClick = { - val addressTag = "${LongTextNoteEvent.KIND}:${article.pubKey}:${article.dTag()}" - onNavigateToArticle(addressTag) - }, - ) - } - } - } - - 2 -> { - item(key = "gallery") { - GalleryTab( - pictureEvents = pictureEvents, - onImageClick = { urls, index -> lightboxState = LightboxState(urls, index) }, - modifier = Modifier.fillParentMaxHeight(), - ) - } - } - - 3 -> { - if (highlightEvents.isEmpty()) { - item(key = "no-highlights") { - Box( - modifier = Modifier.fillMaxWidth().padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - "No published highlights", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else { - items( - highlightEvents.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }), - key = { "hl-${it.id}" }, - ) { highlight -> - PublishedHighlightCard( - highlight = highlight, - localCache = localCache, - ) - } - } - } } } - } - // Floating header — appears on scroll up when profile header is out of view - AnimatedVisibility( - visible = showFloatingHeader, - enter = slideInVertically { -it }, - exit = slideOutVertically { -it }, - modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(), - ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) - .padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, + // Floating header — appears on scroll up when profile header is out of view + AnimatedVisibility( + visible = showFloatingHeader, + enter = slideInVertically { -it }, + exit = slideOutVertically { -it }, + modifier = Modifier.align(Alignment.TopCenter).fillMaxWidth(), ) { - IconButton(onClick = onBack) { - Icon(MaterialSymbols.AutoMirrored.ArrowBack, "Back") + Row( + modifier = + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.95f)) + .padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (canGoBack) { + IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { + Icon( + MaterialSymbols.AutoMirrored.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + Spacer(Modifier.width(4.dp)) + } + UserAvatar( + userHex = pubKeyHex, + pictureUrl = picture, + size = 28.dp, + contentDescription = "Profile picture", + ) + Spacer(Modifier.width(8.dp)) + Text( + displayName ?: pubKeyHex.take(12) + "...", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) } - Spacer(Modifier.width(8.dp)) - UserAvatar( - userHex = pubKeyHex, - pictureUrl = picture, - size = 28.dp, - contentDescription = "Profile picture", - ) - Spacer(Modifier.width(8.dp)) - Text( - displayName ?: pubKeyHex.take(12) + "...", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - maxLines = 1, - ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt index d622e3c74..d41b1de86 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -460,6 +460,7 @@ internal fun OverlayContent( nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, onBack = onBack, + canGoBack = true, onCompose = onShowComposeDialog, onNavigateToProfile = onNavigateToProfile, onNavigateToThread = onNavigateToThread, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt index b8bb5129b..f6fc2d797 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -26,9 +26,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -72,15 +72,21 @@ fun MyHighlightsScreen( val scope = rememberCoroutineScope() var deleteTarget by remember { mutableStateOf(null) } - Column(modifier = Modifier.fillMaxSize()) { - Text( - "Highlights", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - ) - - Spacer(Modifier.height(8.dp)) + com.vitorpamplona.amethyst.desktop.ui.ReadingColumn { + Row( + modifier = + Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Highlights", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } if (allHighlights.isEmpty()) { EmptyState( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 7cc2500c0..4f87e08cd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -160,8 +160,9 @@ fun NoteCard( modifier = modifier.fillMaxWidth(), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, + containerColor = MaterialTheme.colorScheme.surface, ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), ) { Column(modifier = Modifier.padding(12.dp)) { // Header + text area — clickable to navigate to thread From cd4dd42bc4c9225bbed2ea8714ae7490f88f0eb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 15:14:08 +0000 Subject: [PATCH 17/29] fix(desktop): clip card ripples to card shape via Card(onClick = ...) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Material3 Card has a built-in onClick parameter that handles the ripple with proper rounded-corner clipping. Using Modifier.clickable on the Card's modifier bypasses that — the ripple renders as a rectangle and gets awkwardly cut off at the card corners. Converted to the onClick-parameter form on: - LongFormCard (Reads) - DraftCard (Drafts) - RelayMetricCard (Relay Dashboard / Monitor tab) NoteCard intentionally left unchanged: its inner clickable covers only the header+text region (not the action-buttons row), so a full-card onClick would conflict with the per-action handlers. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt | 4 ++-- .../com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt | 6 ++---- .../amethyst/desktop/ui/relay/RelayMetricCard.kt | 5 ++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt index 5668a93a6..d5d9c062f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -152,7 +151,8 @@ private fun DraftCard( onDelete: () -> Unit, ) { Card( - modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + onClick = onClick, + modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 9b015f0b9..b286fc35e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -98,10 +98,8 @@ fun LongFormCard( val publishedAt = event.publishedAt() ?: event.createdAt Card( - modifier = - Modifier - .fillMaxWidth() - .clickable(onClick = onClick), + onClick = onClick, + modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricCard.kt index 4ae586b88..072131bdd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/relay/RelayMetricCard.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.desktop.ui.relay import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -63,9 +62,9 @@ fun RelayMetricCard( value = nip11Fetcher.fetch(status.url) } - Card(modifier = modifier.fillMaxWidth()) { + Card(onClick = onToggleExpand, modifier = modifier.fillMaxWidth()) { Column( - modifier = Modifier.clickable { onToggleExpand() }.padding(12.dp), + modifier = Modifier.padding(12.dp), ) { Row( modifier = Modifier.fillMaxWidth(), From c4ae0a30ebd882f95493768ad2674abbbb021cda Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 15:27:53 +0000 Subject: [PATCH 18/29] fix(desktop): width cap ordering, platform icon weight, Settings hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes: ReadingColumn modifier ordering: - fillMaxSize().widthIn(max = 720) was locking width to parent before widthIn could cap it, so the cap had no effect in practice. Switched to widthIn(max = 720).fillMaxWidth().fillMaxHeight() which now caps correctly on wide displays. Same fix applied to the three screens that use Box+widthIn inline (UserProfile, Search, Settings). Platform-aware Material Symbols weight: - ProvideMaterialSymbols now takes an optional weight parameter that defaults to MaterialSymbolsDefaults.WEIGHT (300) so Android keeps current behavior. Desktop passes PlatformIconWeight.current which maps: macOS → 200 (thin, matches SF Symbols stroke) GNOME → 300 (matches libadwaita symbolic icons) KDE → 300 (matches Breeze) Windows → 400 (matches Fluent Icons) other → 300 Settings section hierarchy: - "Wallet Connect (NWC)" and "Relay Settings" section headers dropped from titleLarge (22sp) to titleSmall (14sp) so they're smaller than the "Settings" screen title (titleMedium 16sp). Restores the visual hierarchy the user reported inverted. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../icons/symbols/MaterialSymbolsFont.kt | 19 +++++--- .../vitorpamplona/amethyst/desktop/Main.kt | 12 +++-- .../desktop/platform/PlatformIconWeight.kt | 46 +++++++++++++++++++ .../amethyst/desktop/ui/ReadingColumn.kt | 11 ++++- .../amethyst/desktop/ui/SearchScreen.kt | 4 +- .../amethyst/desktop/ui/UserProfileScreen.kt | 9 +++- 6 files changed, 88 insertions(+), 13 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformIconWeight.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt index 27e58f724..882dd1e90 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbolsFont.kt @@ -47,24 +47,31 @@ private const val TEXT_MEASURER_CACHE_SIZE = 64 /** * Builds the Material Symbols FontFamily and a shared TextMeasurer once for the subtree and * exposes them via CompositionLocal. Wrap app roots (AmethystTheme, desktop MaterialTheme) in this. + * + * The optional [weight] override lets callers pick a stroke thickness different from the + * library default — desktop uses per-OS weights (macOS likes the thinner SF-Symbols-ish + * look at 200, Windows Fluent icons sit closer to 400) while Android keeps the default. */ @Composable -fun ProvideMaterialSymbols(content: @Composable () -> Unit) { +fun ProvideMaterialSymbols( + weight: Int = MaterialSymbolsDefaults.WEIGHT, + content: @Composable () -> Unit, +) { val font = Font( resource = Res.font.material_symbols_outlined, - weight = FontWeight(MaterialSymbolsDefaults.WEIGHT), + weight = FontWeight(weight), variationSettings = FontVariation.Settings( - FontVariation.weight(MaterialSymbolsDefaults.WEIGHT), + FontVariation.weight(weight), FontVariation.Setting("FILL", MaterialSymbolsDefaults.FILL), FontVariation.Setting("opsz", MaterialSymbolsDefaults.OPTICAL_SIZE), FontVariation.Setting("GRAD", MaterialSymbolsDefaults.GRADE), ), ) - // Keyless remember: the Font wrapper identity changes every composition but the underlying - // resource is a compile-time constant, so one FontFamily for the lifetime of the subtree. - val fontFamily = remember { FontFamily(font) } + // Keyless remember is safe only when weight is stable; key on weight so a platform- + // preview override swap actually rebuilds the FontFamily. + val fontFamily = remember(weight) { FontFamily(font) } val textMeasurer = rememberTextMeasurer(cacheSize = TEXT_MEASURER_CACHE_SIZE) CompositionLocalProvider( LocalMaterialSymbolsFontFamily provides fontFamily, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index a82aa523e..1c78e6f5c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -807,7 +808,9 @@ fun App( .rememberSystemDark(LocalAwtWindow.current) com.vitorpamplona.amethyst.desktop.platform.PlatformMaterialTheme(isDark = isDark) { - ProvideMaterialSymbols { + ProvideMaterialSymbols( + weight = com.vitorpamplona.amethyst.desktop.platform.PlatformIconWeight.current, + ) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, @@ -1328,8 +1331,9 @@ fun RelaySettingsScreen( Column( modifier = Modifier - .fillMaxSize() .widthIn(max = 720.dp) + .fillMaxWidth() + .fillMaxHeight() .verticalScroll(rememberScrollState()) .padding(horizontal = 12.dp), ) { @@ -1353,7 +1357,7 @@ fun RelaySettingsScreen( // Wallet Connect Section Text( "Wallet Connect (NWC)", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.height(8.dp)) @@ -1462,7 +1466,7 @@ fun RelaySettingsScreen( Text( "Relay Settings", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.height(8.dp)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformIconWeight.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformIconWeight.kt new file mode 100644 index 000000000..e72ce429c --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/platform/PlatformIconWeight.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.platform + +/** + * Preferred Material Symbols stroke weight per host OS. Each desktop UI language + * draws icons at a different visual weight; matching them makes the in-app icons + * feel at home next to the OS's own. Values use the Material Symbols variation + * axis (100 = Thin, 200 = ExtraLight, 300 = Light, 400 = Regular, …, 700 = Bold). + * + * Reference calibration: + * - macOS: SF Symbols "Regular" is visually lighter than 400 Material Symbols; + * 200 (Thin/ExtraLight) matches the delicate stroke macOS users expect. + * - GNOME: libadwaita symbolic icons are a hair thinner than Regular — 300 matches. + * - KDE Breeze: Breeze icons are thin-to-medium, also 300. + * - Windows (Fluent): Fluent Icons have moderate weight, 400 is the sweet spot. + */ +object PlatformIconWeight { + val current: Int by lazy { + when (PlatformInfo.current) { + Platform.MACOS -> 200 + Platform.GNOME -> 300 + Platform.KDE -> 300 + Platform.WINDOWS -> 400 + Platform.LINUX_OTHER, Platform.UNKNOWN -> 300 + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt index 7f6bb82f5..645b6bd15 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt @@ -23,7 +23,9 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -59,8 +61,15 @@ fun ReadingColumn( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter, ) { + // Order matters: widthIn must come BEFORE fillMaxWidth, otherwise + // fillMaxWidth locks the Column to parent.width and the cap is ignored. + // fillMaxHeight is safe (it only constrains the other axis). Column( - modifier = modifier.fillMaxSize().widthIn(max = maxWidth), + modifier = + modifier + .widthIn(max = maxWidth) + .fillMaxWidth() + .fillMaxHeight(), content = content, ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index e980ccb2d..c1298df96 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -300,8 +301,9 @@ fun SearchScreen( Column( modifier = modifier - .fillMaxSize() .widthIn(max = DefaultReadingWidth) + .fillMaxWidth() + .fillMaxHeight() .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false when (event.key) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index dded5f020..7a501d60d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -439,7 +440,13 @@ fun UserProfileScreen( } Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { - Box(modifier = Modifier.fillMaxSize().widthIn(max = DefaultReadingWidth)) { + Box( + modifier = + Modifier + .widthIn(max = DefaultReadingWidth) + .fillMaxWidth() + .fillMaxHeight(), + ) { if (connectedRelays.isEmpty()) { LoadingState("Connecting to relays...") } else { From 6c3327944942f68c2d8e2f0a0c46866d04bb9984 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 15:33:38 +0000 Subject: [PATCH 19/29] fix(desktop): compact text fields + normalize Settings section headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text fields: - Chat input (ChatPane) and Search field (SearchScreen) both used M3 OutlinedTextField defaults, which has a 56dp min-height tuned for mobile touch. On desktop that reads as "way too tall" — roughly double what Slack / Raycast / VS Code show. Overrode with Modifier.height(40.dp) / heightIn(min = 40.dp), paired with bodyMedium (14sp) textStyle and slightly smaller leading/trailing icons so the field sits at a desktop-native ~40dp when empty, still grows with content in the chat case. Settings section headers — normalized to titleSmall (14sp) so every section sits consistently under the "Settings" titleMedium (16sp) screen title: - "Wallet Connect (NWC)" (was titleLarge, already fixed) - "Relay Settings" (was titleLarge, already fixed) - "Tor" (was titleLarge, now titleSmall) - "Media Servers (Blossom)" (was titleMedium, now titleSmall) MediaServerSettings extra border removed — the parent RelaySettingsScreen already provides a 12dp horizontal gutter, but MediaServerSettings was adding another 16dp all-sides padding on top, showing as a visible inner frame. Dropped that wrapper padding. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../amethyst/desktop/ui/SearchScreen.kt | 19 +++++++++++++++---- .../amethyst/desktop/ui/chats/ChatPane.kt | 15 +++++++++++++-- .../ui/settings/MediaServerSettings.kt | 7 +++++-- .../desktop/ui/tor/TorSettingsSection.kt | 2 +- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index c1298df96..77774c165 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -377,28 +377,39 @@ fun SearchScreen( textFieldValue = it state.updateFromText(it.text) }, - modifier = Modifier.weight(1f).focusRequester(focusRequester), - placeholder = { Text("Search notes, people, tags... or use operators") }, + // .height(40.dp) overrides M3's 56dp min-height (intended for + // mobile touch) — desktop inputs should feel closer to Slack / + // Raycast / VS Code at ~40dp. + modifier = Modifier.weight(1f).height(40.dp).focusRequester(focusRequester), + textStyle = MaterialTheme.typography.bodyMedium, + placeholder = { + Text( + "Search notes, people, tags... or use operators", + style = MaterialTheme.typography.bodyMedium, + ) + }, leadingIcon = { Icon( MaterialSymbols.Search, contentDescription = "Search", tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), ) }, trailingIcon = { if (displayText.isNotEmpty()) { - IconButton(onClick = { state.clearSearch() }) { + IconButton(onClick = { state.clearSearch() }, modifier = Modifier.size(28.dp)) { Icon( MaterialSymbols.Clear, contentDescription = "Clear", tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), ) } } }, singleLine = true, - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(10.dp), ) if (account != null && !account.isReadOnly) { IconButton(onClick = { showRelayPicker = true }) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 194a40c36..261934e09 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -718,9 +719,13 @@ private fun MessageInput( OutlinedTextField( value = messageText, onValueChange = onMessageChange, + // heightIn(min = 40.dp) overrides M3's 56dp mobile-touch min so the + // chat input sits at a desktop-sensible 40dp when empty; still grows + // to up to ~120dp with content via maxLines = 4. modifier = Modifier .weight(1f) + .heightIn(min = 40.dp) .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false // Cmd+Enter (Mac) or Ctrl+Enter to send @@ -732,10 +737,16 @@ private fun MessageInput( false } }, - placeholder = { Text("Message... (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)") }, + textStyle = MaterialTheme.typography.bodyMedium, + placeholder = { + Text( + "Message... (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)", + style = MaterialTheme.typography.bodyMedium, + ) + }, singleLine = false, maxLines = 4, - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(10.dp), ) IconButton( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt index 15bdea390..41cfddee0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/MediaServerSettings.kt @@ -92,10 +92,13 @@ fun MediaServerSettings( } } - Column(modifier = modifier.fillMaxWidth().padding(16.dp)) { + // Parent (RelaySettingsScreen) provides the 12dp horizontal gutter, so this + // column no longer adds its own all-sides 16dp which was showing up as an + // extra frame inside the settings screen. + Column(modifier = modifier.fillMaxWidth()) { Text( "Media Servers (Blossom)", - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.titleSmall, ) Spacer(Modifier.height(8.dp)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt index 33161d667..1020fc3c3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/tor/TorSettingsSection.kt @@ -70,7 +70,7 @@ fun TorSettingsSection( Row(verticalAlignment = Alignment.CenterVertically) { Text( "Tor", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.width(12.dp)) From 3b9ef980a6226a744fe238beeae77988cd899fa2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 15:49:24 +0000 Subject: [PATCH 20/29] fix(desktop): full-width scroll with centered content via LocalReadingSidePadding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widthIn cap confined every scrollable element to a 720dp column in the middle of the window. Anywhere the mouse hovered outside that column, the scroll wheel did nothing — scroll events only reached the LazyColumn when the cursor was inside. Refactored ReadingColumn to use BoxWithConstraints + a CompositionLocal (LocalReadingSidePadding) instead of a width-capping Box wrapper. The outer Column now fills the whole window so scroll gestures land on the scrollable wherever the mouse is. Each screen reads the side padding from the CompositionLocal and applies it to: - its header Row as `horizontal = readingHorizontalPadding()` - its LazyColumn as `contentPadding = PaddingValues(horizontal = readingHorizontalPadding())` Items still appear centered at DefaultReadingWidth (720dp), but the scrollable surface now spans the full window width. Also: - Search placeholder shortened ("Search people, tags, notes…") so it stops getting cut off in the compact 40dp field. - UserProfile's floating header AnimatedVisibility call needed to be fully-qualified (androidx.compose.animation.AnimatedVisibility) to avoid the compiler picking the ColumnScope overload inherited from ReadingColumn's outer ColumnScope — the inner BoxScope's Modifier.align(Alignment.TopCenter) required the non-scoped variant. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../vitorpamplona/amethyst/desktop/Main.kt | 12 ++-- .../amethyst/desktop/ui/BookmarksScreen.kt | 5 +- .../amethyst/desktop/ui/DraftsScreen.kt | 5 +- .../amethyst/desktop/ui/FeedHeader.kt | 5 +- .../amethyst/desktop/ui/FeedScreen.kt | 9 ++- .../desktop/ui/NotificationsScreen.kt | 2 +- .../amethyst/desktop/ui/ReadingColumn.kt | 63 +++++++++++-------- .../amethyst/desktop/ui/ReadsScreen.kt | 5 +- .../amethyst/desktop/ui/SearchScreen.kt | 17 ++--- .../amethyst/desktop/ui/ThreadScreen.kt | 5 +- .../amethyst/desktop/ui/UserProfileScreen.kt | 26 ++++---- .../ui/highlights/MyHighlightsScreen.kt | 7 ++- 12 files changed, 88 insertions(+), 73 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 1c78e6f5c..0d370ce63 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -32,7 +32,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState @@ -1324,18 +1323,17 @@ fun RelaySettingsScreen( accountManager.loadNwcConnection() } - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter, - ) { + com.vitorpamplona.amethyst.desktop.ui.ReadingColumn { + val sidePadding = + com.vitorpamplona.amethyst.desktop.ui + .readingHorizontalPadding() Column( modifier = Modifier - .widthIn(max = 720.dp) .fillMaxWidth() .fillMaxHeight() .verticalScroll(rememberScrollState()) - .padding(horizontal = 12.dp), + .padding(horizontal = sidePadding), ) { Row( modifier = diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt index 8c418e309..54a2b1626 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/BookmarksScreen.kt @@ -249,13 +249,14 @@ fun BookmarksScreen( val currentBookmarkIds = if (selectedTab == BookmarkTab.PUBLIC) publicBookmarkIds else privateBookmarkIds ReadingColumn { + val sidePadding = readingHorizontalPadding() // Header with tabs Row( modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = sidePadding, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -306,7 +307,7 @@ fun BookmarksScreen( else -> { LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 12.dp), + contentPadding = PaddingValues(horizontal = sidePadding), ) { items(currentEvents, key = { it.id }) { event -> Column( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt index d5d9c062f..70f904c32 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -67,12 +67,13 @@ fun DraftsScreen( var deleteTarget by remember { mutableStateOf(null) } ReadingColumn { + val sidePadding = readingHorizontalPadding() Row( modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = sidePadding, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -103,7 +104,7 @@ fun DraftsScreen( ) } else { LazyColumn( - contentPadding = PaddingValues(horizontal = 12.dp), + contentPadding = PaddingValues(horizontal = sidePadding), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(drafts, key = { it.slug }) { entry -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedHeader.kt index 802d7eb12..c1dd65a69 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedHeader.kt @@ -55,7 +55,10 @@ fun FeedHeader( modifier: Modifier = Modifier, ) { Row( - modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = readingHorizontalPadding(), vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index f8542c338..35b786398 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -546,8 +546,9 @@ fun FeedScreen( is FeedState.Loaded -> { val loadedState by state.feed.collectAsState() + val sidePadding = LocalReadingSidePadding.current LazyColumn( - contentPadding = PaddingValues(horizontal = 12.dp), + contentPadding = PaddingValues(horizontal = sidePadding + 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(loadedState.list, key = { it.idHex }) { note -> @@ -649,8 +650,12 @@ private fun FeedHeader( onNavigateToRelays: () -> Unit = {}, onOpenRelayPicker: () -> Unit = {}, ) { + val sidePadding = LocalReadingSidePadding.current Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = sidePadding + 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 6a7395e89..1429565d7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -256,7 +256,7 @@ fun NotificationsScreen( ) } else { LazyColumn( - contentPadding = PaddingValues(horizontal = 12.dp), + contentPadding = PaddingValues(horizontal = readingHorizontalPadding()), verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt index 645b6bd15..040e385a1 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadingColumn.kt @@ -20,15 +20,13 @@ */ package com.vitorpamplona.amethyst.desktop.ui -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.widthIn import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -42,35 +40,50 @@ import androidx.compose.ui.unit.dp val DefaultReadingWidth: Dp = 720.dp /** - * A top-level content scaffold that caps width and centers its column on wide - * displays. Each feed / list / profile screen wraps its contents in this so - * cards maintain a consistent proportion across the whole app. + * Side padding the current screen should apply to its scrollable list / + * headers to keep content centered at [DefaultReadingWidth] within the + * window. `0.dp` outside of a [ReadingColumn]. + * + * Screens use this to widen the gutter on wide displays (`horizontal = + * readingSidePadding + 12.dp`) while keeping the scrollable area itself at + * full window width — so the mouse wheel scrolls the feed wherever it + * hovers, not only inside the 720 dp column. + */ +val LocalReadingSidePadding = compositionLocalOf { 0.dp } + +/** + * Convenience: reads the current reading-column side padding and adds the + * standard 12.dp screen-edge gutter. Use this inside composable bodies when + * applying horizontal padding to header rows or `contentPadding` on + * LazyColumns so items stay centered at [DefaultReadingWidth] while the + * scrollable surface still spans the full window width. + */ +@Composable +fun readingHorizontalPadding(): Dp = LocalReadingSidePadding.current + 12.dp + +/** + * Top-level scaffold for single-pane content screens. Measures the window + * width and computes the side padding that centers a [maxWidth]-wide column + * within it. The actual content (header + LazyColumn) fills the window — the + * centering is done via [LocalReadingSidePadding] applied to inner modifiers + * (`horizontal` padding on a header Row, `contentPadding` on a LazyColumn). + * This keeps scroll events live across the full window, not just the center + * column. * * Not used by: * - Messages (two-pane layout with its own sizing) * - Article Reader (has its own narrower reading-width logic) - * - Editor / Chess / Relay Dashboard (rely on full width for tools / boards) + * - Editor / Chess / Relay Dashboard (tools that want full width) */ @Composable fun ReadingColumn( - modifier: Modifier = Modifier, maxWidth: Dp = DefaultReadingWidth, content: @Composable ColumnScope.() -> Unit, ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter, - ) { - // Order matters: widthIn must come BEFORE fillMaxWidth, otherwise - // fillMaxWidth locks the Column to parent.width and the cap is ignored. - // fillMaxHeight is safe (it only constrains the other axis). - Column( - modifier = - modifier - .widthIn(max = maxWidth) - .fillMaxWidth() - .fillMaxHeight(), - content = content, - ) + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val sidePadding = ((this.maxWidth - maxWidth) / 2).coerceAtLeast(0.dp) + CompositionLocalProvider(LocalReadingSidePadding provides sidePadding) { + Column(modifier = Modifier.fillMaxSize(), content = content) + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index b286fc35e..fa3a2135f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -286,13 +286,14 @@ fun ReadsScreen( } ReadingColumn { + val sidePadding = readingHorizontalPadding() // Header — Messages-style: tabs left, refresh right. The selected tab // (Following / Global) acts as the screen title, so no separate label. Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = sidePadding, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -357,7 +358,7 @@ fun ReadsScreen( else -> { LazyColumn( - contentPadding = PaddingValues(horizontal = 12.dp), + contentPadding = PaddingValues(horizontal = sidePadding), verticalArrangement = Arrangement.spacedBy(12.dp), ) { items(events, key = { it.id }) { event -> diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index 77774c165..b895a80ed 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -32,14 +32,12 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -292,16 +290,10 @@ fun SearchScreen( focusRequester.requestFocus() } - androidx.compose.foundation.layout.Box( - modifier = - androidx.compose.ui.Modifier - .fillMaxSize(), - contentAlignment = androidx.compose.ui.Alignment.TopCenter, - ) { + ReadingColumn { Column( modifier = modifier - .widthIn(max = DefaultReadingWidth) .fillMaxWidth() .fillMaxHeight() .onPreviewKeyEvent { event -> @@ -342,12 +334,13 @@ fun SearchScreen( ) // Title row + val sidePadding = readingHorizontalPadding() Row( modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = sidePadding, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -384,7 +377,7 @@ fun SearchScreen( textStyle = MaterialTheme.typography.bodyMedium, placeholder = { Text( - "Search notes, people, tags... or use operators", + "Search people, tags, notes…", style = MaterialTheme.typography.bodyMedium, ) }, @@ -548,7 +541,7 @@ private fun SearchEmptyState( ) { LazyColumn( modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 12.dp), + contentPadding = PaddingValues(horizontal = readingHorizontalPadding()), verticalArrangement = Arrangement.spacedBy(4.dp), ) { // Saved searches diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index b57685ec3..4bc4b2a36 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -199,9 +199,10 @@ fun ThreadScreen( Box(modifier = Modifier.fillMaxSize()) { ReadingColumn { + val sidePadding = readingHorizontalPadding() // Header — Messages-style: compact row with back + titleMedium Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = onBack, modifier = Modifier.size(32.dp)) { @@ -240,7 +241,7 @@ fun ThreadScreen( else -> { LazyColumn( - contentPadding = PaddingValues(horizontal = 12.dp), + contentPadding = PaddingValues(horizontal = sidePadding), verticalArrangement = Arrangement.spacedBy(0.dp), ) { // Root note diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 7a501d60d..c80bddd02 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -30,14 +30,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize 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.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState @@ -439,20 +437,14 @@ fun UserProfileScreen( previousFirstVisibleItemScrollOffset = currentOffset } - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) { - Box( - modifier = - Modifier - .widthIn(max = DefaultReadingWidth) - .fillMaxWidth() - .fillMaxHeight(), - ) { + ReadingColumn { + Box(modifier = Modifier.fillMaxSize()) { if (connectedRelays.isEmpty()) { LoadingState("Connecting to relays...") } else { LazyColumn( state = listState, - contentPadding = PaddingValues(horizontal = 12.dp), + contentPadding = PaddingValues(horizontal = readingHorizontalPadding()), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxSize(), ) { @@ -470,10 +462,11 @@ fun UserProfileScreen( ) } - // Header — Messages-style: compact row, titleMedium title + // Header — Messages-style: compact row, titleMedium title. + // Horizontal gutter already supplied by LazyColumn.contentPadding. item(key = "header") { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { @@ -902,8 +895,11 @@ fun UserProfileScreen( } } - // Floating header — appears on scroll up when profile header is out of view - AnimatedVisibility( + // Floating header — appears on scroll up when profile header is out of view. + // Fully-qualified call to force the non-scoped overload; ReadingColumn + // provides a ColumnScope in the outer lambda which would otherwise win + // overload resolution and break the BoxScope Modifier.align call below. + androidx.compose.animation.AnimatedVisibility( visible = showFloatingHeader, enter = slideInVertically { -it }, exit = slideOutVertically { -it }, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt index f6fc2d797..67c8baa34 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/highlights/MyHighlightsScreen.kt @@ -73,12 +73,15 @@ fun MyHighlightsScreen( var deleteTarget by remember { mutableStateOf(null) } com.vitorpamplona.amethyst.desktop.ui.ReadingColumn { + val sidePadding = + com.vitorpamplona.amethyst.desktop.ui + .readingHorizontalPadding() Row( modifier = Modifier .fillMaxWidth() .heightIn(min = 48.dp) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = sidePadding, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -95,7 +98,7 @@ fun MyHighlightsScreen( ) } else { LazyColumn( - contentPadding = PaddingValues(horizontal = 12.dp), + contentPadding = PaddingValues(horizontal = sidePadding), verticalArrangement = Arrangement.spacedBy(8.dp), ) { allHighlights.forEach { (addressTag, highlights) -> From 7da69fbaee518fb75784d539df632df6994b9396 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 15:55:57 +0000 Subject: [PATCH 21/29] fix(desktop): hover-state polish + Search width cap + reserve reaction-icon space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NoteCard hover (header+text inner clickable + avatar/name chip): - Clip(RoundedCornerShape(8.dp)) before .clickable on the header+text Column so the ripple rounds instead of cutting a hard rectangle. - Avatar+name chip gets a stadium clip (RoundedCornerShape(100.dp)) before its clickable — matches how Slack / Notion / Linear render user-pill hover states. Chat bubble hover (ChatBubbleLayout): - combinedClickable was applied outside the Surface's shape clipping, so the ripple ignored ChatBubbleShapeMe/Them. Moved the shape into a named val and added Modifier.clip(bubbleShape) ahead of combinedClickable. Hover now rounds with the bubble. Chat reaction icon (ChatPane detailRow): - On hover the "add reaction" icon used to conditionally appear and shift every other element in the detail row, causing the whole list to reflow up or down. Wrapped in a Box with Modifier.alpha that fades in/out — the icon is always laid out so the row stays fixed. Button is disabled when not hovered so it's click-through when invisible. Search text field: - Now padded by LocalReadingSidePadding so it stays inside the 720dp reading column on wide displays (previously the search Row's fillMaxWidth spanned the whole window after the ReadingColumn refactor). - Bumped height from 40dp → 44dp. M3 OutlinedTextField has ~16dp of internal vertical content padding that was clipping the bodyMedium placeholder at 40dp. Same change for the chat input. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../commons/ui/chat/ChatBubbleLayout.kt | 28 +++++--- .../amethyst/desktop/ui/SearchScreen.kt | 13 ++-- .../amethyst/desktop/ui/chats/ChatPane.kt | 67 ++++++++++--------- .../amethyst/desktop/ui/note/NoteCard.kt | 14 ++-- 4 files changed, 70 insertions(+), 52 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt index bd92d401a..fd479141e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleMaxSizeModifier @@ -128,18 +129,23 @@ fun ChatBubbleLayout( ) } + val bubbleShape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem + // Clip the hover/ripple to the bubble shape — combinedClickable otherwise + // paints a rectangular ripple that ignores the Surface's rounded corners. val clickableModifier = - remember { - Modifier.combinedClickable( - onClick = { - if (!onClick()) { - if (!isComplete) { - showDetails.value = !showDetails.value + remember(bubbleShape) { + Modifier + .clip(bubbleShape) + .combinedClickable( + onClick = { + if (!onClick()) { + if (!isComplete) { + showDetails.value = !showDetails.value + } } - } - }, - onLongClick = { popupExpanded.value = true }, - ) + }, + onLongClick = { popupExpanded.value = true }, + ) } Row( @@ -148,7 +154,7 @@ fun ChatBubbleLayout( ) { Surface( color = bgColor.value, - shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem, + shape = bubbleShape, modifier = clickableModifier, ) { Column(modifier = MessageBubbleLimits, verticalArrangement = ChatRowColSpacing5dp) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index b895a80ed..b0c5e4ecb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -358,9 +358,10 @@ fun SearchScreen( Spacer(Modifier.height(16.dp)) - // Search bar with advanced toggle + // Search bar with advanced toggle — honors the reading width cap so it + // stays centered with the rest of the screen's content on wide windows. Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(horizontal = sidePadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { @@ -370,10 +371,12 @@ fun SearchScreen( textFieldValue = it state.updateFromText(it.text) }, - // .height(40.dp) overrides M3's 56dp min-height (intended for + // .height(44.dp) overrides M3's 56dp min-height (intended for // mobile touch) — desktop inputs should feel closer to Slack / - // Raycast / VS Code at ~40dp. - modifier = Modifier.weight(1f).height(40.dp).focusRequester(focusRequester), + // Raycast / VS Code. 44dp (not 40) keeps the bodyMedium + // placeholder from clipping vertically inside M3's built-in + // content padding. + modifier = Modifier.weight(1f).height(44.dp).focusRequester(focusRequester), textStyle = MaterialTheme.typography.bodyMedium, placeholder = { Text( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 261934e09..85caf36ea 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -63,6 +63,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draganddrop.DragAndDropEvent import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draw.alpha import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isCtrlPressed @@ -560,35 +561,36 @@ private fun MessageWithReactions( } } - // AddReaction icon on hover - if (showIcon) { - Box { - IconButton( - onClick = { showPicker = !showPicker }, - modifier = Modifier.size(20.dp), - ) { - Icon( - symbol = MaterialSymbols.AddReaction, - contentDescription = "React", - modifier = Modifier.size(14.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + // AddReaction icon: always laid out to reserve space (so hover + // doesn't reflow the detail row and shift the whole list); + // alpha fades in/out based on hover. + Box(modifier = Modifier.alpha(if (showIcon) 1f else 0f)) { + IconButton( + onClick = { showPicker = !showPicker }, + enabled = showIcon, + modifier = Modifier.size(20.dp), + ) { + Icon( + symbol = MaterialSymbols.AddReaction, + contentDescription = "React", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } - if (showPicker) { - Popup( - alignment = Alignment.TopCenter, - offset = IntOffset(0, -44), - onDismissRequest = { showPicker = false }, - properties = PopupProperties(focusable = true), - ) { - ReactionBar( - onReaction = { emoji -> - onReaction(emoji) - showPicker = false - }, - ) - } + if (showPicker) { + Popup( + alignment = Alignment.TopCenter, + offset = IntOffset(0, -44), + onDismissRequest = { showPicker = false }, + properties = PopupProperties(focusable = true), + ) { + ReactionBar( + onReaction = { emoji -> + onReaction(emoji) + showPicker = false + }, + ) } } } @@ -719,13 +721,14 @@ private fun MessageInput( OutlinedTextField( value = messageText, onValueChange = onMessageChange, - // heightIn(min = 40.dp) overrides M3's 56dp mobile-touch min so the - // chat input sits at a desktop-sensible 40dp when empty; still grows - // to up to ~120dp with content via maxLines = 4. + // heightIn(min = 44.dp) overrides M3's 56dp mobile-touch min so the + // chat input sits at a desktop-sensible ~44dp when empty; still grows + // to up to ~120dp with content via maxLines = 4. 44 (not 40) keeps the + // bodyMedium placeholder from clipping inside M3's internal padding. modifier = Modifier .weight(1f) - .heightIn(min = 40.dp) + .heightIn(min = 44.dp) .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false // Cmd+Enter (Mac) or Ctrl+Enter to send diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 4f87e08cd..8a966f695 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -165,11 +165,14 @@ fun NoteCard( elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), ) { Column(modifier = Modifier.padding(12.dp)) { - // Header + text area — clickable to navigate to thread + // Header + text area — clickable to navigate to thread. Clip BEFORE + // clickable so the ripple/hover fill is rounded, not a hard rectangle. Column( modifier = if (onClick != null) { - Modifier.clickable { onClick() } + Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { onClick() } } else { Modifier }, @@ -179,12 +182,15 @@ fun NoteCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - // Author with avatar + // Author with avatar — stadium-shaped hover to match the + // avatar+name chip's visual shape. Row( verticalAlignment = Alignment.CenterVertically, modifier = if (onAuthorClick != null) { - Modifier.clickable { onAuthorClick(note.pubKeyHex) } + Modifier + .clip(RoundedCornerShape(100.dp)) + .clickable { onAuthorClick(note.pubKeyHex) } } else { Modifier }, From 8ae4ce6f9179543c18b04e29d67b98220c622bf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 15:57:49 +0000 Subject: [PATCH 22/29] fix(desktop): compact Profile header buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Edit Profile" was an OutlinedButton with icon + text which rendered at ~40dp tall — taller than the 32dp IconButtons every other screen uses in its header row, so it was pushing the Profile title down and inflating the top border of the whole page. - Edit Profile → IconButton(size = 32.dp) with Edit icon (size = 20.dp) tinted primary. Matches the + / refresh / relays pattern on Home, Reads, Notifications, etc. - Follow / Unfollow button (when viewing someone else's profile) — kept as a labelled Button for clarity but compacted to height 32.dp with contentPadding(horizontal = 12.dp, vertical = 0.dp) so it matches the header row's scale. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../amethyst/desktop/ui/UserProfileScreen.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index c80bddd02..0a4ae4ad6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -491,25 +491,28 @@ fun UserProfileScreen( ) } - // Edit button for own profile + // Edit button for own profile — compact IconButton to match + // the action-icon pattern every other screen's header uses. if (isOwnProfile && account.isReadOnly == false) { - OutlinedButton( + IconButton( onClick = { editingDisplayName = displayName ?: "" showEditDialog = true }, + modifier = Modifier.size(32.dp), ) { Icon( MaterialSymbols.Edit, - contentDescription = "Edit profile", - modifier = Modifier.size(18.dp), + contentDescription = "Edit Profile", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), ) - Spacer(Modifier.width(8.dp)) - Text("Edit Profile") } } - // Follow/Unfollow button for other profiles + // Follow/Unfollow button for other profiles — compact to + // match the header row height (32dp); primary-coloured + // text button so the affordance is still legible. if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { Column(horizontalAlignment = Alignment.End) { Button( @@ -536,6 +539,8 @@ fun UserProfileScreen( } }, enabled = contactListLoaded && followState.state.value !is com.vitorpamplona.amethyst.commons.state.LoadingState.Loading, + modifier = Modifier.height(32.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 0.dp), ) { val state = followState.state.collectAsState().value val isFollowing = (state as? com.vitorpamplona.amethyst.commons.state.LoadingState.Success)?.data?.isFollowing ?: false From bbe84c2bb0679a08c0834142109f1830aa7cc817 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 16:02:00 +0000 Subject: [PATCH 23/29] fix(desktop): BasicTextField+DecorationBox for compact Search/Chat inputs M3 OutlinedTextField has ~32dp of vertical contentPadding baked in, so forcing .height(40-44dp) clipped the bodyMedium (14sp, 20dp line- height) placeholder vertically. Refactored both Search and Chat inputs to BasicTextField wrapped in OutlinedTextFieldDefaults.DecorationBox, which lets us override contentPadding to (12.dp horizontal, 8.dp vertical). The field now renders at a true 40dp tall with the placeholder centered and no clipping. Border, focus states, placeholder, and leading/trailing icons still come from M3 so the look stays consistent. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../amethyst/desktop/ui/SearchScreen.kt | 96 ++++++++++++------- .../amethyst/desktop/ui/chats/ChatPane.kt | 61 +++++++++--- 2 files changed, 110 insertions(+), 47 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index b0c5e4ecb..e0a18453a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -49,7 +49,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -365,47 +364,80 @@ fun SearchScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - OutlinedTextField( + // Compact desktop search field. M3's OutlinedTextField has a hard + // ~32dp vertical contentPadding baked in, so forcing .height(40dp) + // clipped the placeholder. BasicTextField + DecorationBox lets us + // override contentPadding to 8dp vertical so the field can sit at + // a true 40dp tall without clipping the bodyMedium line-height. + val searchInteraction = + remember { + androidx.compose.foundation.interaction + .MutableInteractionSource() + } + androidx.compose.foundation.text.BasicTextField( value = textFieldValue, onValueChange = { textFieldValue = it state.updateFromText(it.text) }, - // .height(44.dp) overrides M3's 56dp min-height (intended for - // mobile touch) — desktop inputs should feel closer to Slack / - // Raycast / VS Code. 44dp (not 40) keeps the bodyMedium - // placeholder from clipping vertically inside M3's built-in - // content padding. - modifier = Modifier.weight(1f).height(44.dp).focusRequester(focusRequester), - textStyle = MaterialTheme.typography.bodyMedium, - placeholder = { - Text( - "Search people, tags, notes…", - style = MaterialTheme.typography.bodyMedium, - ) - }, - leadingIcon = { - Icon( - MaterialSymbols.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp), - ) - }, - trailingIcon = { - if (displayText.isNotEmpty()) { - IconButton(onClick = { state.clearSearch() }, modifier = Modifier.size(28.dp)) { + modifier = Modifier.weight(1f).height(40.dp).focusRequester(focusRequester), + textStyle = + MaterialTheme.typography.bodyMedium + .copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = + androidx.compose.ui.graphics + .SolidColor(MaterialTheme.colorScheme.primary), + singleLine = true, + interactionSource = searchInteraction, + decorationBox = { innerTextField -> + androidx.compose.material3.OutlinedTextFieldDefaults.DecorationBox( + value = textFieldValue.text, + innerTextField = innerTextField, + enabled = true, + singleLine = true, + visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None, + interactionSource = searchInteraction, + placeholder = { + Text( + "Search people, tags, notes…", + style = MaterialTheme.typography.bodyMedium, + ) + }, + leadingIcon = { Icon( - MaterialSymbols.Clear, - contentDescription = "Clear", + MaterialSymbols.Search, + contentDescription = "Search", tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp), ) - } - } + }, + trailingIcon = { + if (displayText.isNotEmpty()) { + IconButton(onClick = { state.clearSearch() }, modifier = Modifier.size(28.dp)) { + Icon( + MaterialSymbols.Clear, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp), + ) + } + } + }, + contentPadding = + androidx.compose.foundation.layout.PaddingValues( + horizontal = 12.dp, + vertical = 8.dp, + ), + container = { + androidx.compose.material3.OutlinedTextFieldDefaults.Container( + enabled = true, + isError = false, + interactionSource = searchInteraction, + shape = RoundedCornerShape(10.dp), + ) + }, + ) }, - singleLine = true, - shape = RoundedCornerShape(10.dp), ) if (account != null && !account.isReadOnly) { IconButton(onClick = { showRelayPicker = true }) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index 85caf36ea..fb6b1b311 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -43,7 +43,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface @@ -718,17 +717,22 @@ private fun MessageInput( ) } - OutlinedTextField( + // BasicTextField + DecorationBox with custom contentPadding lets the + // field sit at a compact ~40dp minimum on desktop (M3's default + // OutlinedTextField has ~32dp vertical contentPadding baked in, which + // would clip the bodyMedium placeholder at any height below ~52dp). + val messageInteraction = + remember { + androidx.compose.foundation.interaction + .MutableInteractionSource() + } + androidx.compose.foundation.text.BasicTextField( value = messageText, onValueChange = onMessageChange, - // heightIn(min = 44.dp) overrides M3's 56dp mobile-touch min so the - // chat input sits at a desktop-sensible ~44dp when empty; still grows - // to up to ~120dp with content via maxLines = 4. 44 (not 40) keeps the - // bodyMedium placeholder from clipping inside M3's internal padding. modifier = Modifier .weight(1f) - .heightIn(min = 44.dp) + .heightIn(min = 40.dp) .onPreviewKeyEvent { event -> if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false // Cmd+Enter (Mac) or Ctrl+Enter to send @@ -740,16 +744,43 @@ private fun MessageInput( false } }, - textStyle = MaterialTheme.typography.bodyMedium, - placeholder = { - Text( - "Message... (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)", - style = MaterialTheme.typography.bodyMedium, + textStyle = + MaterialTheme.typography.bodyMedium + .copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = + androidx.compose.ui.graphics + .SolidColor(MaterialTheme.colorScheme.primary), + maxLines = 4, + interactionSource = messageInteraction, + decorationBox = { innerTextField -> + androidx.compose.material3.OutlinedTextFieldDefaults.DecorationBox( + value = messageText, + innerTextField = innerTextField, + enabled = true, + singleLine = false, + visualTransformation = androidx.compose.ui.text.input.VisualTransformation.None, + interactionSource = messageInteraction, + placeholder = { + Text( + "Message\u2026 (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)", + style = MaterialTheme.typography.bodyMedium, + ) + }, + contentPadding = + androidx.compose.foundation.layout.PaddingValues( + horizontal = 12.dp, + vertical = 8.dp, + ), + container = { + androidx.compose.material3.OutlinedTextFieldDefaults.Container( + enabled = true, + isError = false, + interactionSource = messageInteraction, + shape = RoundedCornerShape(10.dp), + ) + }, ) }, - singleLine = false, - maxLines = 4, - shape = RoundedCornerShape(10.dp), ) IconButton( From fc9eb833ac1681ee81bb98d0926834fd8f0ff18e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 16:06:08 +0000 Subject: [PATCH 24/29] fix(desktop): remove ID footer/divider, whole-card hover on NoteCard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dropped the "ID: …" debug footer and the HorizontalDivider that sat above it. Cards read as finished content now, not dev output. - The previous design had an inner header+text Column carrying the clickable modifier, which meant the hover ring only covered part of the card (a rectangle inside). Switched to Material3 Card(onClick = onClick, …) so the whole card is the click-surface and the ripple is clipped to the card's rounded shape by M3 itself. - Content moved into a `cardBody` lambda reused by both branches of the `if (onClick != null)` check — non-clickable fallback exists for in-thread quoted note wrappers. - Action buttons inside NoteActionsRow have their own clickables that consume taps before they reach the Card's handler, so tapping reply / repost / zap still fires only that action, not the Card. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../amethyst/desktop/ui/note/NoteCard.kt | 61 ++++++++----------- 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt index 8a966f695..879ae5ca2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/note/NoteCard.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -34,7 +35,6 @@ 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.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -156,27 +156,16 @@ fun NoteCard( 400.dp } - Card( - modifier = modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp), - ) { + // Whole-card hover/ripple: pass onClick to M3 Card so the click-surface is + // the entire card (M3 handles shape clipping for us). Action buttons inside + // the NoteActionsRow have their own clickables that consume the click before + // it reaches the Card's handler, so tapping an action still fires only that + // action. + val cardColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface) + val cardElevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + val cardBody: @Composable ColumnScope.() -> Unit = { Column(modifier = Modifier.padding(12.dp)) { - // Header + text area — clickable to navigate to thread. Clip BEFORE - // clickable so the ripple/hover fill is rounded, not a hard rectangle. - Column( - modifier = - if (onClick != null) { - Modifier - .clip(RoundedCornerShape(8.dp)) - .clickable { onClick() } - } else { - Modifier - }, - ) { + Column { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -312,21 +301,25 @@ fun NoteCard( } } } - - Spacer(Modifier.height(8.dp)) - - HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)) - - Spacer(Modifier.height(4.dp)) - - // Event ID (truncated) - Text( - text = "ID: ${note.id.take(12)}...", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - ) } } + + if (onClick != null) { + Card( + onClick = onClick, + modifier = modifier.fillMaxWidth(), + colors = cardColors, + elevation = cardElevation, + content = cardBody, + ) + } else { + Card( + modifier = modifier.fillMaxWidth(), + colors = cardColors, + elevation = cardElevation, + content = cardBody, + ) + } } /** From c5712af2de7370f48d67eae3cfbf05b593c05585 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 16:09:25 +0000 Subject: [PATCH 25/29] refactor(desktop): move commons/ui/chat into desktopApp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every file under commons/src/commonMain/kotlin/.../commons/ui/chat/ (ChatBubbleLayout, ChatMessageCompose, ChatroomHeader, DmBroadcastBanner, DmBroadcastStatus, UserDisplayNameLayout) was imported only by desktopApp. Android has its own parallel implementation at amethyst/.../chats/feed/, so nothing shared. Moved the six files into desktopApp/.../ui/chats/ alongside ChatPane.kt, DesktopMessagesScreen.kt, etc. — same package as every existing caller, which lets us drop six `import` lines from ChatPane.kt + DmSendTracker.kt + DesktopMessagesScreen.kt. Empty commons/ui/chat/ directory removed. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../amethyst/desktop/ui/chats}/ChatBubbleLayout.kt | 2 +- .../amethyst/desktop/ui/chats}/ChatMessageCompose.kt | 2 +- .../com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt | 5 ----- .../amethyst/desktop/ui/chats}/ChatroomHeader.kt | 2 +- .../amethyst/desktop/ui/chats/DesktopMessagesScreen.kt | 1 - .../amethyst/desktop/ui/chats}/DmBroadcastBanner.kt | 2 +- .../amethyst/desktop/ui/chats}/DmBroadcastStatus.kt | 2 +- .../vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt | 1 - .../amethyst/desktop/ui/chats}/UserDisplayNameLayout.kt | 2 +- 9 files changed, 6 insertions(+), 13 deletions(-) rename {commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat => desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats}/ChatBubbleLayout.kt (99%) rename {commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat => desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats}/ChatMessageCompose.kt (98%) rename {commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat => desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats}/ChatroomHeader.kt (99%) rename {commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat => desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats}/DmBroadcastBanner.kt (99%) rename {commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat => desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats}/DmBroadcastStatus.kt (97%) rename {commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat => desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats}/UserDisplayNameLayout.kt (97%) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatBubbleLayout.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatBubbleLayout.kt index fd479141e..c0f1dd5d4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatBubbleLayout.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.chat +package com.vitorpamplona.amethyst.desktop.ui.chats import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatMessageCompose.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatMessageCompose.kt similarity index 98% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatMessageCompose.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatMessageCompose.kt index a848bfd8a..22784f879 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatMessageCompose.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatMessageCompose.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.chat +package com.vitorpamplona.amethyst.desktop.ui.chats import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt index fb6b1b311..5b529efb8 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -83,11 +83,6 @@ import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.commons.service.upload.UploadOrchestrator -import com.vitorpamplona.amethyst.commons.ui.chat.ChatMessageCompose -import com.vitorpamplona.amethyst.commons.ui.chat.ChatroomHeader -import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastBanner -import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus -import com.vitorpamplona.amethyst.commons.ui.chat.GroupChatroomHeader import com.vitorpamplona.amethyst.commons.ui.components.LoadingState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.commons.util.toTimeAgo diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatroomHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatroomHeader.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt index 021b7530f..760974048 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatroomHeader.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomHeader.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.chat +package com.vitorpamplona.amethyst.desktop.ui.chats import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index b172c6303..3bade983a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -56,7 +56,6 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider -import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmBroadcastBanner.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmBroadcastBanner.kt index 532814311..86a2184cd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmBroadcastBanner.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.chat +package com.vitorpamplona.amethyst.desktop.ui.chats import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmBroadcastStatus.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmBroadcastStatus.kt index 0d6973560..240090c4c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmBroadcastStatus.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.chat +package com.vitorpamplona.amethyst.desktop.ui.chats import androidx.compose.runtime.Immutable diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt index 3399e8e4d..398a72e77 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.desktop.ui.chats -import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirmDetailed diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/UserDisplayNameLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/UserDisplayNameLayout.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/UserDisplayNameLayout.kt rename to desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/UserDisplayNameLayout.kt index 703369752..4876011c2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/UserDisplayNameLayout.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/UserDisplayNameLayout.kt @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.commons.ui.chat +package com.vitorpamplona.amethyst.desktop.ui.chats import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope From aac56bd92e552f68fdffc185d9e3a9acabe640dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 16:14:18 +0000 Subject: [PATCH 26/29] feat(desktop): Messages draggable divider + alignment polish + centered empty states - **Draggable list pane**: the 280dp-fixed conversation list width is now user-adjustable via a draggable divider between the list and the chat pane. Width is clamped to 220-480dp and persisted across app restarts via DesktopPreferences.messagesListWidthDp. Cursor flips to the horizontal-resize arrow on hover. - **ConversationCard alignment**: the unread indicator used to sit as an 8dp dot + 14dp spacer to the left of the avatar, which pushed every card ~14dp inward from the header row's 12dp padding. The avatar now starts flush with the header (aligned with the top-bar buttons); the unread state is drawn as a 10dp dot overlaid on the avatar's bottom-right corner (iMessage / Slack-style), with a surface-matched ring so it reads clearly against the avatar. - **Drafts empty state**: was a flush-left Text pinned 16dp below the header; now uses the shared EmptyState composable so "No drafts yet" / description is vertically + horizontally centered in the available space, consistent with Notifications / Highlights / etc. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../amethyst/desktop/DesktopPreferences.kt | 8 +++ .../amethyst/desktop/ui/DraftsScreen.kt | 10 ++-- .../desktop/ui/chats/ConversationListPane.kt | 60 ++++++++++--------- .../desktop/ui/chats/DesktopMessagesScreen.kt | 50 +++++++++++++++- 4 files changed, 92 insertions(+), 36 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index 11e9521bb..00d8e26fa 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -37,6 +37,7 @@ object DesktopPreferences { private const val KEY_LAST_SCREEN = "last_screen" private const val KEY_DECK_COLUMNS = "deck_columns" private const val KEY_LAYOUT_MODE = "layout_mode" + private const val KEY_MESSAGES_LIST_WIDTH = "messages_list_width_dp" var feedMode: FeedMode get() { @@ -69,6 +70,13 @@ object DesktopPreferences { prefs.put(KEY_LAYOUT_MODE, value) } + /** Width (dp) of the conversation list pane in the Messages screen. */ + var messagesListWidthDp: Float + get() = prefs.getFloat(KEY_MESSAGES_LIST_WIDTH, 280f) + set(value) { + prefs.putFloat(KEY_MESSAGES_LIST_WIDTH, value) + } + private const val KEY_WORKSPACES = "workspaces" var workspaces: String diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt index 70f904c32..0de229a37 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/DraftsScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.ui.text.style.TextOverflow 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.components.EmptyState import com.vitorpamplona.amethyst.desktop.service.drafts.DesktopDraftStore import com.vitorpamplona.amethyst.desktop.service.drafts.DraftEntry import kotlinx.coroutines.launch @@ -94,13 +95,10 @@ fun DraftsScreen( } } - Spacer(Modifier.height(16.dp)) - if (drafts.isEmpty()) { - Text( - "No drafts yet. Click \"New Draft\" to start writing.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + EmptyState( + title = "No drafts yet", + description = "Click \"New Draft\" to start writing.", ) } else { LazyColumn( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index 3e8bd2fd4..ecb597b24 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -306,35 +306,39 @@ private fun ConversationCard( .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - // Unread indicator - if (item.hasUnread) { - Box( - modifier = - Modifier - .size(8.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), - ) - Spacer(Modifier.width(6.dp)) - } else { - Spacer(Modifier.width(14.dp)) - } - - // Avatar + // Avatar — starts flush with the header's 12dp padding. Unread state is + // signalled by a small primary-coloured dot overlaid on the avatar's + // bottom-right corner (Slack / iMessage-style), not an inline indent + // that would push the whole card to the right of the header label. val firstUser = item.users.firstOrNull() - if (firstUser != null) { - UserAvatar( - userHex = firstUser.pubkeyHex, - pictureUrl = firstUser.profilePicture(), - size = 40.dp, - ) - } else if (item.isGroup) { - Icon( - MaterialSymbols.Group, - contentDescription = "Group", - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Box { + if (firstUser != null) { + UserAvatar( + userHex = firstUser.pubkeyHex, + pictureUrl = firstUser.profilePicture(), + size = 40.dp, + ) + } else if (item.isGroup) { + Icon( + MaterialSymbols.Group, + contentDescription = "Group", + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (item.hasUnread) { + Box( + modifier = + Modifier + .align(Alignment.BottomEnd) + .size(10.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainer) + .padding(1.5.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + } } Spacer(Modifier.width(10.dp)) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 3bade983a..98bae6adc 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.desktop.ui.chats import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -51,6 +52,9 @@ import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols @@ -63,6 +67,7 @@ import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.coroutines.CoroutineScope +import java.awt.Cursor private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") @@ -273,6 +278,15 @@ private fun SplitMessagesContent( onShowRelayPicker: () -> Unit = {}, keyHandler: Modifier, ) { + // Draggable split: the conversation list width is user-adjustable and + // persisted so it survives app restarts. + var listWidth by remember { + androidx.compose.runtime.mutableStateOf( + com.vitorpamplona.amethyst.desktop.DesktopPreferences.messagesListWidthDp.dp, + ) + } + val density = androidx.compose.ui.platform.LocalDensity.current + Row(modifier = Modifier.fillMaxSize().then(keyHandler)) { ConversationListPane( state = listState, @@ -281,10 +295,17 @@ private fun SplitMessagesContent( onNewConversation = onShowNewDm, onShowRelayPicker = onShowRelayPicker, focusRequester = listFocusRequester, - modifier = Modifier.width(280.dp), + modifier = Modifier.width(listWidth), ) - VerticalDivider(modifier = Modifier.fillMaxHeight()) + MessagesDraggableDivider( + onDrag = { deltaPx -> + val deltaDp = with(density) { deltaPx.toDp() } + val next = (listWidth + deltaDp).coerceIn(220.dp, 480.dp) + listWidth = next + com.vitorpamplona.amethyst.desktop.DesktopPreferences.messagesListWidthDp = next.value + }, + ) Box( modifier = @@ -357,3 +378,28 @@ private fun EmptyConversationState() { } } } + +/** + * Draggable vertical divider between the conversation list pane and the chat + * pane. 12dp wide hit area; cursor flips to the horizontal resize arrow on + * hover. + */ +@Composable +private fun MessagesDraggableDivider(onDrag: (Float) -> Unit) { + Box( + modifier = + Modifier + .width(12.dp) + .fillMaxHeight() + .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR))) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + onDrag(dragAmount.x) + } + }, + contentAlignment = Alignment.Center, + ) { + VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } +} From beca79d8533a6afb57681105edc6b1b754836583 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 16:17:34 +0000 Subject: [PATCH 27/29] fix(desktop): Messages divider blends into both panes Split the 12dp draggable divider into two 6dp halves: left half takes surfaceContainer (matching the conversation list pane's fill), right half takes surface (matching the chat pane's fill). The divider now reads as the shared edge between the two panes instead of a solid contrasting stripe cutting down the middle. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../desktop/ui/chats/DesktopMessagesScreen.kt | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt index 98bae6adc..e2e77337b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -381,12 +380,15 @@ private fun EmptyConversationState() { /** * Draggable vertical divider between the conversation list pane and the chat - * pane. 12dp wide hit area; cursor flips to the horizontal resize arrow on - * hover. + * pane. The hit area is split in half horizontally: the left half takes the + * conversation list's surfaceContainer fill, the right half takes the chat + * pane's surface fill, so the divider reads as a continuation of the two panes + * instead of a single contrasting stripe. Cursor flips to the horizontal + * resize arrow on hover. */ @Composable private fun MessagesDraggableDivider(onDrag: (Float) -> Unit) { - Box( + Row( modifier = Modifier .width(12.dp) @@ -398,8 +400,20 @@ private fun MessagesDraggableDivider(onDrag: (Float) -> Unit) { onDrag(dragAmount.x) } }, - contentAlignment = Alignment.Center, ) { - VerticalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Box( + modifier = + Modifier + .width(6.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceContainer), + ) + Box( + modifier = + Modifier + .width(6.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surface), + ) } } From a59358f888c75b769607842f6ef89bf7ac4398d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Apr 2026 16:31:01 +0000 Subject: [PATCH 28/29] =?UTF-8?q?fix(desktop):=20Messages=20list=20typogra?= =?UTF-8?q?phy=20hierarchy=20=E2=80=94=20name=20vs.=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversation cards had the name (bodyMedium) and last-message preview (bodySmall) stacked flush against each other with very similar color weight, so they blurred into one visual block. Now: - Name: titleSmall (14sp Medium) — the focal top line. - Preview: bodySmall at onSurfaceVariant.alpha = 0.7 with a 2dp spacer above so it sits as a clearly secondary line. - Timestamp: labelSmall at alpha 0.6, with an 8dp gutter before it so long names don't collide with the time. - Group badge: onSurfaceVariant.alpha = 0.5 instead of primary-70% so it doesn't compete with the name for attention. The name now wins the eye first; the preview reads as a subtitle. https://claude.ai/code/session_01NufduPfZvYQVYwLkbCjCUo --- .../desktop/ui/chats/ConversationListPane.kt | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt index f774cdc31..ed7ee475a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -342,7 +342,10 @@ private fun ConversationCard( Spacer(Modifier.width(10.dp)) - // Name, preview, timestamp + // Name, preview, timestamp — vertical hierarchy: name = medium-weight + // primary text (titleSmall), preview = muted secondary. Prior styling had + // both lines at the same weight / near-same color and stacked flush, + // making them blur into each other. Column(modifier = Modifier.weight(1f)) { Row( modifier = Modifier.fillMaxWidth(), @@ -351,7 +354,7 @@ private fun ConversationCard( ) { Text( text = item.displayName, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -359,30 +362,34 @@ private fun ConversationCard( ) if (item.lastMessageTimestamp > 0) { + Spacer(Modifier.width(8.dp)) Text( text = item.lastMessageTimestamp.toTimeAgo(withDot = false), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), ) } } if (item.lastMessagePreview.isNotEmpty()) { + Spacer(Modifier.height(2.dp)) Text( text = item.lastMessagePreview, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), maxLines = 1, overflow = TextOverflow.Ellipsis, ) } - // Group indicator + // Group indicator — labelSmall with muted tertiary so the name line + // still wins the eye even when a group badge is present. if (item.isGroup) { + Spacer(Modifier.height(2.dp)) Text( text = "Group (${item.users.size + 1})", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), ) } } From 6580e1abf310be307a13d5caff8a52656ab01ff0 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 24 Apr 2026 14:37:56 -0400 Subject: [PATCH 29/29] Fixes mobile setup --- .../src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index b794b28a3..d6c4ebf26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -592,7 +592,7 @@ fun AmethystTheme( colorScheme = colors, typography = Typography, shapes = Shapes, - content = { ProvideMaterialSymbols(content) }, + content = { ProvideMaterialSymbols(content = content) }, ) val view = LocalView.current