Design Tokens
All visual constants live in design-system-docs/tokens.json. A Node generator (scripts/gen-tokens.mjs) emits exactly six Kotlin files into ui/theme/generated/ (ColorTokens.kt, DimensionTokens.kt, TypographyTokens.kt, MotionTokens.kt, InputTokens.kt, ComponentDefaults.kt) from that single source. Dimens (ui/theme/Dimensions.kt) and Theme.kt consume the generated values and add derivations on top. Color.kt does NOT - it imports nothing from generated/ and still declares 30+ raw Color(0xFF...) literals in ALauncherColors, several of which duplicate tokens (StarGold is ColorTokens.Domain.ratingStar). Treat ALauncherColors as legacy to migrate off, never as a place to add a color.
The system exists because we caught the same bug class repeatedly: a default in UserPreferences disagreeing with a default in BoxArtStyleConfig disagreeing with a fromString fallback. One source ends that class permanently.
Hard rules
- Never add a
Color(0xFF...)literal to a Composable. If the color isn't already inColorTokens, add it totokens.jsonfirst, regenerate, then reference it. - Never add a
*.dpor*.spmagic number to a Composable if it could fit an existing token slot. CheckDimensionTokens.Spacing/Radius/Border/Icon/Dot/Avatar/Layout/Elevationfirst. - Constants only in tokens.json. If the value is computed from other tokens at runtime (HSV math, palette cascade, mode selection, scale multiplier), it stays hand-written in
Theme.kt/Dimens/UiScale. See "Constant vs derivation" below. - Enum-typed component defaults reference enum members by name.
components.boxArt.cornerRadiusis"MEDIUM", not8- the dp value lives inenums.BoxArtCornerRadius. - Never edit the files under
ui/theme/generated/directly. The next generator run erases your edit. Changetokens.json, regenerate.
Files
| Path | Role |
|---|---|
design-system-docs/tokens.json |
Source of truth - edit this |
design-system-docs/tokens.schema.json |
JSON Schema for IDE autocomplete |
scripts/gen-tokens.mjs |
Generator - Node, zero deps |
app/src/main/kotlin/com/nendo/argosy/ui/theme/generated/*.kt |
Generated Kotlin (6 files) - never hand-edit |
app/src/main/kotlin/com/nendo/argosy/ui/theme/ArgosyTokens.kt |
ArgosyThemeTokens / argosyThemeTokens - the surface + text ramp every V2 primitive reads via LocalArgosyTheme |
scripts/ci/smell-rules.json |
raw-dp-literal - the enforced half of hard rule 2 |
The generator emits Kotlin only. The design side reads tokens via Penpot, whose token names mirror this tree.
Workflow: adding or changing a token
- Edit
design-system-docs/tokens.jsonin the right bucket - Run
node scripts/gen-tokens.mjs - Diff the generated files; verify the change matches intent
- Reference the new value from your consumer (Theme.kt, a Composable, Dimens, etc.)
- Commit
tokens.jsonAND the generated diffs together - they MUST stay in sync
ASPIRATIONAL, NOT BUILT: a CI job failing when node scripts/gen-tokens.mjs && git diff --exit-code is non-empty. .github/workflows/build.yml has build / test / rules / lint and nothing regenerates tokens. Keeping tokens.json and the generated Kotlin in sync is on the author.
What IS enforced is the raw-dp-literal rule in scripts/ci/smell-rules.json, run by the blocking rules job on pull requests. It flags any newly added N.dp literal under app/src/main/**/ui/**/*.kt; 0.dp is excepted and ui/theme/** plus Dimens*.kt are excluded. The whole rules job is skipped when the PR carries the maintainer-applied rules-exempt label - that label is the escape hatch, not a rewrite of the rule.
Buckets
color.scheme.{dark, light}
Material 3 role tokens: primary, secondary, surface, surfaceVariant, surfaceElevated, background, onSurface, outline, outlineVariant. No tertiary - see "Tertiary color is intentionally absent" below. Always mode-paired. Consumed by createDarkColorScheme / createLightColorScheme in Theme.kt.
color.scheme.debugOverrides.{dark, light}
Per-mode overrides that win in debug builds. Currently only primary (Orange in debug, the regular scheme.dark.primary in release).
color.semantic.{dark, light}
Success / warning / info / progress. Mode-paired. Consumed by SemanticColors data class through LocalLauncherTheme.semanticColors.
color.domain
Narrow-purpose colors where the hue carries meaning: ratingStar, difficulty, trophyAmber, favoriteStar, socialBrand.accent, presence.{online, away, offline}, battery.low, code.background, achievementTier.{hardcore, softcore}, and completion.{playing, beaten, completed, retired, never} (each with {dark, light}). A value may be a flat hex, a { color, alpha } object, a { dark, light } pair, or a nested group containing any of those.
color.accentPresets
Array of unnamed { dark, light } pairs for the accent-color picker. Empty by default; add entries to expand the preset list.
dimension.*
spacing.{xs..xxl}-{ base, floor }pairs; UI-scaled at runtime with a minimum floor inDimensradius,border,icon,dot,avatar- flat integer dp valueslayout- component dimensions (game card sizes, header/footer heights, modal widths, settings item min height)elevation- Material 3 elevation dp scaleuiScale-{ min, max, default, step }for the user UI-scale preference
typography.*
Per Material 3 style: fontFamily, fontSize (sp), lineHeight (sp), fontWeight (100-900 integer), optional letterSpacing. Maps 1:1 to the M3 type scale slots.
motion.spring.* / motion.tween.*
Springs are { dampingRatio, stiffness }. Tweens are durations in milliseconds. Generator emits both raw constants and pre-built AnimationSpec<*> instances.
input.*
debounce.* timings in ms; scrollPaddingPercent as a 0-1 float. Not strictly "design tokens" but live here because they're system-wide UI behavior constants.
enums.*
Each enum entry: { type, values }. Types:
marker- values isstring[]of member names (e.g.BoxArtBorderStyle: ["SOLID", "GLASS", "GRADIENT"])dp/px- values is{ MEMBER: integer }alpha- values is{ MEMBER: float 0-1 }aspectRatio- values is{ MEMBER: [num, denom] }alpha+shadow- values is{ MEMBER: { alpha, isShadow } }
Enum member names MUST match the Kotlin enum entries they mirror - which live wherever the enum is declared, NOT all in one file (GradientPreset is in data/cache/GradientModels.kt, not UserPreferencesRepository.kt).
The generator does NOT check Kotlin. renderValue in scripts/gen-tokens.mjs only validates that a component default's string appears in the JSON-side enums.* member list. Nothing reads a .kt file, so a Kotlin rename that drifts from tokens.json generates cleanly and fails at compile time instead.
components.*
Per-component default values. Component name → flat field map. Enum-typed fields contain a member name string; the generator looks the field up in fieldEnumMap inside the script and emits EnumName.MEMBER. Non-enum scalars are numbers, booleans, or strings.
Constant vs derivation
A token is a constant - a value with no runtime dependencies. It goes in tokens.json.
A derivation is a runtime computation over tokens or state. It stays hand-written in Theme.kt. Examples:
| Looks like a knob | Actually a derivation |
|---|---|
MaterialTheme.colorScheme.primaryContainer |
toContainerDark(primary) - HSV math |
LauncherThemeConfig.focusGlowColor |
palette.effectivePrimary.copy(alpha = 0.4f) in ProvideArgosyThemeLocals |
ArgosyPalette.effectivePrimary |
rawPrimary ?: if (isDarkTheme) defaultPrimary else defaultPrimaryDark |
ArgosyPalette.effectiveSecondary |
rawSecondary ?: effectivePrimary (cascade) |
BoxArtStyleConfig.accentColor |
palette.effectivePrimary |
Dimens.spacingMd |
maxOf(base * uiScale, floor).dp |
BoxArtStyleConfig.glowAlpha |
BoxArtGlowStrength.MEDIUM.alpha (flattened enum) |
If a value is computed, do NOT tokenize the output - tokenize its inputs.
focusGlowAlpha is the cautionary case, not the exemplar. ComponentDefaults.Launcher.focusGlowAlpha = 0.4f exists in tokens.json and in the generated Kotlin and has ZERO consumers; ProvideArgosyThemeLocals hardcodes 0.4f instead. A token nobody reads is a ghost that drifts silently - tokenizing an input only pays off if the derivation actually reads the token.
Regenerator gotchas
- Floats with whole-number values (
1,0) need anfsuffix in Kotlin. The generator'sisFloatFieldheuristic inscripts/gen-tokens.mjscatches field names containingalpha/scale/saturation/ratio/percent. If you add a new Float-typed field with a different name, extend that list or expect a Kotlin type mismatch. - Color alpha is emitted as 8-digit
Color(0xAARRGGBB)in Kotlin. - Empty arrays (e.g.
accentPresets: []) are valid and emitlistOf()in Kotlin. - New enum? Add it to both
enums.*ANDfieldEnumMap/enumNameMapinscripts/gen-tokens.mjs. The Kotlin import for the enum class also needs to land in the generator's import list insideemitComponentDefaults.
Anti-patterns (still live in the tree, not yet remediated)
private val goldColor = Color(0xFFFFD700)declared locally in a screen file → useColorTokens.Domain.ratingStar.ALauncherColors.StarGoldinColor.ktis the same value again.Color(0xFF4CAF50)for "synced" / "online" / "charging" → useColorTokens.Semantic.{Dark, Light}.successorColorTokens.Domain.Presence.onlinedepending on intent. Currently inDualGameDetailUpperScreen.kt,DualGameDetailLowerScreen.kt,SaveChannelModal.kt,StateSlotRow.kt,SystemStatusBar.kt,RASettingsSection.ktandCompanionContent.kt.Color(0xFF6366F1)(Tailwind indigo-500) → useColorTokens.Domain.SocialBrand.accent. Currently inInlineMarkdown.ktandFeedEventDetailScreen.kt.padding(15.dp)whenspacingMd = 16exists → useDimens.spacingMd. If12.dprecurs (it does), argue for a newspacingMs = 12token.- Adding a knob to
BoxArtStyleConfigwithout also adding it totokens.components.boxArt→ the data-class default and the JSON default WILL drift, and you have just recreated the bug class this system exists to prevent. - Editing a file under
ui/theme/generated/directly → nextnode scripts/gen-tokens.mjserases the change.
Tertiary color is intentionally absent
tertiaryColor is a stored, settable, but never-read user preference (the tertiaryColor field on the aggregated UserPreferences in UserPreferencesRepository.kt). It is intentionally NOT in tokens.json. If you find code referencing or trying to revive a user-selectable tertiary, that is a bug to remove, not a feature to wire up.