coderef

Nike Run Club

Nike Run Club is a stadium tunnel — black walls, white spotlights, a runner sprinting into them. The canvas is true black (#000000) on every screen, the typography is editorial and athletic, and every interaction is shouted in screaming…
DownloadRaw
Nike Run Club

Build with the Nike Run Club look.

Palette, type and components from this DESIGN.md, applied to a sample hero.

Palette

At a glance

Theme
dark
Typefaces
Trade Gothic Condensed, SF Pro Text, SF Pro Display
Sections
8
Length
4,441 words
Platforms
Android, Expo, SwiftUI, DESIGN.md

Add it to your project

Then prompt your agent

Design System · Nike Run Club

Light and dark previews, tokens and a ready-to-use package, generated from this DESIGN.md. No AI involved: the same file always produces the same output.

Generating design system…
DownloadRaw

Nike Run Club (iOS) — Jetpack Compose Implementation Guide

Companion to DESIGN.md — the framework-neutral spec. This file ports NRC’s visual language to Android with Jetpack Compose (Material 3): a color token object, a Typography set, paste-ready @Composables, the signature Volt run-tracking ring, 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 (NRC’s true-black stadium void, radioactive Volt accent, screaming Trade Gothic Condensed) while making everything idiomatic Android — NavigationBar instead of a UITabBar, a drawArc Canvas ring instead of a SwiftUI Circle().trim, sp/dp instead of pt.

Assumes Compose BOM 2024.09+, Kotlin 2.0+, minSdk 24, Material 3, and Coil 2.6+ for plan-card photography. No color extraction — NRC’s accent system is fixed Volt/Red, so Palette is not needed.

1. Color Tokens

// ui/theme/NikeColors.kt
import androidx.compose.ui.graphics.Color

object NikeColors {
    // Nike Brand
    val Volt        = Color(0xFFCCFF00)
    val VoltPressed = Color(0xFF9FCC00)
    val VoltDim     = Color(0xFF8AA800) // light-mode accessible accent
    val Red         = Color(0xFFFA5400)
    val BrakeRed    = Color(0xFFE50916)

    // Canvas & Surfaces
    val Canvas    = Color(0xFF000000) // true black — Nike's stadium void
    val Charcoal  = Color(0xFF0A0A0A)
    val Surface1  = Color(0xFF1A1A1A)
    val Surface2  = Color(0xFF2B2B2B)
    val Divider   = Color(0xFF333333)

    // Text
    val White = Color(0xFFFFFFFF)
    val Gray1 = Color(0xFFB3B3B3)
    val Gray2 = Color(0xFF8E8E8E)
    val Gray3 = Color(0xFF5C5C5C)

    // Heart Rate Zones (workout results "training effect" page)
    val HR1Blue   = Color(0xFF3D87F4)
    val HR2Green  = Color(0xFF48C77E)
    val HR3Yellow = Color(0xFFFFD600)
    val HR4Orange = Color(0xFFFA5400) // same as Red
    val HR5Red    = Color(0xFFE50916)

    // Semantic
    val Warning = Color(0xFFFFC400)
}

Wire it into a Material 3 darkColorScheme so ripples, dividers, and default component colors inherit the brand. NRC is dark-first by brand design; a light scheme exists only for accessibility — keep the app dark regardless of the system setting.

// ui/theme/Theme.kt
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.MaterialTheme

private val NikeScheme = darkColorScheme(
    primary        = NikeColors.Volt,
    onPrimary      = NikeColors.Canvas,   // black on Volt
    secondary      = NikeColors.Red,
    background      = NikeColors.Canvas,
    onBackground   = NikeColors.White,
    surface        = NikeColors.Surface1,
    onSurface      = NikeColors.White,
    surfaceVariant = NikeColors.Surface2,
    outline        = NikeColors.Divider,
    error          = NikeColors.BrakeRed,
)

@Composable
fun NikeTheme(content: @Composable () -> Unit) =
    MaterialTheme(colorScheme = NikeScheme, typography = NikeTypography, content = content)

2. Typography

Trade Gothic Next LT (Heavy/Bold Condensed) is proprietary (Linotype/Monotype). Drop the TTFs in res/font/ (lowercase, snake_case) and build a FontFamily. Fall back to Oswald (a close geometric-condensed Google Font) or system condensed. Body/metadata use the system font (Roboto, the SF Pro analog). Stats are tabular — set fontFeatureSettings = "tnum".

// ui/theme/NikeType.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 TradeGothic = FontFamily(
    Font(R.font.trade_gothic_next_bold,  FontWeight.Bold),   // 700 Bold Condensed
    Font(R.font.trade_gothic_next_heavy, FontWeight.Black),  // 900 Heavy Condensed
)
// Body / metadata — system (Roboto) is the SF Pro analog
val NikeBodyFamily = FontFamily.Default

private const val TNUM = "tnum" // tabular digits — pace columns must align

// Named ramp — mirrors DESIGN.md §3 exactly (pt → sp 1:1, ALL CAPS at call sites)
object NikeText {
    val RunHero     = TextStyle(TradeGothic, fontWeight = FontWeight.Black, fontSize = 88.sp, lineHeight = 84.sp, letterSpacing = 0.5.sp, fontFeatureSettings = TNUM)
    val PreHero     = TextStyle(TradeGothic, fontWeight = FontWeight.Black, fontSize = 64.sp, lineHeight = 61.sp, letterSpacing = 0.4.sp, fontFeatureSettings = TNUM)
    val Headline    = TextStyle(TradeGothic, fontWeight = FontWeight.Black, fontSize = 48.sp, lineHeight = 46.sp, letterSpacing = 0.3.sp)
    val ScreamHdr   = TextStyle(TradeGothic, fontWeight = FontWeight.Black, fontSize = 32.sp, lineHeight = 32.sp, letterSpacing = 0.3.sp)
    val SubHdr      = TextStyle(TradeGothic, fontWeight = FontWeight.Bold,  fontSize = 22.sp, lineHeight = 24.sp, letterSpacing = 0.3.sp)
    val StatLabel   = TextStyle(TradeGothic, fontWeight = FontWeight.Bold,  fontSize = 13.sp, lineHeight = 13.sp, letterSpacing = 1.0.sp)
    val StatValue   = TextStyle(TradeGothic, fontWeight = FontWeight.Black, fontSize = 64.sp, lineHeight = 61.sp, letterSpacing = 0.4.sp, fontFeatureSettings = TNUM)
    val Button      = TextStyle(TradeGothic, fontWeight = FontWeight.Black, fontSize = 17.sp, lineHeight = 17.sp, letterSpacing = 0.5.sp)
    val Achievement = TextStyle(TradeGothic, fontWeight = FontWeight.Black, fontSize = 56.sp, lineHeight = 53.sp, fontFeatureSettings = TNUM)

    // SF Pro analog (Roboto) for body / metadata — mixed case allowed
    val CardTitle = TextStyle(NikeBodyFamily, fontWeight = FontWeight.Bold,     fontSize = 17.sp, lineHeight = 20.sp, letterSpacing = (-0.2).sp)
    val Body      = TextStyle(NikeBodyFamily, fontWeight = FontWeight.Normal,   fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = (-0.1).sp)
    val BodyBold  = TextStyle(NikeBodyFamily, fontWeight = FontWeight.SemiBold, fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = (-0.1).sp)
    val Meta      = TextStyle(NikeBodyFamily, fontWeight = FontWeight.Normal,   fontSize = 13.sp, lineHeight = 17.sp)
    val Caption   = TextStyle(NikeBodyFamily, fontWeight = FontWeight.Medium,   fontSize = 11.sp, lineHeight = 14.sp, letterSpacing = 0.1.sp)
    val Tab       = TextStyle(NikeBodyFamily, fontWeight = FontWeight.SemiBold, fontSize = 10.sp, lineHeight = 10.sp, letterSpacing = 0.3.sp)
}

// Map onto Material 3 slots so stock components inherit the brand
val NikeTypography = Typography(
    displayLarge  = NikeText.RunHero,
    headlineLarge = NikeText.ScreamHdr,
    titleMedium   = NikeText.CardTitle,
    bodyMedium    = NikeText.Body,
    labelSmall    = NikeText.Tab,
)

3. Signature Components

Primary Action Button (“START RUN” / “LET’S DO THIS” / “FINISH”)

import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.dp

enum class NikeButtonStyle { White, Volt, Outline }

@Composable
fun NikeActionButton(
    title: String, // pass already-UPPERCASE
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    style: NikeButtonStyle = NikeButtonStyle.White,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(
        targetValue = if (pressed) 0.98f else 1f,
        animationSpec = spring(dampingRatio = 0.7f, stiffness = 700f),
        label = "ctaScale",
    )
    val haptics = LocalHapticFeedback.current
    val bg = when (style) {
        NikeButtonStyle.White   -> NikeColors.White
        NikeButtonStyle.Volt    -> NikeColors.Volt
        NikeButtonStyle.Outline -> Color.Transparent
    }
    val fg = if (style == NikeButtonStyle.Outline) NikeColors.White else NikeColors.Canvas

    Box(
        modifier = modifier
            .fillMaxWidth()
            .height(56.dp)
            .scale(scale)
            .clip(RoundedCornerShape(32.dp)) // very rounded, not a full pill
            .background(bg)
            .then(if (style == NikeButtonStyle.Outline) Modifier.border(2.dp, NikeColors.White, RoundedCornerShape(32.dp)) else Modifier)
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.LongPress) // heaviest — Nike's committed CTA
                onClick()
            },
        contentAlignment = Alignment.Center,
    ) {
        Text(title, style = NikeText.Button, color = fg)
    }
}

Volt Progress Ring + Run Tracking Screen

import androidx.compose.foundation.Canvas
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke

@Composable
fun VoltProgressRing(
    progress: Float, // 0f..1f
    modifier: Modifier = Modifier,
    diameter: Dp = 280.dp,
    strokeWidth: Dp = 6.dp,
) {
    val animated by animateFloatAsState(
        targetValue = progress.coerceIn(0f, 1f),
        animationSpec = spring(stiffness = 50f), // smooth fill toward the goal
        label = "ringProgress",
    )
    Canvas(modifier.size(diameter)) {
        val sw = strokeWidth.toPx()
        val inset = sw / 2
        val arcSize = Size(size.width - sw, size.height - sw)
        // Track
        drawArc(
            color = NikeColors.White.copy(alpha = 0.1f),
            startAngle = 0f, sweepAngle = 360f, useCenter = false,
            topLeft = androidx.compose.ui.geometry.Offset(inset, inset),
            size = arcSize,
            style = Stroke(width = sw, cap = StrokeCap.Round),
        )
        // Volt fill — starts at 12 o'clock (-90°), clockwise
        drawArc(
            color = NikeColors.Volt,
            startAngle = -90f, sweepAngle = 360f * animated, useCenter = false,
            topLeft = androidx.compose.ui.geometry.Offset(inset, inset),
            size = arcSize,
            style = Stroke(width = sw, cap = StrokeCap.Round),
        )
    }
}

@Composable
fun NRCStat(label: String, value: String, modifier: Modifier = Modifier) {
    Column(modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
        Text(label.uppercase(), style = NikeText.StatLabel, color = NikeColors.White.copy(alpha = 0.7f))
        Text(value, style = NikeText.StatValue, color = NikeColors.White)
    }
}

@Composable
fun RunTrackingScreen(
    elapsed: String,  // "12:34"
    distance: String, // "1.4"
    pace: String,     // "8:42"
    hr: String,       // "152"
    progress: Float,
    paused: Boolean,
    onPauseToggle: () -> Unit,
    onLock: () -> Unit,
) {
    val haptics = LocalHapticFeedback.current
    Box(
        Modifier.fillMaxSize().background(NikeColors.Canvas),
        contentAlignment = Alignment.TopCenter,
    ) {
        Column(
            Modifier.fillMaxSize().padding(top = 64.dp),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.spacedBy(32.dp),
        ) {
            Row(
                Modifier.fillMaxWidth().padding(horizontal = 24.dp),
                horizontalArrangement = Arrangement.SpaceBetween,
            ) {
                NRCStat("Distance", distance, Modifier.weight(1f))
                NRCStat("Min/Mi", pace, Modifier.weight(1f))
                NRCStat("BPM", hr, Modifier.weight(1f))
            }
            Box(contentAlignment = Alignment.Center) {
                VoltProgressRing(progress)
                Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp)) {
                    Text(elapsed, style = NikeText.RunHero, color = NikeColors.White)
                    Text("ELAPSED", style = NikeText.StatLabel, color = NikeColors.White.copy(alpha = 0.7f))
                }
            }
            Spacer(Modifier.weight(1f))
            Row(
                Modifier.fillMaxWidth().padding(bottom = 48.dp, end = 16.dp),
                horizontalArrangement = Arrangement.Center,
                verticalAlignment = Alignment.CenterVertically,
            ) {
                Box(
                    Modifier
                        .size(72.dp)
                        .clip(androidx.compose.foundation.shape.CircleShape)
                        .background(if (paused) NikeColors.Volt else NikeColors.White)
                        .clickable {
                            haptics.performHapticFeedback(HapticFeedbackType.LongPress) // heavy
                            onPauseToggle()
                        },
                    contentAlignment = Alignment.Center,
                ) {
                    Icon(
                        if (paused) Icons.Filled.PlayArrow else Icons.Filled.Pause,
                        contentDescription = if (paused) "Resume" else "Pause",
                        tint = NikeColors.Canvas,
                        modifier = Modifier.size(28.dp),
                    )
                }
            }
        }
    }
}

Run Plan Card (Home Feed)

import coil.compose.AsyncImage
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.layout.ContentScale

@Composable
fun RunPlanCard(
    photoUrl: String,
    eyebrow: String,     // "GUIDED RUN · 25 MIN"
    title: String,       // "RECOVERY MILE"
    description: String,  // "Coach Bennett · Easy effort"
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
) {
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val scale by animateFloatAsState(if (pressed) 0.98f else 1f, label = "planScale")
    val haptics = LocalHapticFeedback.current

    Box(
        modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp)
            .height(320.dp)
            .scale(scale)
            .clip(RoundedCornerShape(16.dp)) // no shadow — Nike is flat on black
            .clickable(interaction, indication = null) {
                haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
                onClick()
            },
        contentAlignment = Alignment.BottomStart,
    ) {
        AsyncImage(model = photoUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
        Box(
            Modifier
                .fillMaxSize()
                .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = 0.6f), Color.Black.copy(alpha = 0.85f)))),
        )
        Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
            Text(eyebrow.uppercase(), style = NikeText.Caption, color = NikeColors.White.copy(alpha = 0.8f))
            Text(title.uppercase(), style = NikeText.ScreamHdr, color = NikeColors.White)
            Text(description, style = NikeText.Meta, color = NikeColors.White.copy(alpha = 0.85f))
        }
    }
}

Achievement Medal (hexagonal reveal)

import androidx.compose.animation.core.Animatable
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope

fun DrawScope.hexagonPath(): Path = Path().apply {
    val w = size.width; val h = size.height
    moveTo(w * 0.5f, 0f)
    lineTo(w, h * 0.25f)
    lineTo(w, h * 0.75f)
    lineTo(w * 0.5f, h)
    lineTo(0f, h * 0.75f)
    lineTo(0f, h * 0.25f)
    close()
}

@Composable
fun AchievementMedal(
    milestone: String, // "100"
    label: String,     // "100 MILES"
    subtitle: String,   // "Lifetime distance"
) {
    val scale = remember { Animatable(0.5f) }
    val alpha = remember { Animatable(0f) }
    val haptics = LocalHapticFeedback.current
    LaunchedEffect(Unit) {
        haptics.performHapticFeedback(HapticFeedbackType.Confirm) // .success analog
        alpha.animateTo(1f, spring(stiffness = 200f))
        scale.animateTo(1f, spring(dampingRatio = 0.6f, stiffness = 400f))
    }
    Column(
        Modifier.padding(24.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.spacedBy(16.dp),
    ) {
        Box(contentAlignment = Alignment.Center) {
            Canvas(
                Modifier
                    .size(200.dp)
                    .scale(scale.value)
                    .graphicsLayer { this.alpha = alpha.value },
            ) {
                drawPath(
                    hexagonPath(),
                    brush = Brush.linearGradient(listOf(NikeColors.Volt, NikeColors.Red)),
                )
            }
            Text(milestone, style = NikeText.Achievement, color = NikeColors.Canvas, modifier = Modifier.scale(scale.value))
        }
        Text(label.uppercase(), style = NikeText.SubHdr, color = NikeColors.White)
        Text(subtitle, style = NikeText.Body, color = NikeColors.Gray1)
    }
}

Heart Rate Zone Bar

@Composable
fun HRZoneBar(times: List<Float>, modifier: Modifier = Modifier) {
    val zones = listOf(
        NikeColors.HR1Blue to "Z1 Easy",
        NikeColors.HR2Green to "Z2 Steady",
        NikeColors.HR3Yellow to "Z3 Mod",
        NikeColors.HR4Orange to "Z4 Hard",
        NikeColors.HR5Red to "Z5 Max",
    )
    val total = times.sum().coerceAtLeast(1f)
    Column(modifier, verticalArrangement = Arrangement.spacedBy(12.dp)) {
        Row(
            Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)),
        ) {
            times.forEachIndexed { i, t ->
                Box(Modifier.fillMaxHeight().weight((t / total).coerceAtLeast(0.001f)).background(zones[i].first))
            }
        }
        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
            zones.forEach { (c, label) ->
                Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
                    Box(Modifier.size(6.dp).clip(androidx.compose.foundation.shape.CircleShape).background(c))
                    Text(label.uppercase(), style = NikeText.Caption, color = NikeColors.White)
                }
            }
        }
    }
}

4. The Volt Progress Ring (signature interaction)

The 280.dp Volt ring inside the Run Tracking screen is NRC’s defining moment — the closest a phone gets to feeling like a piece of equipment. Implemented above in §3 via Canvas + drawArc: a rgba(255,255,255,0.1) full-circle track, a Volt sweep from -90° (12 o’clock) clockwise, both with StrokeCap.Round (never sharp caps). The 88.sp Trade Gothic elapsed time sits dead-center, cropped tight by the 84.sp line height so it kisses the ring’s inner edge — that tight crop is intentional Nike.

Drive progress from the run goal and let the ring tick discretely per mile/km/interval:

// In the run ViewModel — emit progress toward the goal each tick
val progress by produceState(0f, distanceMeters, goalMeters) {
    value = (distanceMeters / goalMeters).coerceIn(0f, 1f)
}
// Pass straight into VoltProgressRing(progress) — the spring(stiffness = 50f) gives the
// smooth linear-feeling fill; for hard 1s ticks per unit swap to tween(1000, easing = LinearEasing).

A lock affordance (48.dp Surface2 circle, white lock glyph, top-right inset 16.dp) disables accidental mid-run taps; render it as an overlay aligned Alignment.TopEnd.

5. Bottom Navigation (Tab Bar)

Use Material 3 NavigationBar. NRC’s iOS tab bar is a plain black bar with a 0.5pt #333333 hairline — Android has no live blur but none is needed here (the bar is opaque #000000). 5 tabs: Home / Activity / Coach / Goal / Profile. Active is white icon + a 4.dp Volt dot under it; Material’s pill indicator is suppressed.

@Composable
fun NikeBottomBar(selected: Int, onSelect: (Int) -> Unit) {
    val haptics = LocalHapticFeedback.current
    NavigationBar(
        containerColor = NikeColors.Canvas,
        tonalElevation = 0.dp,
    ) {
        val items = listOf(
            "Home"     to Icons.Filled.Home,
            "Activity" to Icons.Filled.BarChart,
            "Coach"    to Icons.Filled.Person,
            "Goal"     to Icons.Filled.TrackChanges,
            "Profile"  to Icons.Filled.AccountCircle,
        )
        items.forEachIndexed { i, (label, icon) ->
            val active = selected == i
            NavigationBarItem(
                selected = active,
                onClick = {
                    haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) // .selection — never heavy on tabs
                    onSelect(i)
                },
                icon = {
                    Column(horizontalAlignment = Alignment.CenterHorizontally) {
                        Icon(icon, contentDescription = label, modifier = Modifier.size(24.dp))
                        Spacer(Modifier.height(4.dp))
                        Box(
                            Modifier
                                .size(4.dp)
                                .clip(androidx.compose.foundation.shape.CircleShape)
                                .background(if (active) NikeColors.Volt else Color.Transparent),
                        )
                    }
                },
                label = { Text(label, style = NikeText.Tab) },
                colors = NavigationBarItemDefaults.colors(
                    selectedIconColor = NikeColors.White,
                    selectedTextColor = NikeColors.White,
                    unselectedIconColor = NikeColors.Gray3,   // #5C5C5C
                    unselectedTextColor = NikeColors.Gray2,   // #8E8E8E
                    indicatorColor = Color.Transparent, // no Material pill — NRC uses a Volt dot
                ),
            )
        }
    }
}

6. Motion

NRC motion is deterministic and committed — linear ticks on the ring, heavy haptics on commitment, springy medal reveals.

Moment Compose recipe
Progress ring fill animateFloatAsState spring(stiffness = 50f), or tween(1000, LinearEasing) for hard per-unit ticks
START RUN tap scale 1 → 0.98 spring(0.7f) + HapticFeedbackType.LongPress (heaviest), then 400ms Crossfade to Run Tracking
Pause tap circle scale 1 → 0.95 + heavy haptic, morph to Resume (Volt) + Stop (red) over 250ms spring
Achievement reveal Animatable scale 0.5 → 1 spring(response 0.4, damping 0.6) + alpha fade + Confirm haptic; confetti burst
Tab switch Volt dot slides via SharedTransitionLayout/animateDpAsState, icon cross-fades 200ms, TextHandleMove haptic
PR confetti 16-particle Volt burst + LongPress heavy haptic
// Volt confetti burst on a PR / milestone — 16 particles
@Composable
fun VoltConfetti(trigger: Boolean) {
    val haptics = LocalHapticFeedback.current
    val t = remember { Animatable(0f) }
    LaunchedEffect(trigger) {
        if (trigger) {
            haptics.performHapticFeedback(HapticFeedbackType.LongPress) // heavy
            t.snapTo(0f); t.animateTo(1f, tween(600))
        }
    }
    Canvas(Modifier.fillMaxSize()) {
        if (t.value == 0f) return@Canvas
        val cx = size.width / 2; val cy = size.height / 2
        repeat(16) { i ->
            val angle = (i / 16f) * 2f * Math.PI
            val r = 320f * t.value
            drawCircle(
                color = NikeColors.Volt.copy(alpha = 1f - t.value),
                radius = 6f,
                center = androidx.compose.ui.geometry.Offset(
                    cx + (r * kotlin.math.cos(angle)).toFloat(),
                    cy + (r * kotlin.math.sin(angle)).toFloat(),
                ),
            )
        }
    }
}

Haptics: prefer LocalHapticFeedback. For the heaviest “START RUN”/“PAUSE” feel use LocalView.current.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) or a Vibrator VibrationEffect.createOneShot(25, VibrationEffect.DEFAULT_AMPLITUDE).

7. Icons

NRC ships custom glyphs; the closest first-party set is androidx.compose.material:material-icons-extended. The hexagonal medal is NOT an icon — draw it via Canvas (§3); the Volt active-tab dot is also drawn, not an icon.

Purpose SF Symbol (iOS) Material Icon (Compose)
Pause pause.fill Icons.Filled.Pause
Play / Resume play.fill Icons.Filled.PlayArrow
Stop stop.fill Icons.Filled.Stop
Lock lock.fill Icons.Filled.Lock
GPS strength location.fill Icons.Filled.LocationOn
Coach Bennett person.crop.circle.fill Icons.Filled.AccountCircle
Home (tab) house / house.fill Icons.Filled.Home
Activity (tab) chart.bar / .fill Icons.Filled.BarChart
Coach (tab) person.crop.square / .fill Icons.Filled.Person
Goal (tab) target Icons.Filled.TrackChanges
Profile (tab) person.circle / .fill Icons.Filled.AccountCircle
Heart rate heart.fill Icons.Filled.Favorite
Share square.and.arrow.up Icons.Filled.IosShare
Settings gearshape.fill Icons.Filled.Settings
Music music.note Icons.Filled.MusicNote

8. Minimum SDK & Accessibility Notes

  • minSdk 24 (Compose floor is 21; Canvas-based ring + modern motion are comfortable at 24). compileSdk 34+, Compose BOM 2024.09+, Kotlin 2.0+.
  • Edge-to-edge: call enableEdgeToEdge() in the Activity; the true-black canvas wants WindowCompat light-content system bars. The top app bar / Run Tracking stats sit below the camera cutout; the pause control respects the gesture-nav inset via Scaffold/windowInsetsPadding.
  • Font scaling: sp honors the user’s font scale automatically — scale body, metadata, and plan-card titles. Pin the visual identity (88sp RunHero, 64sp StatValue, 17sp Button, 13sp StatLabel, 10sp tab labels) by deriving size from dp or wrapping in CompositionLocalProvider(LocalDensity provides Density(density, fontScale = 1f)) — NRC’s identity depends on these exact sizes.
  • Tabular digits: keep fontFeatureSettings = "tnum" on every stat (pace, distance, elapsed, BPM, splits) so columns align on the results page.
  • TalkBack: make the Run Tracking screen read each stat as a separate node so a user can spot-check one at a time — e.g. “Run in progress, 12 minutes 34 seconds elapsed”, “1.4 miles”, “8 minute 42 second per mile pace”, “heart rate 152 BPM”, then “Pause button”. Use Modifier.semantics { contentDescription = ... } per stat.
  • Touch targets: Material guidance is 48.dp minimum. The 72.dp pause circle and 56.dp CTA clear it; give the 48.dp lock circle its full hit area.
  • Contrast: Volt #CCFF00 on #000000 passes WCAG AAA at all sizes; Volt on white FAILS — use VoltDim #8AA800 on any light surface. #8E8E8E tab labels on black pass AA at 10sp+; validate the 11sp Caption.
  • Reduce motion: when Settings.Global.ANIMATOR_DURATION_SCALE == 0, skip the medal scale spring and the confetti burst — fade the medal in over 300ms with no scale change; keep the ring fill (it conveys state, not decoration).
  • Dynamic color: do not enable Material You dynamicDarkColorScheme() — NRC’s brand requires the fixed #000000 canvas and the radioactive #CCFF00 Volt regardless of wallpaper. Keep the app dark even when the system is light (light mode is an accessibility-only fallback using VoltDim).
All mobile designs →

Brand names describe the visual style only; coderef is not affiliated with or endorsed by any brand listed. Licenses