feat(desktop): icon mark sized to match neighbors, add drop shadow, unify screen headers

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
This commit is contained in:
Claude
2026-04-24 14:07:39 +00:00
parent be8fef15db
commit f3ef3514b2
7 changed files with 93 additions and 28 deletions
@@ -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)
}
}
@@ -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,
)
@@ -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) }) {
@@ -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,
)
@@ -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(
@@ -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,
@@ -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(