Every desktop packaging job (Windows MSI, macOS DMG, Linux DEB) currently
breaks on the smallest blip when fetching VLC from get.videolan.org. The
`ir.mahozad.vlc-setup` plugin registers `vlcDownload` / `upxDownload`
tasks that extend `de.undercouch.gradle.tasks.download.Download`, but it
does not configure retries or a generous read timeout, so a single
`SocketTimeoutException` aborts the build.
Recent main runs all failed at the same step:
> Task :desktopApp:vlcDownload FAILED
> A failure occurred while executing ...DefaultWorkAction
> java.net.SocketTimeoutException: Read timed out
Configure all `Download` tasks in this project with:
- 5 attempts total (initial + 4 retries),
- 30 s connect timeout,
- 5 min read timeout (VLC archives are 40-90 MB and the mirror can be
slow under load),
- `tempAndMove` so a partial download from one attempt cannot poison
the next.
This is a CI-only change \u2014 it does not alter what gets bundled, just
makes the fetch resilient.
Cases removed:
- Trivial Modifier allocations (Modifier.weight/padding/size) — slot table
cost dominates the cost of building a fresh Modifier each recomposition.
- Map.keys views over relayStatuses on desktop screens — .keys is a
property read on the same map, no need to memoize.
- coerceIn() arithmetic on AudioWaveform Dp/Float params — two compares
are cheaper than the slot table read+compare.
- fadeIn()/fadeOut() in AnimatedVisibility — small EnterTransition
allocations that don't justify the slot table overhead.
Audit-only changes; no behavior changes.
https://claude.ai/code/session_011Ea2pVjwvCEx7X4izwryV4
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
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
- **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
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
- Dropped the "ID: <hex>…" 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
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
"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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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.<key>=<value>`
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
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
Material Symbol icons were allocating a fresh Font wrapper on every
composition (Font(resource = ...) returns a new instance each call),
which invalidated the remember(font) cache in rememberMaterialSymbolsFontFamily
and forced a fresh TextMeasurer per call site. Tint was also baked into
the TextStyle passed to measure(), so any tint change (selected /
unselected reaction icons, hover states) produced a cache miss.
Hoist the FontFamily + TextMeasurer to CompositionLocals provided once
at the root (AmethystTheme on Android, MaterialTheme wrapper on desktop)
via ProvideMaterialSymbols. Every icon in the subtree now reuses:
- One FontFamily (Skia typeface cache hit for every subsequent glyph)
- One TextMeasurer with a 64-entry LRU, shared across all call sites
so its measurement cache is hit across 300+ MaterialSymbols usages
- Tint applied via drawText(layout, color = tint) instead of TextStyle,
so the measured TextLayoutResult is reused across tint variations
Icon(symbol = ...) now delegates to Material3's Icon(painter = ...),
restoring proper sizing/semantics without the custom Box+drawBehind
workaround. autoMirror handled inside the painter's onDraw. The
previously-unused MaterialSymbolPainter is now the single render path.
The weight parameter is dropped from the Icon/Painter APIs since the
app uses a single weight globally (MaterialSymbolsDefaults.WEIGHT).
A local fallback path keeps @Preview composables that don't wrap in
the theme renderable (per-composition allocation, same cost as before).
https://claude.ai/code/session_01V9esGUFH72ujCQNWCAnjKN
Moves five JVM-only upload helpers from desktopApp into commons so
the CLI can reuse the same Blossom upload + AES-GCM encryption path
without depending on :desktopApp:
desktop/service/upload/DesktopBlossomAuth.kt → commons/service/upload/BlossomAuth.kt
desktop/service/upload/DesktopBlossomClient.kt → commons/service/upload/BlossomClient.kt
desktop/service/upload/DesktopMediaCompressor.kt → commons/service/upload/MediaCompressor.kt
desktop/service/upload/DesktopMediaMetadata.kt → commons/service/upload/MediaMetadata.kt
desktop/service/upload/DesktopUploadOrchestrator.kt → commons/service/upload/UploadOrchestrator.kt
API tweaks:
- Drop the `Desktop` prefix on every class.
- Rename the `DesktopMediaMetadata` object to `MediaMetadataReader`
to avoid colliding with the `MediaMetadata` data class in the same
package.
- BlossomClient no longer reaches into desktop's `DesktopHttpClient`.
Callers pass an `OkHttpClient` (or use the default one-shot client);
desktop continues to inject its Tor-aware client.
- Tighten BlossomClient's response handling — the OkHttp version on
commons' classpath has a nullable `Response.body`, so explicitly
null-check it.
Mechanical updates to keep desktop building:
- ComposeNoteDialog / ChatPane / DesktopUploadTracker switch to the
new commons imports.
- DimensionTag-from-MediaMetadata: cross-module smart cast no longer
works on the public `width`/`height` properties — replace with
`?.let { … }` chains.
- commons/jvmMain now declares `commons-imaging` (used only by
MediaCompressor's EXIF stripping).
Tests come along too: file names follow the renamed classes
(spotless ktlint enforces this).
Next commit will add `amy dm send-file --file PATH --server URL`,
which calls `UploadOrchestrator.uploadEncrypted(...)` directly.
Replace the `material-icons-extended` library with Google's Material Symbols
variable font, rendered via a new `commons/icons/symbols/` package. Weight is
set to 100 (Thin) via a single tunable default (MaterialSymbolsDefaults.WEIGHT),
so we can re-tune line thickness globally in one line without regenerating
assets.
Key changes:
- commons/composeResources/font/material_symbols_outlined.ttf: Material Symbols
Outlined variable font (all 3600+ glyphs, 4 design axes: wght/FILL/GRAD/opsz).
- commons/icons/symbols/: MaterialSymbol data class, 219 codepoint constants,
variable-font FontFamily helper, Canvas-backed Icon composable (overload that
accepts MaterialSymbol and supports auto-mirror for RTL), and a Painter
wrapper for use with Image / AsyncImage.
- Bulk-rewrote 250+ call sites across amethyst/, commons/, desktopApp/:
Icons.{Default,Outlined,Filled,Rounded,AutoMirrored.*}.X → MaterialSymbols.X
or MaterialSymbols.AutoMirrored.X. `imageVector =` renamed to `symbol =`
inside Icon() calls; Image(imageVector = MS.X) converted to
Image(painter = rememberMaterialSymbolPainter(MS.X)).
- Dropped material-icons-extended dependency from commons, desktopApp,
amethyst build files.
Hand-drawn custom icons (Bookmark, Like, Reply, Zap, chess pieces, robohash)
remain as ImageVector and are untouched.
https://claude.ai/code/session_016gV9gEaSud6kDMdcaqy8ti
Three call sites did the same two-layer peel —
kind:1059 → (nip44 decrypt) → kind:13 sealed rumor → (nip44 decrypt) → rumor
Extracted as `GiftWrapEvent.unwrapAndUnsealOrNull(signer)` in
commons/relayClient/nip17Dm/, and collapsed:
- commons/marmot/MarmotIngest.kt — was an inline `when` switch; now a
one-liner. Behaviour unchanged (still passes through if the inner
isn't a seal, matching the previous defensive `else -> inner` branch).
- desktopApp/Main.kt — dropped the nested unwrap/unseal block in the
kind:1059 case of the LocalCache dispatcher.
- cli/DmCommands.kt — replaced the manual chain in `decryptChatMessages`.
Android's DecryptAndIndexProcessor keeps its two separate handlers
(processNewGiftWrap / processNewSealedRumor) because the LocalCache
pipeline re-enters on each peel — that's a different shape and not
touched.
No behaviour change on any platform.
- Persist relay list events (kinds 10050/10007/10006) as JSON to
java.util.prefs.Preferences with per-account key isolation
- Load persisted relay configs on startup before bootstrap subscription
- Validate loaded events (kind + pubkey check), 8KB guard on writes
- Fix SearchScreen relay count: "0 of 1" not "0 of 7" — uses searchRelays
- Fix FeedScreen relay count: shows feed relay count, not all connected
- Per-screen relay picker dialogs: Dns icon on Feed and Search screens
opens AlertDialog wrapping existing editors (Nip65RelayEditor,
SearchRelayEditor) — no new composable files
- Fix created_at dedup: use >= for replaceable event semantics
- Fix setters: use TimeUtils.now() not Long.MAX_VALUE
- Add consumePublishedEvent() for local immediate update after publish
- Remove stale "not loaded" warnings from Search/Blocked editors
- Fix FeedHeader type: Set<NormalizedRelayUrl> not Set<Any>
- Fix picker LaunchedEffect(Unit) to not overwrite user edits
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds a parallel ThumbHash placeholder everywhere BlurHash is already used.
Remote events now carry both hashes; renderers prefer thumbhash when
available and fall back to blurhash. The MIP-04 `thumbhash` imeta field
that was previously reserved-but-unused is now wired end-to-end.
- New ThumbHash encoder/decoder in commons/commonMain (no new Gradle dep)
- New ThumbhashTag and thumbhash accessors/builders across NIP-17, 68,
71, 94, 95, 99, the experimental profile gallery, and MIP-04
- RichTextParser + MediaContentModels carry a thumbhash field alongside
blurhash so every downstream composable can pick its preferred placeholder
- New ThumbHashFetcher (Android + Desktop) registered with Coil, plus a
small placeholderModel(thumbhash, blurhash) helper centralising the
"prefer thumbhash, fall back to blurhash" rule
- Rename BlurhashMetadataCalculator -> PreviewMetadataCalculator; the
calculator decodes the bitmap / video thumbnail once and runs both
encoders on the same pixels to keep upload cost flat
- DesktopMediaMetadata, MediaUploadResult, and FileHeader now surface
both hashes through the NIP-96, Blossom, NIP-95, MIP-04, NIP-17 DM,
Classifieds, picture, video, and long-form upload paths
- Round-trip unit test for the ThumbHash port
Replace tint-only selection with Surface background + primaryContainer
color so the selected icon is clearly visible in the workspace editor.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Shorten "Single Pane" to "Single" in SegmentedButton to prevent wrapping
- Add 8dp left margin before checkmark in workspace card
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PinnedNavBarState now takes WorkspaceManager reference. Pin/unpin
actions update the active workspace's singlePaneScreens list,
making sidebar customization and workspace editing the same action.
- PinnedNavBarState.syncToWorkspace() updates active workspace on pin/unpin
- PinnedNavBarState.loadFromWorkspace() loads from active workspace
- Remove separate DesktopPreferences.pinnedNavItems persistence
- Workspace is the single source of truth for nav bar screens
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Change Workspace.singlePaneScreen (single string) to
singlePaneScreens (list of typeKey strings). First screen is
the default. On workspace switch, loads the screen list into
PinnedNavBarState and navigates to the first screen.
- Add SinglePaneScreensEditor to workspace editor dialog
- Add PinnedNavBarState.loadFromList() for workspace-driven nav
- Backward compat: load old "singlePaneScreen" format as single-item list
- Cmd+Shift+S captures current deck columns as singlePaneScreens
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove consumed flag (stale on re-open, double-fire is harmless)
- Fix keyboard nav for WORKSPACES tab (was broken, only worked for screens)
- Reset selectedIndex on tab switch
- Fix delete active workspace → reload columns for new active workspace
- Extract DeckColumnType.param() extension to eliminate 3x duplication
- Simplify moveSelection to handle all modes (tabs + unified search)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add two-tab App Drawer (Screens/Workspaces), workspace cards with CRUD,
editor dialog with icon picker and column configuration, unified search
across screens and workspaces, layout mode auto-switching, and
Cmd+Shift+S to save current layout as workspace.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>