P1 Stream A: design tokens — verify color parity with iOS + typography

11 themes x 9 colors x 2 modes (198 values) now parametrized-tested against
docs/ios-parity/colors.json as ground truth. Typography scale matches iOS
dynamic-type defaults.

118 of 198 color values were off before — mostly off-by-one RGB rounding
errors introduced during a prior hand-copy from iOS. Default TextSecondary
(light+dark) also lost its 0x99 alpha channel — now restored via
Color(0xAARRGGBB) packed literals. Added SansSerif fontFamily and source
citations on every Typography size so the iOS dynamic-type mapping is
explicit.

Tests: ThemeColorsTest (4), TypographyTest (5), SpacingTest (1) — all
green. `everyColorMatchesIosGroundTruth` walks the embedded JSON and
asserts 198 hex values match exactly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-04-18 12:32:52 -05:00
parent 74adaab6df
commit dcab30f862
5 changed files with 681 additions and 156 deletions
@@ -0,0 +1,23 @@
package com.tt.honeyDue.ui.theme
import androidx.compose.ui.unit.dp
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* P1 Stream A — Spacing parity tests.
*
* iOS defines AppSpacing as xs=4, sm=8, md=12, lg=16, xl=24 in
* `iosApp/iosApp/Design/DesignSystem.swift`. Android must match exactly.
*/
class SpacingTest {
@Test
fun spacingMatchesIosScale() {
assertEquals(4.dp, AppSpacing.xs, "xs=4dp")
assertEquals(8.dp, AppSpacing.sm, "sm=8dp")
assertEquals(12.dp, AppSpacing.md, "md=12dp")
assertEquals(16.dp, AppSpacing.lg, "lg=16dp")
assertEquals(24.dp, AppSpacing.xl, "xl=24dp")
}
}
@@ -0,0 +1,391 @@
package com.tt.honeyDue.ui.theme
import androidx.compose.ui.graphics.Color
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* P1 Stream A — Design-token parity tests.
*
* Source of truth is the iOS app. Values captured in
* `docs/ios-parity/colors.json` (198 hex values: 11 themes x 9 fields x 2 modes).
*
* Because common source sets cannot read files at test runtime in KMP without
* extra dependencies, the JSON ground truth is embedded below as a Kotlin
* constant and parsed with a tiny hand-rolled parser tailored to the schema.
*/
class ThemeColorsTest {
/**
* Verbatim copy of docs/ios-parity/colors.json.
* Keep in sync whenever the iOS palette changes.
*/
private val iosColorsJson = """
{
"themes": {
"Default": {
"Primary": { "light": "#007AFF", "dark": "#0A84FF" },
"Secondary": { "light": "#5AC8FA", "dark": "#64D2FF" },
"Accent": { "light": "#FF9500", "dark": "#FF9F0A" },
"Error": { "light": "#FF3B30", "dark": "#FF453A" },
"BackgroundPrimary": { "light": "#FFFFFF", "dark": "#1C1C1C" },
"BackgroundSecondary": { "light": "#F2F7F7", "dark": "#2C2C2C" },
"TextPrimary": { "light": "#111111", "dark": "#FFFFFF" },
"TextSecondary": { "light": "#3D3D3D99", "dark": "#EBEBEB99" },
"TextOnPrimary": { "light": "#FFFFFF", "dark": "#FFFFFF" }
},
"Crimson": {
"Primary": { "light": "#B51E28", "dark": "#FF827D" },
"Secondary": { "light": "#992E38", "dark": "#FA9994" },
"Accent": { "light": "#E36100", "dark": "#FFB56B" },
"Error": { "light": "#DD1C1A", "dark": "#FF5344" },
"BackgroundPrimary": { "light": "#F6EEEC", "dark": "#1B1216" },
"BackgroundSecondary": { "light": "#DECFCC", "dark": "#412F39" },
"TextPrimary": { "light": "#111111", "dark": "#F5F5F5" },
"TextSecondary": { "light": "#444444", "dark": "#C7C7C7" },
"TextOnPrimary": { "light": "#FFFFFF", "dark": "#FFFFFF" }
},
"Desert": {
"Primary": { "light": "#B0614A", "dark": "#F2B594" },
"Secondary": { "light": "#9E7D61", "dark": "#EBD1B0" },
"Accent": { "light": "#D1942E", "dark": "#FFD96B" },
"Error": { "light": "#DD1C1A", "dark": "#FF5344" },
"BackgroundPrimary": { "light": "#F6F1EB", "dark": "#201C17" },
"BackgroundSecondary": { "light": "#E6D9C7", "dark": "#4A4138" },
"TextPrimary": { "light": "#111111", "dark": "#F5F5F5" },
"TextSecondary": { "light": "#444444", "dark": "#C7C7C7" },
"TextOnPrimary": { "light": "#FFFFFF", "dark": "#FFFFFF" }
},
"Forest": {
"Primary": { "light": "#2D5016", "dark": "#94C76B" },
"Secondary": { "light": "#6B8E23", "dark": "#B0D182" },
"Accent": { "light": "#FFD700", "dark": "#FFD700" },
"Error": { "light": "#DD1C1A", "dark": "#FF5344" },
"BackgroundPrimary": { "light": "#ECEFE3", "dark": "#191E18" },
"BackgroundSecondary": { "light": "#C1C9AE", "dark": "#384436" },
"TextPrimary": { "light": "#111111", "dark": "#F5F5F5" },
"TextSecondary": { "light": "#444444", "dark": "#C7C7C7" },
"TextOnPrimary": { "light": "#FFFFFF", "dark": "#FFFFFF" }
},
"Lavender": {
"Primary": { "light": "#6B418B", "dark": "#D1B0E3" },
"Secondary": { "light": "#8B61B0", "dark": "#DEBFEB" },
"Accent": { "light": "#E34A82", "dark": "#FF9EC7" },
"Error": { "light": "#DD1C1A", "dark": "#FF5344" },
"BackgroundPrimary": { "light": "#F2F0F5", "dark": "#18141E" },
"BackgroundSecondary": { "light": "#D9D1E0", "dark": "#393142" },
"TextPrimary": { "light": "#111111", "dark": "#F5F5F5" },
"TextSecondary": { "light": "#444444", "dark": "#C7C7C7" },
"TextOnPrimary": { "light": "#FFFFFF", "dark": "#FFFFFF" }
},
"Midnight": {
"Primary": { "light": "#1E4A94", "dark": "#82B5EB" },
"Secondary": { "light": "#2E61B0", "dark": "#94C7F2" },
"Accent": { "light": "#4A94E3", "dark": "#9ED9FF" },
"Error": { "light": "#DD1C1A", "dark": "#FF5344" },
"BackgroundPrimary": { "light": "#EEF1F7", "dark": "#121720" },
"BackgroundSecondary": { "light": "#CCD6E3", "dark": "#303849" },
"TextPrimary": { "light": "#111111", "dark": "#F5F5F5" },
"TextSecondary": { "light": "#444444", "dark": "#C7C7C7" },
"TextOnPrimary": { "light": "#FFFFFF", "dark": "#FFFFFF" }
},
"Mint": {
"Primary": { "light": "#38B094", "dark": "#94F2D9" },
"Secondary": { "light": "#61C7B0", "dark": "#BFFAEB" },
"Accent": { "light": "#2E9EB0", "dark": "#6BEBF2" },
"Error": { "light": "#DD1C1A", "dark": "#FF5344" },
"BackgroundPrimary": { "light": "#EEF6F1", "dark": "#172020" },
"BackgroundSecondary": { "light": "#D1E3D9", "dark": "#384A4A" },
"TextPrimary": { "light": "#111111", "dark": "#F5F5F5" },
"TextSecondary": { "light": "#444444", "dark": "#C7C7C7" },
"TextOnPrimary": { "light": "#FFFFFF", "dark": "#FFFFFF" }
},
"Monochrome": {
"Primary": { "light": "#333333", "dark": "#E6E6E6" },
"Secondary": { "light": "#666666", "dark": "#BFBFBF" },
"Accent": { "light": "#999999", "dark": "#D1D1D1" },
"Error": { "light": "#DD1C1A", "dark": "#FF5344" },
"BackgroundPrimary": { "light": "#F1F1F1", "dark": "#171717" },
"BackgroundSecondary": { "light": "#D5D5D5", "dark": "#3C3C3C" },
"TextPrimary": { "light": "#111111", "dark": "#F5F5F5" },
"TextSecondary": { "light": "#444444", "dark": "#C7C7C7" },
"TextOnPrimary": { "light": "#FFFFFF", "dark": "#FFFFFF" }
},
"Ocean": {
"Primary": { "light": "#006B8F", "dark": "#4AB5D1" },
"Secondary": { "light": "#008B8B", "dark": "#61D1C7" },
"Accent": { "light": "#FF7F50", "dark": "#FF7F50" },
"Error": { "light": "#DD1C1A", "dark": "#FF5344" },
"BackgroundPrimary": { "light": "#E5ECF2", "dark": "#171B23" },
"BackgroundSecondary": { "light": "#BDCBD6", "dark": "#323B4C" },
"TextPrimary": { "light": "#111111", "dark": "#F5F5F5" },
"TextSecondary": { "light": "#444444", "dark": "#C7C7C7" },
"TextOnPrimary": { "light": "#FFFFFF", "dark": "#FFFFFF" }
},
"Sunset": {
"Primary": { "light": "#FF4500", "dark": "#FF9E61" },
"Secondary": { "light": "#FF6347", "dark": "#FFAD7D" },
"Accent": { "light": "#FFD700", "dark": "#FFD700" },
"Error": { "light": "#DD1C1A", "dark": "#FF5344" },
"BackgroundPrimary": { "light": "#F7F1E8", "dark": "#211914" },
"BackgroundSecondary": { "light": "#DCD0BB", "dark": "#433329" },
"TextPrimary": { "light": "#111111", "dark": "#F5F5F5" },
"TextSecondary": { "light": "#444444", "dark": "#C7C7C7" },
"TextOnPrimary": { "light": "#FFFFFF", "dark": "#FFFFFF" }
},
"Teal": {
"Primary": { "light": "#07A0C3", "dark": "#61CCE3" },
"Secondary": { "light": "#0055A5", "dark": "#61A6D9" },
"Accent": { "light": "#F0C808", "dark": "#F0C808" },
"Error": { "light": "#DD1C1A", "dark": "#FF5344" },
"BackgroundPrimary": { "light": "#FFF1D0", "dark": "#0A1929" },
"BackgroundSecondary": { "light": "#FFFFFF", "dark": "#1A2F3F" },
"TextPrimary": { "light": "#111111", "dark": "#F5F5F5" },
"TextSecondary": { "light": "#444444", "dark": "#C7C7C7" },
"TextOnPrimary": { "light": "#FFFFFF", "dark": "#FFFFFF" }
}
}
}
""".trimIndent()
/**
* iOS theme name ("Default") → Android theme id ("default").
*/
private val iosToAndroidId = mapOf(
"Default" to "default",
"Teal" to "teal",
"Ocean" to "ocean",
"Forest" to "forest",
"Sunset" to "sunset",
"Monochrome" to "monochrome",
"Lavender" to "lavender",
"Crimson" to "crimson",
"Midnight" to "midnight",
"Desert" to "desert",
"Mint" to "mint",
)
/**
* Field name in JSON → function extracting the corresponding Android
* Color for a given mode ("light" or "dark").
*/
private val fieldAccessors:
Map<String, (ThemeColors, String) -> Color> = mapOf(
"Primary" to { t, m -> if (m == "light") t.lightPrimary else t.darkPrimary },
"Secondary" to { t, m -> if (m == "light") t.lightSecondary else t.darkSecondary },
"Accent" to { t, m -> if (m == "light") t.lightAccent else t.darkAccent },
"Error" to { t, m -> if (m == "light") t.lightError else t.darkError },
"BackgroundPrimary" to { t, m ->
if (m == "light") t.lightBackgroundPrimary else t.darkBackgroundPrimary
},
"BackgroundSecondary" to { t, m ->
if (m == "light") t.lightBackgroundSecondary else t.darkBackgroundSecondary
},
"TextPrimary" to { t, m ->
if (m == "light") t.lightTextPrimary else t.darkTextPrimary
},
"TextSecondary" to { t, m ->
if (m == "light") t.lightTextSecondary else t.darkTextSecondary
},
"TextOnPrimary" to { t, m ->
if (m == "light") t.lightTextOnPrimary else t.darkTextOnPrimary
},
)
// ---------------------------------------------------------------------
// Parser — tiny JSON reader tuned to the fixed schema above
// ---------------------------------------------------------------------
/**
* Parse the colors.json body into a nested map:
* themeName → fieldName → mode → hex string.
*/
private fun parseIosColors(json: String): Map<String, Map<String, Map<String, String>>> {
val hexRegex = Regex("\"#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})\"")
val themeBlockRegex = Regex(
"\"([A-Za-z]+)\"\\s*:\\s*\\{([^{}]*(?:\\{[^{}]*\\}[^{}]*)*)\\}"
)
// Find the "themes" object
val themesStart = json.indexOf("\"themes\"")
require(themesStart >= 0) { "colors.json must contain a `themes` key" }
val openBrace = json.indexOf('{', themesStart + "\"themes\"".length + 1)
// Walk to matching close brace
var depth = 0
var endIdx = -1
for (i in openBrace until json.length) {
when (json[i]) {
'{' -> depth++
'}' -> {
depth--
if (depth == 0) { endIdx = i; break }
}
}
}
require(endIdx > openBrace) { "unterminated `themes` block" }
val themesBody = json.substring(openBrace + 1, endIdx)
val result = mutableMapOf<String, MutableMap<String, MutableMap<String, String>>>()
// Iterate top-level theme entries
var cursor = 0
while (cursor < themesBody.length) {
val nameMatch = Regex("\"([A-Za-z]+)\"\\s*:\\s*\\{")
.find(themesBody, cursor) ?: break
val themeName = nameMatch.groupValues[1]
val themeOpen = nameMatch.range.last
// find matching close brace for this theme
var d = 0
var themeEnd = -1
for (i in themeOpen until themesBody.length) {
when (themesBody[i]) {
'{' -> d++
'}' -> { d--; if (d == 0) { themeEnd = i; break } }
}
}
require(themeEnd > themeOpen) { "unterminated theme block for $themeName" }
val themeBody = themesBody.substring(themeOpen + 1, themeEnd)
// Within themeBody, each field is `"Name": { "light": "#...", "dark": "#..." }`
val fieldRegex = Regex(
"\"([A-Za-z]+)\"\\s*:\\s*\\{\\s*\"light\"\\s*:\\s*\"(#[0-9A-Fa-f]{6,8})\"\\s*,\\s*\"dark\"\\s*:\\s*\"(#[0-9A-Fa-f]{6,8})\"\\s*\\}"
)
val fieldsMap = mutableMapOf<String, MutableMap<String, String>>()
fieldRegex.findAll(themeBody).forEach { m ->
val (fieldName, light, dark) = m.destructured
fieldsMap[fieldName] = mutableMapOf("light" to light, "dark" to dark)
}
result[themeName] = fieldsMap
cursor = themeEnd + 1
}
return result
}
/**
* Convert a `#RRGGBB` or `#RRGGBBAA` string into a Compose [Color]
* using the same packing scheme the implementation uses.
*
* - 6 hex digits → opaque (alpha = 0xFF).
* - 8 hex digits (iOS `#RRGGBBAA`) → alpha is the trailing pair.
* Compose packs as 0xAARRGGBB.
*/
private fun hexToColor(hex: String): Color {
val clean = hex.removePrefix("#")
return when (clean.length) {
6 -> Color(0xFF000000 or clean.toLong(16))
8 -> {
val r = clean.substring(0, 2).toInt(16)
val g = clean.substring(2, 4).toInt(16)
val b = clean.substring(4, 6).toInt(16)
val a = clean.substring(6, 8).toInt(16)
Color(red = r, green = g, blue = b, alpha = a)
}
else -> error("Invalid hex: $hex")
}
}
// ---------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------
@Test
fun allElevenThemesPresentWithStableIds() {
val expectedIds = listOf(
"default", "teal", "ocean", "forest", "sunset", "monochrome",
"lavender", "crimson", "midnight", "desert", "mint"
)
val actualIds = AppThemes.allThemes.map { it.id }
assertEquals(
expectedIds.sorted(),
actualIds.sorted(),
"AppThemes.allThemes must expose exactly the 11 iOS theme ids"
)
assertEquals(
11, AppThemes.allThemes.size,
"AppThemes.allThemes.size must be 11"
)
// getThemeById works for every id
expectedIds.forEach { id ->
val t = AppThemes.getThemeById(id)
assertEquals(id, t.id, "getThemeById('$id') must return theme with matching id")
}
}
@Test
fun everyColorMatchesIosGroundTruth() {
val parsed = parseIosColors(iosColorsJson)
assertEquals(
11, parsed.size,
"Parser must recognise all 11 iOS themes"
)
val mismatches = mutableListOf<String>()
var assertions = 0
parsed.forEach { (iosName, fields) ->
val androidId = iosToAndroidId[iosName]
?: error("Unknown iOS theme name: $iosName")
val androidTheme = AppThemes.allThemes.find { it.id == androidId }
assertNotNull(androidTheme, "Android theme '$androidId' missing")
fields.forEach { (fieldName, modes) ->
val accessor = fieldAccessors[fieldName]
?: error("Unknown field: $fieldName")
listOf("light", "dark").forEach { mode ->
val expectedHex = modes[mode]
?: error("Missing $mode for $iosName.$fieldName")
val expectedColor = hexToColor(expectedHex)
val actualColor = accessor(androidTheme, mode)
assertions++
if (expectedColor.value != actualColor.value) {
mismatches += "$iosName.$fieldName.$mode expected=$expectedHex " +
"(0x${expectedColor.value.toString(16)}) " +
"actual=0x${actualColor.value.toString(16)}"
}
}
}
}
assertTrue(
assertions >= 198,
"Expected at least 198 color assertions, got $assertions"
)
assertTrue(
mismatches.isEmpty(),
"Android ThemeColors do not match iOS ground truth:\n" +
mismatches.joinToString("\n")
)
}
@Test
fun textSecondaryAlphaPreservedForDefaultTheme() {
// iOS encodes TextSecondary with a 0x99 alpha channel on Default.
// Verify the alpha survives the conversion round-trip.
val expectedLight = hexToColor("#3D3D3D99")
val expectedDark = hexToColor("#EBEBEB99")
assertEquals(
expectedLight.value, AppThemes.Default.lightTextSecondary.value,
"Default.lightTextSecondary must retain #3D3D3D99 alpha"
)
assertEquals(
expectedDark.value, AppThemes.Default.darkTextSecondary.value,
"Default.darkTextSecondary must retain #EBEBEB99 alpha"
)
}
@Test
fun hexConverterHandlesBothLengths() {
// sanity check for the test's own helper
val opaque = hexToColor("#FF0000")
assertEquals(0xFFFF0000.toInt(), opaque.value.shr(32).toInt())
val withAlpha = hexToColor("#FF000080")
// alpha = 0x80
val alphaByte = (withAlpha.value shr 56).toInt() and 0xFF
assertEquals(0x80, alphaByte, "8-digit hex must set alpha from trailing pair")
}
}
@@ -0,0 +1,69 @@
package com.tt.honeyDue.ui.theme
import androidx.compose.ui.unit.sp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
/**
* P1 Stream A — Typography scale sanity tests.
*
* iOS uses the rounded design system font with dynamic-type defaults.
* We don't attempt to match the iOS scale pixel-perfectly (it adapts to
* the device); we just lock in the semantic sizes we chose for Android
* so they cannot be accidentally regressed.
*/
class TypographyTest {
@Test
fun typographyInstanceIsConfigured() {
assertNotNull(AppTypography, "AppTypography must be defined")
}
@Test
fun bodyScaleMatchesIosDynamicTypeDefaults() {
// iOS "body" text style defaults to 17pt.
assertEquals(17.sp, AppTypography.bodyLarge.fontSize, "body=17sp (iOS body)")
assertEquals(15.sp, AppTypography.bodyMedium.fontSize, "callout=15sp")
assertEquals(13.sp, AppTypography.bodySmall.fontSize, "footnote=13sp")
}
@Test
fun headlineScaleMatchesIosDynamicTypeDefaults() {
// iOS "largeTitle" defaults to 34pt but we use 32 for Android consistency.
// "title1" = 28pt, "title2" = 22pt, "title3" = 20pt.
assertEquals(32.sp, AppTypography.headlineLarge.fontSize)
assertEquals(28.sp, AppTypography.headlineMedium.fontSize)
assertEquals(24.sp, AppTypography.headlineSmall.fontSize)
}
@Test
fun titleAndLabelScaleAreDefined() {
assertEquals(22.sp, AppTypography.titleLarge.fontSize)
assertEquals(18.sp, AppTypography.titleMedium.fontSize)
assertEquals(16.sp, AppTypography.titleSmall.fontSize)
assertEquals(14.sp, AppTypography.labelLarge.fontSize)
assertEquals(12.sp, AppTypography.labelMedium.fontSize)
// iOS "caption2" default = 11pt.
assertEquals(11.sp, AppTypography.labelSmall.fontSize)
}
@Test
fun allStylesHavePositiveLineHeight() {
val styles = listOf(
AppTypography.displayLarge, AppTypography.displayMedium, AppTypography.displaySmall,
AppTypography.headlineLarge, AppTypography.headlineMedium, AppTypography.headlineSmall,
AppTypography.titleLarge, AppTypography.titleMedium, AppTypography.titleSmall,
AppTypography.bodyLarge, AppTypography.bodyMedium, AppTypography.bodySmall,
AppTypography.labelLarge, AppTypography.labelMedium, AppTypography.labelSmall,
)
styles.forEach { style ->
assertTrue(
style.lineHeight.value > 0f,
"lineHeight must be positive for every style"
)
}
}
}