feat(android): FabledSword theme — Material 3 + Google Fonts

M8 phase 1.4. Mirrors flutter_client/lib/theme/. Source of truth for hex
values is flutter_client/shared/fabledsword.tokens.json (manual sync until
cross-language codegen lands; ports the dark-surface + flat cohort).

Material 3 ColorScheme takes accent as primary; action colors
(Moss/Bronze/Oxblood) live in LocalActionColors as semantic roles per the
project_design_system rule "NEVER use accent for action buttons".

Typography uses androidx.compose.ui.text.googlefonts to fetch Fraunces /
Inter / JetBrains Mono at runtime via Play Services Fonts — matches the
Flutter client's `google_fonts` package (no bundled .ttf files in either
tree). Weights restricted to 400/500. Fraunces is reserved for ≥18sp
display/headline slots per the design-system rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 22:18:15 -04:00
parent a948a71fa5
commit f2f6fa06a2
8 changed files with 215 additions and 1 deletions
+1
View File
@@ -98,6 +98,7 @@ dependencies {
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3)
implementation(libs.compose.ui.text.google.fonts)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview)
@@ -10,6 +10,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.fabledsword.minstrel.theme.MinstrelTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
@@ -17,7 +18,9 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { App() }
setContent {
MinstrelTheme { App() }
}
}
}
@@ -0,0 +1,29 @@
package com.fabledsword.minstrel.theme
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
/**
* Semantic action colors. The FabledSword design system reserves Moss/Bronze/
* Oxblood for action buttons; the forest-teal accent (`FabledSwordTokens.accent`)
* is for surfaces / highlights / non-action emphasis only and must NEVER be
* used as a button color.
*
* Composables that render action buttons should read from `LocalActionColors.current`
* rather than `MaterialTheme.colorScheme.primary`.
*/
data class ActionColors(
val primary: Color, // Moss — confirm, save, play
val secondary: Color, // Bronze — neutral, browse, accent action
val destructive: Color, // Oxblood — delete, unflag-as-destructive
val onAction: Color, // foreground / text on any of the above
)
val LocalActionColors = staticCompositionLocalOf {
ActionColors(
primary = FabledSwordTokens.moss,
secondary = FabledSwordTokens.bronze,
destructive = FabledSwordTokens.oxblood,
onAction = FabledSwordTokens.onAction,
)
}
@@ -0,0 +1,51 @@
package com.fabledsword.minstrel.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
/**
* Dark Material 3 color scheme assembled from FabledSword tokens. The accent
* (forest-teal) becomes the M3 `primary`; surfaces/backgrounds come from the
* dark token cohort. Action colors do NOT come from this scheme — see
* `LocalActionColors`.
*
* Light theme is deferred to post-v1 (mirrors Flutter primary mode).
*/
private val MinstrelDarkColorScheme = darkColorScheme(
primary = FabledSwordTokens.accent,
onPrimary = FabledSwordTokens.onAction,
primaryContainer = FabledSwordTokens.accent.copy(alpha = 0.25f),
onPrimaryContainer = FabledSwordTokens.parchment,
secondary = FabledSwordTokens.bronze,
onSecondary = FabledSwordTokens.onAction,
background = FabledSwordTokens.obsidian,
onBackground = FabledSwordTokens.parchment,
surface = FabledSwordTokens.iron,
onSurface = FabledSwordTokens.parchment,
surfaceVariant = FabledSwordTokens.slate,
onSurfaceVariant = FabledSwordTokens.ash,
error = FabledSwordTokens.error,
onError = FabledSwordTokens.onAction,
outline = FabledSwordTokens.pewter,
outlineVariant = FabledSwordTokens.slate,
)
@Composable
fun MinstrelTheme(content: @Composable () -> Unit) {
MaterialTheme(
colorScheme = MinstrelDarkColorScheme,
typography = MinstrelTypography,
) {
CompositionLocalProvider(
LocalActionColors provides ActionColors(
primary = FabledSwordTokens.moss,
secondary = FabledSwordTokens.bronze,
destructive = FabledSwordTokens.oxblood,
onAction = FabledSwordTokens.onAction,
),
content = content,
)
}
}
@@ -0,0 +1,42 @@
package com.fabledsword.minstrel.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/**
* FabledSword design-system tokens. Pure data — no theme wiring.
*
* Source of truth: `flutter_client/shared/fabledsword.tokens.json`. When that
* file changes, mirror the new hex values here. (Native v1 doesn't run the
* Flutter `tool/gen_tokens.dart` generator — manual sync until a cross-language
* codegen pipeline lands.)
*
* Mirrors flutter_client/lib/theme/tokens.dart, dark surfaces + flat. Light
* theme support deferred to post-v1; the user's primary mode is dark.
*/
object FabledSwordTokens {
// Dark surface tokens.
val obsidian = Color(0xFF14171A)
val iron = Color(0xFF1E2228)
val slate = Color(0xFF2C313A)
val pewter = Color(0xFF3F4651)
val parchment = Color(0xFFE8E4D8)
val vellum = Color(0xFFC2BFB4)
val ash = Color(0xFF9C9A92)
// Flat (theme-mode-independent) tokens.
val moss = Color(0xFF4A5D3F) // primary action
val bronze = Color(0xFF8B7355) // secondary action
val oxblood = Color(0xFF6B2118) // destructive action
val warning = Color(0xFF8B6F1E)
val error = Color(0xFFC04A1F)
val info = Color(0xFF3D5A6E)
val accent = Color(0xFF4A6B5C) // forest-teal — NEVER for actions
val onAction = Color(0xFFE8E4D8)
// Corner radii.
val radiusSm = 4.dp
val radiusMd = 8.dp
val radiusLg = 12.dp
val radiusXl = 16.dp
}
@@ -0,0 +1,71 @@
package com.fabledsword.minstrel.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
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.text.googlefonts.Font
import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.unit.sp
import com.fabledsword.minstrel.R
/**
* Google Fonts provider — fetches font files via Play Services Fonts at
* runtime, caches them across launches. Matches the Flutter client's
* `google_fonts` package behaviour (no bundled .ttf files in either tree).
*
* Per FabledSword design system:
* - Fraunces — display + headline (mythic serif)
* - Inter — body + label (clean sans for UI text)
* - JetBrains Mono — technical / monospace
* Weights are restricted to 400 (regular) and 500 (medium) only.
*/
private val GoogleFontProvider = GoogleFont.Provider(
providerAuthority = "com.google.android.gms.fonts",
providerPackage = "com.google.android.gms",
certificates = R.array.com_google_android_gms_fonts_certs,
)
private val FrauncesFont = GoogleFont("Fraunces")
private val InterFont = GoogleFont("Inter")
private val JetBrainsMonoFont = GoogleFont("JetBrains Mono")
private val Fraunces = FontFamily(
Font(googleFont = FrauncesFont, fontProvider = GoogleFontProvider, weight = FontWeight.W400, style = FontStyle.Normal),
Font(googleFont = FrauncesFont, fontProvider = GoogleFontProvider, weight = FontWeight.W500, style = FontStyle.Normal),
)
private val Inter = FontFamily(
Font(googleFont = InterFont, fontProvider = GoogleFontProvider, weight = FontWeight.W400, style = FontStyle.Normal),
Font(googleFont = InterFont, fontProvider = GoogleFontProvider, weight = FontWeight.W500, style = FontStyle.Normal),
)
private val JetBrainsMono = FontFamily(
Font(googleFont = JetBrainsMonoFont, fontProvider = GoogleFontProvider, weight = FontWeight.W400, style = FontStyle.Normal),
)
/**
* FabledSword Material 3 Typography mapping. Display/headline slots use
* Fraunces; title/body/label use Inter; the smallest label slots step down
* to JetBrains Mono for technical/code surfaces. Sizes follow Material 3
* defaults; the project's "Fraunces ≥18px" rule is satisfied because every
* Fraunces-bearing slot is 18sp or larger.
*/
val MinstrelTypography = Typography(
displayLarge = TextStyle(fontFamily = Fraunces, fontWeight = FontWeight.W500, fontSize = 32.sp),
displayMedium = TextStyle(fontFamily = Fraunces, fontWeight = FontWeight.W500, fontSize = 28.sp),
displaySmall = TextStyle(fontFamily = Fraunces, fontWeight = FontWeight.W500, fontSize = 24.sp),
headlineLarge = TextStyle(fontFamily = Fraunces, fontWeight = FontWeight.W500, fontSize = 22.sp),
headlineMedium = TextStyle(fontFamily = Fraunces, fontWeight = FontWeight.W500, fontSize = 20.sp),
headlineSmall = TextStyle(fontFamily = Fraunces, fontWeight = FontWeight.W500, fontSize = 18.sp),
titleLarge = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W500, fontSize = 18.sp),
titleMedium = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W500, fontSize = 16.sp),
titleSmall = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W500, fontSize = 14.sp),
bodyLarge = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W400, fontSize = 16.sp),
bodyMedium = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W400, fontSize = 14.sp),
bodySmall = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W400, fontSize = 12.sp),
labelLarge = TextStyle(fontFamily = Inter, fontWeight = FontWeight.W500, fontSize = 14.sp),
labelMedium = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.W400, fontSize = 12.sp),
labelSmall = TextStyle(fontFamily = JetBrainsMono, fontWeight = FontWeight.W400, fontSize = 11.sp),
)
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Google Fonts provider certificate hashes for downloadable fonts via
androidx.compose.ui.text.googlefonts.GoogleFont.Provider. Standard
values published by Google; copied verbatim from the AndroidX docs. -->
<resources>
<array name="com_google_android_gms_fonts_certs">
<item>@array/com_google_android_gms_fonts_certs_dev</item>
<item>@array/com_google_android_gms_fonts_certs_prod</item>
</array>
<string-array name="com_google_android_gms_fonts_certs_dev">
<item>MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpoyLcfobBPv6yyz8x1IxWWmF9c1IGN3vSL6BLNJEUyMEPzC2WZdwT4ZG2cuJTtzeETl6jWFKx68ETtZxNVHe9Iy9NMxEljDqVZ4y6+FlHaiYJqq3LcJpJVuKYz4kvOcyf3M0nDA8mUlVdfsOlw/H4uoNQ7VrAQUKB4kAyfxsKp/RZmnZSJ7+8Ag9aTC+oguTd1iFNuMqDUlpePo6CGuh73iKuq8mYvtdQQ0Yz+mF4j2YWB7Gj0R1k2cCAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod">
<item>MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAwEAAaOB1zCB1DAdBgNVHQ4EFgQUhzkS9E6G+x8U7eIYZVgWyN4j2u4wgaQGA1UdIwSBnDCBmYAUhzkS9E6G+x8U7eIYZVgWyN4j2u6heKR2MHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZIIJAMLgh0ZkSjCNMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABywqUAtNkXf2EVQuRGiI3pnNvIYx7N5xj4LMtloEdEqMpEcMa6Qe87qDx2hsArOR1nzQAFGsT/8YIIfX0fAJjQuP1lAcExSxVKbFICEvFBaWuhGgOOZ7CYzfHB6tEzJFLR2DQHQrXLT2HKDDhxhe9hKzqIRDSc5Hjr3jY5MMzfYM5lFvKK9pLqEsP6/Ad9SDhupcVoOWVrSCNKfRb6jpJbZuxJhCnq8tmlV4iy5tEW0a3VBYzpRoBdAaORWqHQTUlt+iL3aH7C5OxhgN/JuxvxXBL/3kkc0wK1ZNuk+sb4lNXmHnVqQYTcyowQHRPCRsPzCCl4ANULRpZjxAd0xUgg=</item>
</string-array>
</resources>
+1
View File
@@ -41,6 +41,7 @@ compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts", version = "1.7.5" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }