Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions LIGHT_MODE_COLOR_PARITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
# Light-Mode Color Parity

Companion to `WALLET_PARITY.md`. This doc captures the light-mode color-palette
decisions landed on Android in `fix/light-mode-and-icon` so the iOS agent can
mirror them and keep the two apps visually identical.

iOS is the long-running reference, but light-mode polish was an Android-first
pass — the Android code is the source of truth for the contract below until
iOS lands matching changes.

---

## 1. Why two adjustments instead of one

iOS and Android both ship the same per-theme `primary` / `zapColor` /
`repostColor` / etc. swatches. Two specific problems showed up only in light
mode and have separate fixes:

1. **Dark-mode primary reads as too bright on near-white surfaces.** The
default Spark orange `#FF9800` doesn't have enough contrast against the
light-mode background, so buttons look washed out. **Fix:** light mode
uses a deeper, hand-tuned `customLightPrimary` (default `#D9730D`).
2. **The plain zap color reads as muddy when used for the celebratory
in-flight bolt animation.** The HSL lightness drops too low after the
primary darkening. **Fix:** the animation uses a separate
`zapAnimationColor` derived from `zapColor` with floored lightness
(`vividZapColor()`).

Everywhere *other* than the in-flight animation, the zap color and the
Material primary should be the **same** light-mode hue (`#D9730D` for the
default theme), so the post-zap icon, count text, top zap indicators, wallet
accents, and buttons all read as one consistent orange.

---

## 2. The contract (locked values)

### 2.1 `primary` — the throughout-the-theme accent

| Theme | Dark | Light |
| --- | --- | --- |
| default (`custom`) | `#FF9800` | **`#D9730D`** |
| user-picked custom accent | user value (`accentColor`) | `darkenColor(accentColor, fraction = 0.18f)` |

`darkenColor` is `hsl[2] *= (1 - fraction)` then `HSLToColor(hsl)`. Floor
caps optional (Android doesn't currently floor here; iOS can match).

### 2.2 `zapColor` — every zap surface *except* the in-flight animation

**Matches `primary` exactly in light mode.** Used by: post-zap icon tint
when `hasUserZapped`, post-zap count text, reaction count text after
react, top zap indicators in `NotificationsScreen`, wallet accent
(`WalletScreen`), Lightning QR icon, `PostCard` zap-bar tint, etc.

| Theme | Dark | Light |
| --- | --- | --- |
| default (`custom`) | `#FF9800` | **`#D9730D`** (same as `primary.light`) |
| user-picked custom accent | user value | `customLightPrimary` (same as `primary.light`) |

### 2.3 `zapAnimationColor` — *only* the celebratory in-flight bolt

Derived from `zapColor` via:

```
val hsl = colorToHSL(zapColor)
hsl[1] = (hsl[1] * 1.15f).coerceIn(0f, 1f) // bump saturation 15%
hsl[2] = hsl[2].coerceAtLeast(0.5f) // floor lightness at 0.5
return HSLToColor(hsl)
```

Used only by `LightningAnimation` (the three-shadow halo on the post action
bar's bolt during a zap in flight). Everywhere else uses `zapColor`.

### 2.4 Default theme other tokens (light)

| Token | Light value | Notes |
| --- | --- | --- |
| `repostColor` | `#2E7D32` | Dark green for contrast |
| `bookmarkColor` | `#D9730D` | Tracks `primary.light` |
| `paidColor` | `#C9A000` | Slightly muted gold |
| `secondary` | `#FFB74D` | Lighter accent |
| `background` | `#D8D8D8` | |
| `surface` | `#E8E8E8` | |
| `surfaceVariant` | `#CDCDCD` | |
| `onBackground` / `onSurface` | `#1C1B1F` | |
| `onSurfaceVariant` | `#333333` | |
| `outline` | `#999999` | |

---

## 3. Android implementation references

| Concern | File | Symbol |
| --- | --- | --- |
| `primary` darkening for custom accents | `app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt` | `customLightPrimary` |
| `darkenColor()` / `lightenColor()` helpers | `app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt` | `darkenColor`, `lightenColor` |
| `vividZapColor()` for the animation | `app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt` | `vividZapColor` |
| `zapAnimationColor` accessor | `app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt` | `WispThemeColors.zapAnimationColor` |
| `zapColor` aligned to `customLightPrimary` | `app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt` | `WispColors` custom-light branch |
| Default theme light swatches | `app/src/main/kotlin/com/wisp/app/ui/theme/Themes.kt` | `Themes.themes[0].light` |
| In-flight bolt consumes `zapAnimationColor` | `app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt` | `LightningAnimation` |
| In-flight bolt overlay also uses it | `app/src/main/kotlin/com/wisp/app/ui/component/LightningOverlay.kt` | top-level `zapColor` lookup |

Search hint on Android: `git grep -n "WispThemeColors.zap"` lists every
consumer. Every site uses `zapColor` *except* the two animation files
which use `zapAnimationColor`.

---

## 4. iOS port checklist

Mirror the contract above. Specific items the iOS agent should action:

- [ ] Default-theme `light.primary` is `#D9730D` (was `#CC7000` on the
original light branch — bump it to match).
- [ ] Custom-accent light variant darkens by 18% in HSL space
(`darkenColor(accent, 0.18)` equivalent).
- [ ] Add a `zapAnimationColor` computed property derived from `zapColor`
with `saturation *= 1.15` (cap 1.0) and `lightness ≥ 0.5`.
- [ ] In-flight bolt animations (the white-core glow pulse, the trailing
shadow halos) consume `zapAnimationColor`.
- [ ] **Every other zap-tinted UI element consumes plain `zapColor`** —
post-zap icon, post-zap count, reaction count after react, top zap
indicators, wallet zap accents, etc. Don't accidentally use
`zapAnimationColor` anywhere except the celebratory animation.
- [ ] Light-mode `zapColor` and `bookmarkColor` track `primary.light` for
both the default theme and custom-accent themes (so the throughout-
the-theme orange is one consistent hue).
- [ ] Non-default themes (Nord, Dracula, Gruvbox, etc.) keep their own
curated `light.zapColor` — those weren't part of the mismatch and
are intentionally different from `primary` for hue contrast.

### 4.1 Quick visual test

In light mode, with the default theme:

1. Open a post you've already zapped. The bolt icon + count should be
the same orange as the **Save** / **Connect** / **Confirm** buttons
elsewhere in the app.
2. Open the Notifications tab and find a zap row. The bolt + sat count
should be that same orange.
3. Open Wallet → main dashboard. The Send/Receive button rings should be
the same orange.
4. Trigger a fresh zap. The in-flight bolt animation should pulse a
**noticeably brighter** orange (the `zapAnimationColor` vivid variant
— never muddy or dark).

If any of those 4 surfaces reads as a different shade, the contract is
violated — go back to §2 and audit which property the site is reading.

---

## 5. App icon

Separate change shipped in the same PR but unrelated to the color contract.
Android replaced the Android-Studio default launcher icon with the iOS
artwork (orange wisp on black square) as an adaptive icon. iOS already
has this artwork — no action needed on the iOS side.

Reference: `app/src/main/res/drawable/ic_launcher_foreground.xml`
(vector), `app/src/main/res/drawable/ic_launcher_background.xml`
(solid black).

---

## 6. Locked decisions

| Decision | Choice | Why |
| --- | --- | --- |
| Light-mode `primary` for default theme | **`#D9730D`** (deeper than dark `#FF9800`) | Better contrast on near-white surfaces. |
| Light-mode custom-accent derivation | `darkenColor(accent, 0.18)` | Generic darken for any user-picked color. |
| `zapColor` vs `primary` in light mode | **Same value** | Throughout-the-theme orange should be one hue. |
| `zapAnimationColor` is separate | **Yes — vivid variant of `zapColor`** | Plain zap color reads muddy at animation time on light surfaces. |
| Scope of `zapAnimationColor` | **Animation only** | Static UI elements stay on `zapColor` to match `primary`. |
| Non-default themes | Their own curated palettes | Each preset has hue-contrast reasoning; don't blanket-align across all themes. |
2 changes: 1 addition & 1 deletion app/src/main/kotlin/com/wisp/app/ui/component/ActionBar.kt
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ internal fun LightningAnimation(modifier: Modifier = Modifier) {
label = "scale"
)

val zapColor = WispThemeColors.zapColor
val zapColor = WispThemeColors.zapAnimationColor

Canvas(modifier = modifier) {
val w = size.width
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ fun ZapBurstEffect(
var sparks by remember { mutableStateOf<List<SparkParticle>>(emptyList()) }
val progress = remember { Animatable(0f) }

val zapColor = WispThemeColors.zapColor
val zapColor = WispThemeColors.zapAnimationColor

if (!isActive && progress.value <= 0f) return

Expand Down
34 changes: 31 additions & 3 deletions app/src/main/kotlin/com/wisp/app/ui/theme/Theme.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ data class WispColors(
object WispThemeColors {
val backgroundColor: Color @Composable get() = LocalWispColors.current.backgroundColor
val zapColor: Color @Composable get() = LocalWispColors.current.zapColor
val zapAnimationColor: Color @Composable get() = vividZapColor(LocalWispColors.current.zapColor)
val repostColor: Color @Composable get() = LocalWispColors.current.repostColor
val bookmarkColor: Color @Composable get() = LocalWispColors.current.bookmarkColor
val paidColor: Color @Composable get() = LocalWispColors.current.paidColor
Expand Down Expand Up @@ -82,6 +83,19 @@ private fun darkenColor(color: Color, fraction: Float = 0.6f): Color {
return Color(ColorUtils.HSLToColor(hsl))
}

/**
* Vivid variant of a zap color for the celebratory bolt animations. Floors
* lightness so the burst never reads muddy or dark on light backgrounds,
* while leaving already-bright dark-mode colors untouched.
*/
private fun vividZapColor(color: Color): Color {
val hsl = FloatArray(3)
ColorUtils.colorToHSL(color.toArgb(), hsl)
hsl[1] = (hsl[1] * 1.15f).coerceIn(0f, 1f)
hsl[2] = hsl[2].coerceAtLeast(0.5f)
return Color(ColorUtils.HSLToColor(hsl))
}

@Composable
fun WispTheme(
isDarkTheme: Boolean = true,
Expand All @@ -98,6 +112,14 @@ fun WispTheme(
val primaryContainerDark = remember(primary) { darkenColor(primary, 0.6f) }
val primaryContainerLight = remember(primary) { lightenColor(primary, 0.7f) }

// Light mode needs a deeper primary than the bright dark-mode accent so
// buttons keep enough contrast against the near-white surfaces. The default
// accent maps to a curated value shared with iOS; custom accents are darkened.
val customLightPrimary = remember(accentColor, themePreset) {
if (accentColor == Color(0xFFFF9800)) themePreset.light.primary
else darkenColor(accentColor, 0.18f)
}

val colorScheme = if (isDarkTheme) {
if (isCustomTheme) {
darkColorScheme(
Expand Down Expand Up @@ -135,7 +157,7 @@ fun WispTheme(
} else {
if (isCustomTheme) {
lightColorScheme(
primary = accentColor,
primary = customLightPrimary,
onPrimary = Color.White,
primaryContainer = primaryContainerLight,
onPrimaryContainer = darkenColor(accentColor, 0.4f),
Expand Down Expand Up @@ -189,11 +211,17 @@ fun WispTheme(
}
} else {
if (isCustomTheme) {
// zap + bookmark colors track `customLightPrimary` so the
// zap icon, post-success count, and bookmark glyph all
// render the same orange the rest of the light-mode theme
// uses for buttons + accents. Previously these were pinned
// to a darker `#B85C00`, which read as a noticeably
// different shade against `#D9730D` buttons.
WispColors(
backgroundColor = Color(0xFFECECEC),
zapColor = Color(0xFFB85C00),
zapColor = customLightPrimary,
repostColor = Color(0xFF2E7D32),
bookmarkColor = Color(0xFFB85C00),
bookmarkColor = customLightPrimary,
paidColor = Color(0xFFC9A000)
)
} else {
Expand Down
10 changes: 7 additions & 3 deletions app/src/main/kotlin/com/wisp/app/ui/theme/Themes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ object Themes {
paidColor = Color(0xFFFFD54F)
),
light = ThemeColors(
primary = Color(0xFFCC7000),
primary = Color(0xFFD9730D),
secondary = Color(0xFFFFB74D),
background = Color(0xFFD8D8D8),
surface = Color(0xFFE8E8E8),
Expand All @@ -32,9 +32,13 @@ object Themes {
onSurface = Color(0xFF1C1B1F),
onSurfaceVariant = Color(0xFF333333),
outline = Color(0xFF999999),
zapColor = Color(0xFFB85C00),
// zap + bookmark match `primary` so the throughout-the-
// theme orange is consistent — final zap icon, post
// count text, and the top zap indicator all land on
// the same shade as buttons.
zapColor = Color(0xFFD9730D),
repostColor = Color(0xFF2E7D32),
bookmarkColor = Color(0xFFB85C00),
bookmarkColor = Color(0xFFD9730D),
paidColor = Color(0xFFC9A000)
)
),
Expand Down
77 changes: 10 additions & 67 deletions app/src/main/res/drawable/ic_launcher_background.xml
Original file line number Diff line number Diff line change
@@ -1,74 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Solid black background for the adaptive launcher icon — matches the
iOS app icon's dark surround. The orange wisp glyph in
`ic_launcher_foreground.png` (rendered from the iOS 1024.png at every
density) lands on top of this; modern launchers then mask the
composed icon to circle / squircle / rounded-square per device theme.
-->
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
<path android:fillColor="#000000"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>
Loading