Clubhouse (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Clubhouse’s visual language to Android with Jetpack Compose (Material 3): a color token object, the serif/sans Typography pairing, paste-ready @Composables for the signature speaking-pulse avatar / room stage / raise-hand bar, motion, and haptics.
Why a Compose guide for an iOS-referenced app? The DESIGN.md tokens are platform-neutral. This file keeps the visual identity (Clubhouse’s warm cream hallway, serif titles, circular speaker faces, the emerald speaking pulse, the all-pill control language) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar,ModalBottomSheetinstead of a SwiftUI sheet,infiniteRepeatablefor the pulse,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for avatar photos. No color extraction — Clubhouse’s palette is fixed warm tokens, so Palette is not needed. Do not enable Material You — the warm cream + emerald identity must hold regardless of wallpaper.
1. Color Tokens
// ui/theme/ClubhouseColors.kt
import androidx.compose.ui.graphics.Color
object ChColors {
// Light — the Hallway
val Cream = Color(0xFFF2EFE4)
val CreamCard = Color(0xFFFBF9F1)
val CreamPressed = Color(0xFFEAE6D8)
val DividerLight = Color(0xFFDDD8C8)
// Dark — the Room (warm near-black, NOT slate)
val Room = Color(0xFF1A1A1A)
val DarkSurface1 = Color(0xFF242220)
val DarkSurface2 = Color(0xFF2E2B27)
val DividerDark = Color(0xFF3A372F)
// Text
val Ink = Color(0xFF1A1A1A)
val InkSecondary = Color(0xFF6B6453)
val InkTertiary = Color(0xFF9A9483)
val CreamText = Color(0xFFF2EFE4)
val CreamText2 = Color(0xFFB8B2A0)
val CreamText3 = Color(0xFF837D6D)
// Brand — the single accent
val Emerald = Color(0xFF38B569)
val EmeraldDeep = Color(0xFF2E9A58)
val EmeraldSoft = Color(0x2E38B569) // 18% — pulse halo / tinted chip
val EmeraldInk = Color(0xFF07210F) // text on emerald
// Semantic
val Gold = Color(0xFFE9C46A) // moderator
val Leave = Color(0xFFE5575C)
val Scrim = Color(0x80141210) // warm, NOT black
// Avatar gradient palette (content color)
val PeachA = Color(0xFFF4C77B); val PeachB = Color(0xFFE0A24B)
val SageA = Color(0xFF9FD8B4); val SageB = Color(0xFF5FB484)
val LavA = Color(0xFFC9B6E8); val LavB = Color(0xFF9C7FD0)
val CoralA = Color(0xFFF2A9A0); val CoralB = Color(0xFFD9776B)
val SkyA = Color(0xFFA9C7E8); val SkyB = Color(0xFF6E9CD0)
}
val chAvatarPairs = listOf(
ChColors.PeachA to ChColors.PeachB,
ChColors.SageA to ChColors.SageB,
ChColors.LavA to ChColors.LavB,
ChColors.CoralA to ChColors.CoralB,
ChColors.SkyA to ChColors.SkyB,
)
fun chAvatarFor(id: Int) = chAvatarPairs[kotlin.math.abs(id) % chAvatarPairs.size]
Wire it into both schemes. The light scheme is the warm cream hallway; the dark scheme is the warm Room — never a cold slate.
// ui/theme/Theme.kt
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.MaterialTheme
private val ChLight = lightColorScheme(
primary = ChColors.Emerald,
onPrimary = ChColors.EmeraldInk,
background = ChColors.Cream,
onBackground = ChColors.Ink,
surface = ChColors.CreamCard,
onSurface = ChColors.Ink,
surfaceVariant = ChColors.CreamPressed,
outline = ChColors.DividerLight,
error = ChColors.Leave,
)
private val ChDark = darkColorScheme(
primary = ChColors.Emerald, // identical accent in both modes
onPrimary = ChColors.EmeraldInk,
background = ChColors.Room, // warm near-black
onBackground = ChColors.CreamText,
surface = ChColors.DarkSurface1,
onSurface = ChColors.CreamText,
surfaceVariant = ChColors.DarkSurface2,
outline = ChColors.DividerDark,
error = ChColors.Leave,
)
@Composable
fun ClubhouseTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) ChDark else ChLight,
typography = ChTypography,
content = content,
)
2. Typography
Clubhouse pairs a serif display for titles with a sans for UI. Drop DM Serif Display (titles) + Inter (UI) TTFs in res/font/ — both SIL OFL.
// ui/theme/ChType.kt
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
val DMSerif = FontFamily(
Font(R.font.dmserifdisplay_regular, FontWeight.Normal),
Font(R.font.dmserifdisplay_italic, FontWeight.Normal, FontStyle.Italic),
)
val Inter = FontFamily(
Font(R.font.inter_regular, FontWeight.Normal),
Font(R.font.inter_medium, FontWeight.Medium),
Font(R.font.inter_semibold, FontWeight.SemiBold),
Font(R.font.inter_bold, FontWeight.Bold),
)
// Named ramp — mirrors DESIGN.md §3 (pt → sp 1:1)
object ChText {
// Serif display — titles, topics, greetings, quotes
val RoomTitle = TextStyle(DMSerif, fontWeight = FontWeight.Normal, fontSize = 28.sp, lineHeight = 34.sp, letterSpacing = (-0.2).sp)
val ScreenTitle = TextStyle(DMSerif, fontWeight = FontWeight.Normal, fontSize = 24.sp, lineHeight = 29.sp, letterSpacing = (-0.2).sp)
val Quote = TextStyle(DMSerif, fontWeight = FontWeight.Normal, fontStyle = FontStyle.Italic, fontSize = 20.sp, lineHeight = 26.sp)
// Sans (Inter) — every UI text
val Section = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
val Subsection = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 21.sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val Name = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 18.sp)
val Button = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 16.sp, lineHeight = 16.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 20.sp)
val RoleTag = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, lineHeight = 14.sp)
val Overline = TextStyle(Inter, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.8.sp)
val Tab = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 12.sp)
}
// Map onto Material 3 slots
val ChTypography = Typography(
headlineLarge = ChText.RoomTitle,
headlineMedium = ChText.ScreenTitle,
titleMedium = ChText.Section,
bodyMedium = ChText.Body,
labelSmall = ChText.Tab,
)
3. Signature Components
Speaking-Pulse Avatar (the core atom)
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun ChSpeakerAvatar(
id: Int,
initials: String,
name: String,
role: String? = null,
isSpeaking: Boolean = false,
isMuted: Boolean = false,
isHost: Boolean = false,
size: Dp = 60.dp,
) {
val (a, b) = chAvatarFor(id)
val transition = rememberInfiniteTransition(label = "pulse")
val haloScale by transition.animateFloat(
initialValue = 1f, targetValue = if (isSpeaking) 1.12f else 1f,
animationSpec = infiniteRepeatable(tween(900, easing = LinearEasing), RepeatMode.Reverse),
label = "haloScale",
)
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(7.dp)) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size + 16.dp)) {
if (isSpeaking) {
Box(
Modifier
.size(size + 16.dp)
.scale(haloScale)
.clip(CircleShape)
.background(ChColors.EmeraldSoft)
)
}
Box(
Modifier
.size(size)
.clip(CircleShape)
.background(Brush.linearGradient(listOf(a, b)))
.then(if (isSpeaking) Modifier.border(3.dp, ChColors.Emerald, CircleShape) else Modifier)
.alpha(if (isMuted) 0.55f else 1f),
contentAlignment = Alignment.Center,
) {
Text(initials, fontFamily = Inter, fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.33f).sp, color = Color(0xFF2A2620))
}
if (isMuted) {
Box(
Modifier.align(Alignment.BottomEnd).size(22.dp).clip(CircleShape)
.background(ChColors.DarkSurface2).border(2.dp, ChColors.Room, CircleShape),
contentAlignment = Alignment.Center,
) { Icon(Icons.Filled.MicOff, null, tint = ChColors.CreamText2, modifier = Modifier.size(11.dp)) }
}
if (isHost) {
Box(
Modifier.align(Alignment.TopEnd).size(20.dp).clip(CircleShape)
.background(ChColors.DarkSurface2).border(2.dp, ChColors.Room, CircleShape),
contentAlignment = Alignment.Center,
) { Text("🎙️", fontSize = 11.sp) }
}
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(name, style = ChText.Name, maxLines = 1,
color = if (role == "Moderator") ChColors.Gold else ChColors.CreamText)
if (role != null) Text(role, fontFamily = Inter, fontWeight = FontWeight.Medium,
fontSize = 11.sp, color = ChColors.CreamText3)
}
}
}
Room Stage (grouped speaker grid)
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.items
data class ChSpeaker(val id: Int, val initials: String, val name: String, val role: String? = null,
val speaking: Boolean = false, val muted: Boolean = false, val host: Boolean = false)
data class ChGroup(val label: String, val speakers: List<ChSpeaker>)
@Composable
fun ChRoomStage(groups: List<ChGroup>) {
LazyVerticalGrid(
columns = GridCells.Fixed(4),
modifier = Modifier.fillMaxSize().background(ChColors.Room),
contentPadding = PaddingValues(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
groups.forEach { group ->
item(span = { GridItemSpan(maxLineSpan) }) {
Text(group.label.uppercase(), style = ChText.Overline, color = ChColors.CreamText3,
modifier = Modifier.padding(start = 6.dp, top = 14.dp, bottom = 10.dp))
}
items(group.speakers, key = { it.id }) { s ->
ChSpeakerAvatar(s.id, s.initials, s.name, s.role, s.speaking, s.muted, s.host)
}
}
}
}
Raise-Hand Bar (footer)
import androidx.compose.animation.core.*
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material3.Icon
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun ChRaiseHandBar() {
var raised by remember { mutableStateOf(false) }
var waveTrigger by remember { mutableIntStateOf(0) }
val haptics = LocalHapticFeedback.current
val wave by animateFloatAsState(
targetValue = 0f,
animationSpec = keyframes {
durationMillis = 600
(-15f) at 100; 15f at 250; (-15f) at 400; 15f at 500; 0f at 600
},
label = "wave-$waveTrigger",
)
Row(
Modifier
.fillMaxWidth()
.background(ChColors.Room)
.drawTopHairline(ChColors.DividerDark)
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
Modifier
.clip(RoundedCornerShape(50))
.background(if (raised) ChColors.EmeraldSoft else ChColors.DarkSurface2)
.clickable {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
if (!raised) waveTrigger++
raised = !raised
}
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("✋", fontSize = 18.sp, modifier = Modifier.rotate(if (raised) 0f else wave))
Text(if (raised) "Hand raised" else "Raise hand", style = ChText.Button,
color = if (raised) ChColors.Emerald else ChColors.CreamText)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(22.dp)) {
Box(Modifier.size(24.dp).clip(CircleShape).background(ChColors.EmeraldSoft), Alignment.Center) {
Box(Modifier.size(14.dp).clip(CircleShape).background(ChColors.Emerald))
}
Icon(Icons.Filled.Mic, "Mic", tint = ChColors.CreamText2, modifier = Modifier.size(22.dp))
}
}
}
fun Modifier.drawTopHairline(color: Color) = drawWithContent {
drawContent()
drawLine(color, androidx.compose.ui.geometry.Offset(0f, 0f),
androidx.compose.ui.geometry.Offset(size.width, 0f), strokeWidth = 1f)
}
Hallway Room Card
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@Composable
fun ChHallwayCard(club: String, isLive: Boolean, title: String,
avatars: List<Pair<Int, String>>, count: Int) {
Card(
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = ChColors.CreamCard),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Text(club, style = ChText.Meta.copy(fontWeight = FontWeight.SemiBold), color = ChColors.InkSecondary)
Box(
Modifier.clip(RoundedCornerShape(50))
.background(if (isLive) ChColors.EmeraldSoft else ChColors.CreamPressed)
.padding(horizontal = 10.dp, vertical = 5.dp)
) {
Text(if (isLive) "● Open" else "Upcoming", style = ChText.RoleTag,
color = if (isLive) ChColors.Emerald else ChColors.InkSecondary)
}
}
Text(title, style = ChText.RoomTitle.copy(fontSize = 20.sp), color = ChColors.Ink)
Row(verticalAlignment = Alignment.CenterVertically) {
avatars.take(5).forEachIndexed { i, (id, ini) ->
val (c1, c2) = chAvatarFor(id)
Box(
Modifier
.offset(x = if (i == 0) 0.dp else (-10 * i).dp)
.size(32.dp).clip(CircleShape)
.background(Brush.linearGradient(listOf(c1, c2)))
.border(2.dp, ChColors.CreamCard, CircleShape),
contentAlignment = Alignment.Center,
) { Text(ini, fontFamily = Inter, fontWeight = FontWeight.Bold, fontSize = 11.sp, color = Color.White) }
}
Text("$count in the room", style = ChText.Meta,
modifier = Modifier.offset(x = (-10 * (avatars.size.coerceAtMost(5) - 1) + 16).dp))
}
}
}
}
4. Primary Pill Button
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@Composable
fun ChPrimaryButton(text: String, onClick: () -> Unit) {
Button(
onClick = onClick,
shape = RoundedCornerShape(50), // full pill — every Clubhouse button
colors = ButtonDefaults.buttonColors(
containerColor = ChColors.Emerald,
contentColor = ChColors.EmeraldInk,
),
contentPadding = PaddingValues(horizontal = 28.dp, vertical = 14.dp),
) {
Text(text, style = ChText.Button)
}
}
5. Navigation
Clubhouse has a 4-tab bottom strip and a full-screen modal Room. On Android model the strip as a NavigationBar (no colored pill — active is just ink/cream) and the Room as a full-screen destination that collapses to a persistent mini-bar.
import androidx.compose.material3.*
import androidx.compose.material.icons.outlined.*
@Composable
fun ChBottomBar(selected: Int, onSelect: (Int) -> Unit) {
NavigationBar(containerColor = ChColors.Cream, tonalElevation = 0.dp) {
val items = listOf(
"Hallway" to Icons.Outlined.Home,
"Explore" to Icons.Outlined.Explore,
"Activity" to Icons.Outlined.Notifications,
"Profile" to Icons.Outlined.AccountCircle,
)
items.forEachIndexed { i, (label, icon) ->
NavigationBarItem(
selected = selected == i,
onClick = { onSelect(i) },
icon = { Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp)) },
label = { Text(label, style = ChText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = ChColors.Ink, // ink, no colored tint
selectedTextColor = ChColors.Ink,
unselectedIconColor = ChColors.InkTertiary,
unselectedTextColor = ChColors.InkTertiary,
indicatorColor = Color.Transparent, // NO Material pill — Clubhouse has none
),
)
}
}
}
The collapsed Room mini-bar docks just above the NavigationBar as a Surface (DarkSurface1, 16.dp radius, 4.dp elevation) showing the truncated serif room title, the active speaker’s 28.dp circular avatar with its live Emerald pulse still breathing, and a trailing leave ✕ in Leave. Tapping re-expands the full-screen Room.
6. Motion
Clubhouse motion is calm — the speaking pulse is a slow ~0.9s breath; sheets slide; leaving is undramatic.
| Moment | Compose recipe |
|---|---|
| Speaking pulse | rememberInfiniteTransition → animateFloat(1f→1.12f, infiniteRepeatable(tween(900), RepeatMode.Reverse)) on the halo scale |
| New speaker joins | AnimatedVisibility scaleIn(initialScale = 0.8f, tween(200)) + fadeIn(); grid reflows animateItemPlacement(tween(200)) |
| Raise hand | ✋ animateFloat via keyframes (±15° ×2 over 600ms) + soft haptic; pill cross-fades to “Hand raised” emerald-tinted |
| Invited to speak | celebratory ModalBottomSheet slide-up + medium haptic; avatar animates between groups |
| Room enter / collapse | full-screen route slide-up tween(300); collapse slide-down to docked mini-bar tween(280) (pulse continues) |
| Tab switch | Crossfade(tween(200)) |
| Audience pill change | instant select + selection haptic |
// Speaking pulse — the canonical Clubhouse motion
val t = rememberInfiniteTransition(label = "pulse")
val s by t.animateFloat(1f, 1.12f,
infiniteRepeatable(tween(900, easing = LinearEasing), RepeatMode.Reverse), label = "halo")
Box(Modifier.scale(s).background(ChColors.EmeraldSoft, CircleShape))
Haptics: use LocalHapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) for the soft impact on raise-hand, ping, and new-speaker; for “invited to speak” use a stronger pattern via LocalView.current.performHapticFeedback(HapticFeedbackConstants.CONFIRM). Audience-pill change uses HapticFeedbackConstants.CLOCK_TICK. Leaving a room is silent — no haptic, no alarm; the voice is calm.
7. Icons
Clubhouse uses circular illustrated avatars (not icons) for people; the host pin is the 🎙️ emoji; “speaking” is never an icon — it is the emerald ring + halo. The closest first-party set is androidx.compose.material:material-icons-extended.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Collapse room | chevron.down |
Icons.Filled.KeyboardArrowDown |
| Add people | person.badge.plus |
Icons.Filled.PersonAdd |
| Share / more | square.and.arrow.up / ellipsis |
Icons.Filled.IosShare / Icons.Filled.MoreHoriz |
| Mic on | mic.fill |
Icons.Filled.Mic |
| Mic off / muted badge | mic.slash.fill |
Icons.Filled.MicOff |
| Hallway (tab) | house |
Icons.Outlined.Home |
| Explore (tab) | safari |
Icons.Outlined.Explore |
| Activity (tab) | bell |
Icons.Outlined.Notifications |
| Profile (tab) | person.circle |
Icons.Outlined.AccountCircle |
| Search | magnifyingglass |
Icons.Filled.Search |
| Reminder | bell.badge |
Icons.Filled.NotificationsActive |
| Start a room (+) | plus |
Icons.Filled.Add |
| Leave | xmark |
Icons.Filled.Close |
| Host pin | 🎙️ (emoji) | “🎙️” text glyph (not an icon) |
| Speaking | (no icon — emerald ring + halo) | (no icon) |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21; sheets + modern motion are comfortable at 24).
compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the cream hallway wants dark-content system bars (light-content in the dark Room). The room footer / hand bar and theNavigationBarneedModifier.windowInsetsPadding(WindowInsets.navigationBars); the collapsed mini-bar docks within the safe area above the nav bar. - No Material You: do not enable
dynamicColorScheme()— Clubhouse’s warm cream + emerald identity must hold regardless of wallpaper; there is one fixed accent and it means “this person has the floor”. - Warm dark mode: the dark scheme is the warm Room (
#1A1A1A/#242220/#2E2B27) — never let it drift to a neutral/blue#191919. - Font scaling:
sphonors the user’s font scale — keep it on serif titles, section headers, body, speaker names. Pin layout-critical text (role tags, 11sp group overlines, 10sp tab labels, the “+N” cluster chip) by deriving fromdporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). Truncate names to 1 line. - TalkBack: a speaking avatar needs
Modifier.semantics { contentDescription = "$name, speaking" }(the pulse must NOT be the only cue); muted “{name}, muted”; host “{name}, host”; the raise-hand pill toggles its description “Raise hand” ↔ “Hand raised”; group overlines areheading(). - Touch targets: Material guidance is 48.dp. Speaker avatars are ≥60.dp (fine); give the mic toggle (22.dp) and top-bar icons (20.dp) a 48.dp hit area via padding; the raise-hand pill is ≥48.dp tall; the whole Hallway card is clickable.
- Contrast:
#1A1A1Aon#F2EFE4and#F2EFE4on#1A1A1Apass AA;#07210Fon#38B569passes AA for the CTA; the 3dp emerald ring is supplemented by the TalkBack “speaking” string for non-color users. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, replace the breathing speaking pulse with a static 3dpEmeraldring (no infinite scale); sheet slides becomeCrossfade; skip the hand-wave (just cross-fade the pill). - Warm scrim:
ModalBottomSheet/ dialog scrims useChColors.Scrim(0x80141210, warm), never pure black — preserve the hallway warmth. - Dark mode: invert cream → warm Room; the Emerald
#38B569is identical in both schemes. Shadows are soft — Hallway cards use a barely-there 2.dp elevation on cream; in dark, separation comes from the warm#242220surface, not shadow.