8.4 KiB
Storybook — Adding New Design System Pages
The storybook lives in features/tester and lets developers browse and validate
design system components at runtime on a device or emulator.
Architecture overview
storybook/
├── entity/
│ ├── StoryBookPage.kt ← sealed interface + all page state classes
│ ├── StoryBookUM.kt ← top-level UI model (current page, navigation)
│ └── StoryPageFactory.kt ← factory interface used by the list screen
├── page/
│ └── <component>/
│ ├── Build.kt ← creates the StoryPageFactory for this page
│ └── <Component>Story.kt ← the Composable that renders the showcase
├── ui/
│ ├── StoryBookListScreen.kt ← list of all stories (add your entry here)
│ └── StoryBookScreen.kt ← routes currentPage → correct Composable
└── viewmodel/
├── StoryBookViewModel.kt
└── StateUpdater.kt ← helper for stateful pages
Step-by-step: adding a new page
1. Declare the page type in StoryBookPage.kt
For a stateless showcase (no user interaction that mutates page state):
internal data object FooStory : StoryBookPage
For a stateful page (e.g. toggle between variants like NorthernLightsStory):
internal data class FooStory(
val selectedVariant: Variant,
val onVariantChange: (Variant) -> Unit,
) : StoryBookPage {
enum class Variant { A, B }
}
DS components section. If the page belongs to the DS components sub-list, implement [
DsStoryBookPage] instead ofStoryBookPagedirectly. The view model uses this marker to route back-navigation to the DS list rather than the root story list.DsComponentsListStoryitself stays onStoryBookPage, so back from the DS list still goes to the root.internal data class TangemLoaderStory( val selectedSize: TangemLoaderSize, val onSizeChange: (TangemLoaderSize) -> Unit, ) : DsStoryBookPage
2. Create page/foo/Build.kt
Stateless:
internal val fooStoryFactory: StoryPageFactory = StoryPageFactory { FooStory }
Stateful (use storyPageFactory + StateUpdater):
internal fun StateUpdater<FooStory>.build(): FooStory {
return FooStory(
selectedVariant = FooStory.Variant.A,
onVariantChange = { newVariant ->
updateStory { it.copy(selectedVariant = newVariant) }
},
)
}
internal val fooStoryFactory
get() = storyPageFactory(StateUpdater<FooStory>::build)
3. Create page/foo/FooStory.kt
Write a @Composable internal fun FooStory(...) that renders the showcase.
See Design guidelines below for layout advice.
Stateless example skeleton:
@Composable
internal fun FooStory(modifier: Modifier = Modifier) {
LazyColumn(modifier = modifier.fillMaxSize()) {
item("section_a") { /* ... */ }
}
}
Stateful example skeleton:
@Composable
internal fun FooStory(state: FooStory, modifier: Modifier = Modifier) {
// use state.selectedVariant, state.onVariantChange
}
4. Register in StoryBookScreen.kt
Add a branch to the when block.
Naming note: the entity type and the Composable function will share the same simple name (e.g.
FooStory). Kotlin resolves them correctly — the entity import is used in the pattern position, the function import is used as a call. This is the same pattern used forNorthernLightsStoryandButtonsStory.
import com.tangem.feature.tester.presentation.storybook.entity.FooStory
import com.tangem.feature.tester.presentation.storybook.page.foo.FooStory
when (storyState) {
StoryList -> StoryBookListScreen(state = state)
is NorthernLightsStory -> NorthernLightsStory(state = storyState)
ButtonsStory -> ButtonsStory()
FooStory -> FooStory() // stateless
is FooStory -> FooStory(storyState) // stateful (note `is`)
}
5. Register in StoryBookListScreen.kt
Add one entry to buildStories(). Every title must start with an emoji that
represents the component category — this makes the list easier to scan at a glance.
private fun buildStories() = listOf(
StoryItem(title = "🃏 Foo Component", factory = fooStoryFactory),
// existing entries...
)
Pick an emoji that reflects the component's visual nature or purpose, e.g.:
- Buttons → 🔘
- Background effects → 🌌
- Typography → 🔤
- Icons → 🎨
- Cards → 🃏
- Inputs / Text fields → ✏️
- Navigation → 🧭
- Loaders / Progress → ⏳
Design guidelines
Page layout rule (mandatory)
Every DS component page must show a SINGLE instance of the component at the top, with configuration controls below it for almost all of its parameters.
The storybook is an interactive playground, not a static catalog. Pages must NOT render a grid of every possible variant; instead, expose every meaningful parameter as a control and let the user pick the configuration.
Mapping parameter kinds to controls:
| Parameter kind | Control |
|---|---|
Enum-like (size, color, shape, type, variant) |
Chips / segmented selector |
Boolean (enabled, selected, withIcon) |
Toggle / switch |
| Selectable boolean state | Checkbox |
The selected values live in the page's StoryBookPage data class
(e.g. TangemLoaderStory(selectedSize, onSizeChange)) and are wired through
storyPageFactory + StateUpdater<T> (see Step 2).
Skeleton:
@Composable
internal fun FooStory(state: FooStory, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize().background(TangemTheme.colors2.surface.level1),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
// 1. Single component preview at the top
ComponentPreview(/* uses state.* */)
// 2. One control per configurable parameter below
SizeSelector(selected = state.selectedSize, onSelect = state.onSizeChange)
ShapeSelector(selected = state.selectedShape, onSelect = state.onShapeChange)
EnabledToggle(checked = state.isEnabled, onCheckedChange = state.onEnabledChange)
}
}
Stateless (data object) pages are reserved for components with no
configurable parameters at all.
See TangemLoaderStory and TangemBadgeStory for reference implementations.
Layout
Use a Column (or LazyColumn if the controls overflow vertically) as the
root, with the component preview on top and the controls grouped below.
Column(
modifier = modifier
.statusBarsPadding()
.fillMaxSize()
.background(TangemTheme.colors2.surface.level1),
verticalArrangement = Arrangement.spacedBy(24.dp),
) { /* preview, then controls */ }
Chip selector pattern
For enum-like parameters, use a pill-shaped row of chips. The selected chip
gets surface.level3; unselected chips stay on surface.level2.
val shape = RoundedCornerShape(50)
Row(
modifier = Modifier
.fillMaxWidth()
.clip(shape)
.background(TangemTheme.colors2.surface.level2)
.border(1.dp, TangemTheme.colors2.border.neutral.secondary, shape)
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
SomeEnum.entries.forEach { value ->
Chip(
label = value.name,
selected = value == state.selected,
onClick = { state.onSelect(value) },
modifier = Modifier.weight(1f),
)
}
}
See TangemLoaderStory (size selector) and TangemBadgeStory.ColorToggle
for reference implementations.
Colors
- Page background:
TangemTheme.colors2.surface.level1 - Sections that need a contrasting background (e.g. PrimaryInverse):
TangemTheme.colors2.surface.level2 - Section divider:
HorizontalDivider(color = TangemTheme.colors2.border.neutral.secondary)
Realistic text
Use representative text strings, not placeholders like "Btn". Pick labels that
match how the component would appear in the product (e.g. "Continue",
"Send payment", "Confirm").
DS component imports
All design system components (PrimaryTangemButton, TangemButtonSize, etc.)
live in com.tangem.core.ui.ds.* and are public, so they are directly
importable from the features/tester module.
Use com.tangem.core.ui.R for drawable resources (e.g. R.drawable.ic_tangem_24).