Perplexity (iOS) — Jetpack Compose Implementation Guide
Companion to DESIGN.md — the framework-neutral spec. This file ports Perplexity’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the triple-circle brand mark, inline citation chips, 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 (Perplexity’s near-black research-notebook canvas, the single cyan-teal accent, inline citations + source cards, the triple-circle mark) while making everything idiomatic Android —
NavigationBarinstead of a UITabBar, aPath-drawn mark instead of a SwiftUIShape,LazyRowsource cards,sp/dpinstead ofpt.
Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for source-card favicons and Discover hero images. No color extraction — the accent is a fixed teal scale, so Palette is not needed. Perplexity is dark-first; a light scheme is provided.
1. Color Tokens
// ui/theme/PerplexityColors.kt
import androidx.compose.ui.graphics.Color
object PerplexityColors {
// Canvas (dark-first)
val Canvas = Color(0xFF0A0A0A) // near-black — every screen by default
val Surface1 = Color(0xFF171717) // input field, source card, modal
val Surface2 = Color(0xFF1F1F1F) // chips, pressed
val Surface3 = Color(0xFF2A2A2A) // sheets, sticky headers
val Divider = Color(0xFF2A2A2A)
// Text (on dark)
val TextPrimary = Color(0xFFFAFAFA) // off-white — NOT pure white
val TextSecondary = Color(0xFFA1A1A1)
val TextTertiary = Color(0xFF6E6E6E)
val TextMuted = Color(0xFF4A4A4A)
// Perplexity Teal (the single signature accent)
val Teal = Color(0xFF20B8CD)
val TealBright = Color(0xFF3DD6EC) // streaming cursor, hover
val TealDeep = Color(0xFF1591A3) // pressed, light-mode teal
val TealSoft = Color(0xFF0F3A42) // Pro Steps fill, focused chip bg
// Code & syntax
val CodeBg = Color(0xFF0E0E0E)
val CodeFg = Color(0xFFE5E5E5)
val SyntaxStr = Color(0xFFA8E063) // lime
val SyntaxNum = Color(0xFFF2A65A) // soft orange
val SyntaxFunc = Color(0xFFB988F2) // soft purple
// Semantic
val Success = Color(0xFF22C55E)
val Warning = Color(0xFFF59E0B)
val Error = Color(0xFFEF4444)
val ProGold = Color(0xFFE0B341)
// Light mode (supported, but Perplexity defaults dark)
val LightCanvas = Color(0xFFFFFFFF)
val LightSurface1 = Color(0xFFF7F7F7)
val LightSurface2 = Color(0xFFEFEFEF)
val LightDivider = Color(0xFFE5E5E5)
val LightTextPri = Color(0xFF111111)
val LightTextSec = Color(0xFF555555)
val TealLight = Color(0xFF1591A3) // darker teal for WCAG AA on white
}
Wire it into both schemes. Perplexity is dark-first; the light scheme preserves all teal accents but darkens the teal to #1591A3 for WCAG on white.
// 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 PerplexityDark = darkColorScheme(
primary = PerplexityColors.Teal,
onPrimary = PerplexityColors.Canvas, // intentional: dark text on teal (WCAG)
background = PerplexityColors.Canvas,
onBackground = PerplexityColors.TextPrimary,
surface = PerplexityColors.Surface1,
onSurface = PerplexityColors.TextPrimary,
surfaceVariant = PerplexityColors.Surface2,
outline = PerplexityColors.Divider,
error = PerplexityColors.Error,
)
private val PerplexityLight = lightColorScheme(
primary = PerplexityColors.TealLight,
onPrimary = PerplexityColors.LightCanvas,
background = PerplexityColors.LightCanvas,
onBackground = PerplexityColors.LightTextPri,
surface = PerplexityColors.LightSurface1,
onSurface = PerplexityColors.LightTextPri,
surfaceVariant = PerplexityColors.LightSurface2,
outline = PerplexityColors.LightDivider,
error = PerplexityColors.Error,
)
@Composable
fun PerplexityTheme(
dark: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit,
) = MaterialTheme(
colorScheme = if (dark) PerplexityDark else PerplexityLight,
typography = PerplexityTypography,
content = content,
)
2. Typography
Perplexity is a two-face system: FK Grotesk Neue (Florian Karsten — chrome/brand voice) and Inter (answer body). Drop both in res/font/; FK Grotesk Mono drives citation numerals. Fall back to Inter for both (closest geometric-humanist substitute that ships everywhere). Code uses JetBrains Mono.
// ui/theme/PerplexityType.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.FontWeight
import androidx.compose.ui.unit.sp
val FKGrotesk = FontFamily(
Font(R.font.fk_grotesk_neue_regular, FontWeight.Normal),
Font(R.font.fk_grotesk_neue_medium, FontWeight.Medium),
Font(R.font.fk_grotesk_neue_semibold, FontWeight.SemiBold),
Font(R.font.fk_grotesk_neue_bold, FontWeight.Bold),
)
val FKGroteskMono = FontFamily(Font(R.font.fk_grotesk_mono_medium, FontWeight.Medium))
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),
)
val JetBrainsMono = FontFamily(
Font(R.font.jetbrains_mono_regular, FontWeight.Normal),
Font(R.font.jetbrains_mono_medium, FontWeight.Medium),
)
// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1)
object PerplexityText {
val Display = TextStyle(FKGrotesk, fontWeight = FontWeight.Bold, fontSize = 30.sp, lineHeight = 35.sp, letterSpacing = (-0.5).sp)
val Question = TextStyle(FKGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = (-0.3).sp)
val Section = TextStyle(FKGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 18.sp, lineHeight = 23.sp, letterSpacing = (-0.1).sp)
val Subsection = TextStyle(FKGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 21.sp)
val Body = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp) // 1.5 — denser than Claude
val BodyBold = TextStyle(Inter, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, lineHeight = 24.sp)
val ListItem = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 24.sp)
val SourceTitle = TextStyle(Inter, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 17.sp)
val SourceDom = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 14.sp)
val Citation = TextStyle(FKGroteskMono, fontWeight = FontWeight.Medium, fontSize = 11.sp, lineHeight = 11.sp)
val SearchHint = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 22.sp)
val Button = TextStyle(FKGrotesk, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 15.sp)
val ProBadge = TextStyle(FKGrotesk, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 11.sp, letterSpacing = 0.2.sp)
val Tab = TextStyle(FKGrotesk, fontWeight = FontWeight.Medium, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.2.sp)
val Meta = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 12.sp, lineHeight = 16.sp)
val Caption = TextStyle(Inter, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 14.sp)
val SenderLabel = TextStyle(FKGrotesk, fontWeight = FontWeight.Medium, fontSize = 13.sp, lineHeight = 17.sp)
val GroupHeader = TextStyle(FKGrotesk, fontWeight = FontWeight.Bold, fontSize = 11.sp, lineHeight = 13.sp, letterSpacing = 0.4.sp)
val CodeBlock = TextStyle(JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 14.sp, lineHeight = 21.sp)
val CodeInline = TextStyle(JetBrainsMono, fontWeight = FontWeight.Medium, fontSize = 14.sp, lineHeight = 20.sp)
val CodeLang = TextStyle(JetBrainsMono, fontWeight = FontWeight.Normal, fontSize = 11.sp, lineHeight = 14.sp)
}
// Map onto Material 3 slots so stock components inherit the brand
val PerplexityTypography = Typography(
headlineLarge = PerplexityText.Display,
titleLarge = PerplexityText.Section,
titleMedium = PerplexityText.Subsection,
bodyMedium = PerplexityText.Body,
labelSmall = PerplexityText.Tab,
)
3. Signature Components
The Triple-Circle Brand Mark
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.cos
import kotlin.math.sin
@Composable
fun PerplexityMark(
size: Dp = 20.dp,
color: Color = PerplexityColors.Teal, // NEVER any color outside the teal scale
modifier: Modifier = Modifier,
) {
Canvas(modifier.size(size)) {
val c = Offset(this.size.width / 2f, this.size.height / 2f)
val r = minOf(this.size.width, this.size.height) * 0.32f
val d = r * 0.65f
// Three overlapping circles in a triangular formation
val angles = floatArrayOf(-Math.PI.toFloat() / 2f, Math.PI.toFloat() / 6f, 5f * Math.PI.toFloat() / 6f)
angles.forEach { a ->
drawCircle(
color = color,
radius = r,
center = Offset(c.x + d * cos(a), c.y + d * sin(a)),
style = Stroke(width = r * 0.5f),
)
}
}
}
Search Input (the home of the app, with teal focus glow)
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.AttachFile
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@Composable
fun SearchInput(
text: String,
onTextChange: (String) -> Unit,
onSubmit: () -> Unit,
onAttach: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val canSend = text.trim().isNotEmpty()
val haptics = LocalHapticFeedback.current
Row(
modifier = modifier
.fillMaxWidth()
// Teal focus glow — the only "glow" in the app (Android has no live blur; a teal
// halo border is the analog of the iOS rgba(32,184,205,0.25) 4pt ring)
.then(if (focused) Modifier.border(4.dp, PerplexityColors.Teal.copy(alpha = 0.25f), RoundedCornerShape(28.dp)) else Modifier)
.clip(RoundedCornerShape(24.dp))
.background(PerplexityColors.Surface1)
.border(
width = if (focused) 1.5.dp else 1.dp,
color = if (focused) PerplexityColors.Teal else PerplexityColors.Surface3,
shape = RoundedCornerShape(24.dp),
)
.padding(horizontal = 18.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
BasicTextField(
value = text,
onValueChange = onTextChange,
textStyle = PerplexityText.SearchHint.copy(color = PerplexityColors.TextPrimary),
cursorBrush = androidx.compose.ui.graphics.SolidColor(PerplexityColors.Teal),
modifier = Modifier
.weight(1f)
.padding(vertical = 14.dp)
.onFocusChanged { focused = it.isFocused },
maxLines = 8,
decorationBox = { inner ->
if (text.isEmpty()) Text("Ask anything…", style = PerplexityText.SearchHint, color = PerplexityColors.TextTertiary)
inner()
},
)
Icon(
Icons.Filled.AttachFile, contentDescription = "Attach",
tint = PerplexityColors.TextSecondary,
modifier = Modifier.padding(bottom = 14.dp).size(18.dp)
.clickable(MutableInteractionSource(), indication = null, onClick = onAttach),
)
Box(
Modifier
.padding(bottom = 10.dp)
.size(36.dp)
.clip(CircleShape)
.background(if (canSend) PerplexityColors.Teal else PerplexityColors.Surface3)
.clickable(enabled = canSend) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress) // .medium
onSubmit()
},
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Filled.ArrowUpward,
contentDescription = "Send",
tint = if (canSend) PerplexityColors.Canvas else PerplexityColors.TextTertiary, // dark on teal
modifier = Modifier.size(14.dp),
)
}
}
}
Source Card (horizontal scroll above every answer)
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.ui.layout.ContentScale
import coil.compose.AsyncImage
data class Source(val number: Int, val faviconUrl: String?, val domain: String, val title: String)
@Composable
fun SourceCard(source: Source, onClick: () -> Unit, modifier: Modifier = Modifier) {
Column(
modifier
.width(200.dp)
.height(80.dp)
.clip(RoundedCornerShape(12.dp))
.background(PerplexityColors.Surface1)
.border(1.dp, PerplexityColors.Surface3, RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AsyncImage(
model = source.faviconUrl,
contentDescription = null,
modifier = Modifier.size(16.dp).clip(RoundedCornerShape(3.dp)),
contentScale = ContentScale.Crop,
)
Text(source.domain, style = PerplexityText.SourceDom, color = PerplexityColors.TextSecondary, modifier = Modifier.weight(1f), maxLines = 1)
Box(
Modifier.clip(RoundedCornerShape(3.dp)).background(PerplexityColors.Surface2).padding(horizontal = 5.dp, vertical = 1.dp),
) {
Text("${source.number}", style = PerplexityText.Citation.copy(fontWeight = FontWeight.Bold, fontSize = 10.sp), color = PerplexityColors.TextSecondary)
}
}
Text(source.title, style = PerplexityText.SourceTitle, color = PerplexityColors.TextPrimary, maxLines = 2)
}
}
@Composable
fun SourceCardRow(sources: List<Source>, onSource: (Source) -> Unit, onShowAll: () -> Unit) {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
itemsIndexed(sources) { _, s -> SourceCard(s, { onSource(s) }) }
item {
Box(
Modifier
.width(120.dp).height(80.dp)
.clip(RoundedCornerShape(12.dp))
.background(PerplexityColors.Surface2)
.clickable(onClick = onShowAll),
contentAlignment = Alignment.Center,
) {
Text("Show all ${sources.size} sources", style = PerplexityText.SourceDom, color = PerplexityColors.TextSecondary)
}
}
}
}
4. Inline Citation Chips + Source-Card Scroll-To
Perplexity’s defining interaction: numbered [1] chips typeset inline within the answer prose (never a footer), each tapping through to its matching source card with a teal flash. iOS uses ScrollViewReader.scrollTo; Android uses a LazyListState/scroll-to-index plus an Animatable border flash on the target card.
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
@Composable
fun CitationChip(
number: Int,
onTap: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val haptics = LocalHapticFeedback.current
Box(
modifier
.padding(2.dp) // expands toward a ~32dp tap target
.clip(RoundedCornerShape(4.dp))
.background(if (focused) PerplexityColors.TealSoft else PerplexityColors.Surface2)
.border(1.dp, if (focused) PerplexityColors.Teal else PerplexityColors.Surface3, RoundedCornerShape(4.dp))
.clickable {
focused = true
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // .soft
onTap()
}
.padding(horizontal = 5.dp)
.heightIn(min = 18.dp),
contentAlignment = Alignment.Center,
) {
Text(
"$number",
style = PerplexityText.Citation,
color = if (focused) PerplexityColors.Teal else PerplexityColors.TextSecondary,
)
}
// Caller resets `focused` ~300ms after the scroll completes
}
// Render answer prose with tappable chips: split markdown into text runs + [n] tokens and
// emit a Text(...) interleaved with CitationChip(...) inside a FlowRow so they wrap inline.
@Composable
fun rememberCitationScroll(): Pair<LazyListState, suspend (Int) -> Unit> {
val state = androidx.compose.foundation.lazy.rememberLazyListState()
val flash = remember { Animatable(0f) }
val scrollTo: suspend (Int) -> Unit = { index ->
state.animateScrollToItem(index) // 400ms-feel ease handled by LazyList
flash.snapTo(1f); flash.animateTo(0f, tween(200)) // 200ms teal border flash on arrival
}
return state to scrollTo
}
The streaming answer ends with a teal caret: a 6.dp × 16.dp TealBright box blinking on a rememberInfiniteTransition at a 500ms interval (faster than Claude’s 600ms), removed when the answer completes. The “Searching the web…” indicator (3 teal dots, 200ms stagger, 1200ms cycle, SenderLabel text trailing) shows immediately on submit and is replaced by SourceCardRow.
5. Navigation
Perplexity has a 4-tab bottom bar (Home / Discover / Library / Spaces). Use Material 3 NavigationBar; the iOS bar matches the canvas with no blur on scroll, so an opaque Canvas surface is exact (no translucency needed here). Active is off-white #FAFAFA with a teal indicator dot under the label; inactive #6E6E6E.
import androidx.compose.material.icons.filled.*
@Composable
fun PerplexityBottomBar(selected: Int, onSelect: (Int) -> Unit) {
val haptics = LocalHapticFeedback.current
NavigationBar(
containerColor = PerplexityColors.Canvas,
tonalElevation = 0.dp,
) {
val items = listOf(
"Home" to Icons.Filled.Search,
"Discover" to Icons.Filled.Explore,
"Library" to Icons.Filled.Bookmarks,
"Spaces" to Icons.Filled.Workspaces,
)
items.forEachIndexed { i, (label, icon) ->
val active = selected == i
NavigationBarItem(
selected = active,
onClick = {
haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // .selection
onSelect(i)
},
icon = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(icon, contentDescription = label, modifier = Modifier.size(22.dp))
Spacer(Modifier.height(4.dp))
Box(
Modifier.size(width = 16.dp, height = 2.dp)
.background(if (active) PerplexityColors.Teal else Color.Transparent),
)
}
},
label = { Text(label, style = PerplexityText.Tab) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = PerplexityColors.TextPrimary, // off-white
selectedTextColor = PerplexityColors.TextPrimary,
unselectedIconColor = PerplexityColors.TextTertiary, // #6E6E6E
unselectedTextColor = PerplexityColors.TextTertiary,
indicatorColor = Color.Transparent, // no Material pill — Perplexity uses a teal dot
),
)
}
}
}
The conversation-history sidebar is a ModalNavigationDrawer (ModalDrawerSheet, Canvas background) with a full-width “+ New thread” button (Surface1 fill, 1.dp Surface3 border, 10.dp radius) followed by date-grouped thread rows (GroupHeader uppercase headers, 56.dp rows with Inter title + timestamp).
6. Motion
Perplexity motion is live and information-paced — slightly faster than Claude, restrained but responsive.
| Moment | Compose recipe |
|---|---|
| Send tap | circle scale 1 → 0.94 → 1 tween(200) + HapticFeedbackType.LongPress (medium); question slides up, “Searching…” fades in tween(250) |
| Source cards appear | LazyRow items animateItemPlacement + per-item slideInVertically(tween(350)) with 60ms stagger (cascade) |
| Citation → source | LazyListState.animateScrollToItem + target card Animatable border flash tween(200) teal |
| Streaming text | word-by-word AnimatedVisibility/fadeIn at ~60ms per word |
| Streaming cursor | rememberInfiniteTransition alpha 1 → 0, 500ms |
| Pro toggle | indicator animateDpAsState slide tween(200), label color cross-fade, .selection haptic |
| Pro Steps expand | AnimatedVisibility(expandVertically(tween(300))), 30ms per-step stagger |
| Tab switch | teal dot fadeIn tween(200), .selection haptic |
// "Searching the web…" — three teal dots, 200ms stagger, 1200ms cycle
@Composable
fun SearchingIndicator() {
val t = rememberInfiniteTransition(label = "searching")
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
repeat(3) { i ->
val s by t.animateFloat(
1f, 1.3f,
infiniteRepeatable(tween(600, delayMillis = i * 200), RepeatMode.Reverse),
label = "dot$i",
)
Box(
Modifier.size(6.dp).graphicsLayer { scaleX = s; scaleY = s }
.clip(CircleShape).background(PerplexityColors.Teal),
)
}
}
Text("Searching the web…", style = PerplexityText.SenderLabel, color = PerplexityColors.TextSecondary)
}
}
Haptics: prefer LocalHapticFeedback. Use HapticFeedbackType.LongPress for the medium send impact and HapticFeedbackType.TextHandleMove for the soft citation tap; for finer control use LocalView.current.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK) or a Vibrator VibrationEffect.createOneShot(8, ...).
7. Icons
Perplexity ships custom glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. The triple-circle mark is NOT an icon — draw it via Canvas (§3) and never tint it outside the teal scale.
| Purpose | SF Symbol (iOS) | Material Icon (Compose) |
|---|---|---|
| Send | arrow.up |
Icons.Filled.ArrowUpward |
| Attach | paperclip |
Icons.Filled.AttachFile |
| Copy | doc.on.doc |
Icons.Filled.ContentCopy |
| Share | square.and.arrow.up |
Icons.Filled.IosShare |
| Save | bookmark / bookmark.fill |
Icons.Outlined.BookmarkBorder / Icons.Filled.Bookmark |
| Pro sparkles | sparkles |
Icons.Filled.AutoAwesome |
| Step check | checkmark.circle.fill |
Icons.Filled.CheckCircle |
| Chevron up/down | chevron.up / chevron.down |
Icons.Filled.KeyboardArrowUp / KeyboardArrowDown |
| Related arrow | arrow.right |
Icons.Filled.ArrowForward |
| Home (tab) | magnifyingglass |
Icons.Filled.Search |
| Discover (tab) | safari |
Icons.Filled.Explore |
| Library (tab) | books.vertical |
Icons.Filled.Bookmarks |
| Spaces (tab) | square.stack.3d.down.right |
Icons.Filled.Workspaces |
| Sidebar | line.3.horizontal |
Icons.Filled.Menu |
| Overflow | ellipsis |
Icons.Filled.MoreHoriz |
| Settings | gearshape |
Icons.Filled.Settings |
| Show all sources | chevron.right |
Icons.Filled.ChevronRight |
8. Minimum SDK & Accessibility Notes
- minSdk 24 (Compose floor is 21;
Canvasmark + infinite-transition motion are comfortable at 24).compileSdk 34+, Compose BOM2024.09+, Kotlin2.0+. - Edge-to-edge: call
enableEdgeToEdge(); the near-black canvas wants light-content system bars (dark-content in light mode). The 56dp header sits below the camera cutout; the search input pins above the IME — useModifier.imePadding()so it never hides behind the keyboard; the tab bar clears the gesture nav viaScaffoldinsets. - Font scaling:
sphonors the user’s font scale — keep it on answer body, all headings, list items, source titles, question titles, related questions. Code scales but cap it (fontSizeclamp at 18sp) to prevent overflow. Pin layout-sensitive text (citation chips, 11sp source domains, 10sp tab labels, code-language label) by deriving fromdporCompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)). - TalkBack: the answer block announces “Answer from Perplexity” then the body once; citation chips read “Citation 1, button — opens source 1: {title}” via
Modifier.semantics { ... }+ a custom “Open source” action so users reach the source without scrolling; source cards read “Source 1, {domain}, {title}, button”. Announce “Generating answer” once on stream start and “Answer complete” on finalize — never per token. - Touch targets: Material guidance is 48.dp minimum. The 36.dp send circle gets a 48–56.dp hit area; the 18.dp citation chip is wrapped to a ~32–48.dp target via padding; source cards and related-question rows are full-row tappable.
- Contrast:
#FAFAFAon#0A0A0Aexceeds WCAG AAA;#A1A1A1meets AA at 14sp+;#6E6E6Eonly at 18sp+ — do not use TextTertiary on small body text. Teal CTAs use Canvas#0A0A0Atext (reversed from typical) for AA; in light mode the teal darkens to#1591A3. - Reduce motion: when
Settings.Global.ANIMATOR_DURATION_SCALE == 0, skip the searching-dot pulse, the source-card cascade, the streaming-cursor blink, and the focus-ring glow — preserve haptics. - Dynamic / dark color: do not enable Material You
dynamicColorScheme()— Perplexity’s single teal accent and near-black canvas are brand-load-bearing and must not retint to the user’s wallpaper. The triple-circle mark must stay within the teal scale (#20B8CD/#3DD6EC/#1591A3) in every theme. Light mode flips canvas/surface/text and darkens teal to#1591A3for WCAG; everything else is preserved.